Compare commits

..

1 commit

Author SHA1 Message Date
cd5ec164dc Màj du framework js 2022-01-11 10:17:17 +01:00
68 changed files with 1103 additions and 1458 deletions

View file

@ -1 +0,0 @@
localhost

View file

@ -1 +0,0 @@
Kadenios <kadenios@localhost>

View file

@ -1 +0,0 @@
insecure-secret-key

View file

@ -1 +0,0 @@
kadenios@localhost

1
.envrc
View file

@ -1 +0,0 @@
use nix

2
.gitignore vendored
View file

@ -11,9 +11,7 @@
venv/ venv/
.python-version .python-version
pyrightconfig.json
*.sqlite3 *.sqlite3
.vscode .vscode
.direnv

View file

@ -1,11 +0,0 @@
diff --git a/src/authens/utils.py b/src/authens/utils.py
index 7306506..36063b6 100644
--- a/src/authens/utils.py
+++ b/src/authens/utils.py
@@ -16,7 +16,7 @@ def get_cas_client(request):
service_url=urlunparse(
(request.scheme, request.get_host(), request.path, "", "", "")
),
- server_url="https://cas.eleves.ens.fr/",
+ server_url="https://cas-eleves.dgnum.eu/",
)

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Klub Dev ENS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -15,7 +15,7 @@ Debian et dérivées (Ubuntu, ...) :
sudo apt-get install python3-pip python3-dev python3-venv sqlite3 sudo apt-get install python3-pip python3-dev python3-venv sqlite3
Si vous décidez d'utiliser un environnement virtuel Python (virtualenv; Si vous décidez d'utiliser un environnement virtuel Python (virtualenv;
fortement conseillé), déplacez-vous dans le dossier où est installé kadenios fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF
(le dossier où se trouve ce README), et créez-le maintenant : (le dossier où se trouve ce README), et créez-le maintenant :
python3 -m venv venv python3 -m venv venv
@ -26,18 +26,11 @@ Pour l'activer, il faut taper
depuis le même dossier. depuis le même dossier.
Une autre solution est d'utiliser [`pyenv`](https://github.com/pyenv/pyenv) et
[`pyenv-virtualenv`](https://github.com/pyenv/pyenv-virtualenv).
pyenv install 3.7.3
pyenv virtualenv 3.7.3 kadenios
pyenv local kadenios
Vous pouvez maintenant installer les dépendances Python depuis le fichier Vous pouvez maintenant installer les dépendances Python depuis le fichier
`requirements-dev.txt` : `requirements-devel.txt` :
pip install -U pip pip install -U pip # parfois nécessaire la première fois
pip install -r requirements-dev.txt pip install -r requirements-devel.txt
Nous avons un git hook de pre-commit pour formatter et vérifier que votre code Nous avons un git hook de pre-commit pour formatter et vérifier que votre code
vérifie nos conventions. Pour bénéficier des mises à jour du hook, préférez vérifie nos conventions. Pour bénéficier des mises à jour du hook, préférez
@ -53,11 +46,11 @@ Il ne vous reste plus qu'à initialiser les modèles de Django :
Il vous faut ensuite créer un superutilisateur : Il vous faut ensuite créer un superutilisateur :
./manage.py createadmin {username} {password} --superuser ./manage.py createsuperuser
Vous êtes prêts à développer ! Lancer Kadenios en faisant Vous êtes prêts à développer ! Lancer Kadenios en faisant
./manage.py runserver python manage.py runserver
## Fonctionnalités ## Fonctionnalités

View file

@ -1,210 +0,0 @@
"""
Django settings for the kadenios project
"""
from pathlib import Path
from loadcredential import Credentials
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
credentials = Credentials(env_prefix="KADENIOS_")
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# WARNING: keep the secret key used in production secret!
SECRET_KEY = credentials["SECRET_KEY"]
# WARNING: don't run with debug turned on in production!
DEBUG = credentials.get_json("DEBUG", False)
ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", [])
ADMINS = credentials.get_json("ADMINS", [])
###
# List the installed applications
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"shared.IgnoreSrcStaticFilesConfig",
"background_task",
"shared",
"elections",
"faqs",
"authens",
]
###
# List the installed middlewares
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
###
# The main url configuration
ROOT_URLCONF = "app.urls"
###
# Template configuration:
# - Django Templating Language is used
# - Application directories can be used
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
###
# WSGI application configuration
WSGI_APPLICATION = "app.wsgi.application"
###
# E-Mail configuration
DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"]
EMAIL_HOST = credentials.get("EMAIL_HOST", "localhost")
EMAIL_HOST_PASSWORD = credentials.get("EMAIL_HOST_PASSWORD", "")
EMAIL_HOST_USER = credentials.get("EMAIL_HOST_USER", "")
EMAIL_USE_SSL = credentials.get("EMAIL_USE_SSL", False)
SERVER_EMAIL = credentials["SERVER_EMAIL"]
###
# Default primary key field type
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
###
# Database configuration
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = credentials.get_json(
"DATABASES",
{
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
},
)
###
# Authentication configuration
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
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
###
# Internationalization configuration
# -> https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "Europe/Paris"
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGES = [
("fr", _("Français")),
("en", _("Anglais")),
]
LOCALE_PATHS = [BASE_DIR / "shared" / "locale"]
###
# Static files (CSS, JavaScript, Images) configuration
# -> https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "/static/"
STATIC_ROOT = credentials["STATIC_ROOT"]
###
# Background tasks configuration
# -> https://django4-background-tasks.readthedocs.io/en/latest/#settings
BACKGROUND_TASK_RUN_ASYNC = True
BACKGROUND_TASK_ASYNC_THREADS = 4
if DEBUG:
# Print the e-mails in the console
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
INSTALLED_APPS += [
"debug_toolbar",
"django_browser_reload",
]
MIDDLEWARE += [
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
]
INTERNAL_IPS = ["127.0.0.1"]
DEBUG_TOOLBAR_CONFIG = {"INSERT_BEFORE": "</footer>"}

View file

@ -1,67 +0,0 @@
{
sources ? import ./npins,
pkgs ? import sources.nixpkgs { },
}:
let
nix-pkgs = import sources.nix-pkgs { inherit pkgs; };
python3 = pkgs.python3.override {
packageOverrides = _: _: {
inherit (nix-pkgs)
django-background-tasks
django-browser-reload
django-bulma-forms
django-translated-fields
loadcredential
pyrage
;
authens = nix-pkgs.authens.overridePythonAttrs (old: {
patches = [ ./01-authens.patch ];
});
};
};
in
{
devShell = pkgs.mkShell {
name = "cas-eleves.dev";
packages = [
(python3.withPackages (ps: [
ps.django
ps.ipython
ps.django-stubs
ps.markdown
ps.numpy
ps.networkx
ps.authens
ps.django-background-tasks
ps.django-browser-reload
ps.django-bulma-forms
ps.django-debug-toolbar
ps.django-translated-fields
ps.loadcredential
ps.pyrage
]))
pkgs.gettext
pkgs.gtranslator
];
env = {
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
KADENIOS_DEBUG = "true";
KADENIOS_STATIC_ROOT = builtins.toString ./.static;
};
shellHook = ''
if [ ! -d .static ]; then
mkdir .static
fi
'';
};
}

View file

@ -14,9 +14,6 @@ class ElectionForm(forms.ModelForm):
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
assert cleaned_data is not None
if cleaned_data["start_date"] < timezone.now(): if cleaned_data["start_date"] < timezone.now():
self.add_error( self.add_error(
"start_date", _("Impossible de faire débuter l'élection dans le passé") "start_date", _("Impossible de faire débuter l'élection dans le passé")

View file

@ -1,25 +0,0 @@
# Generated by Django 4.2.12 on 2024-07-11 12:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('elections', '0033_inactive_users'),
]
operations = [
migrations.AddField(
model_name='vote',
name='pseudonymous_user',
field=models.CharField(blank=True, max_length=16),
),
migrations.AlterField(
model_name='vote',
name='user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -1,44 +0,0 @@
# Generated by Django 4.2.12 on 2024-07-11 12:24
import random
from django.db import migrations
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def generate_password(size):
random.seed()
return "".join(random.choice(alphabet) for _ in range(size))
def pseudonymize_users(apps, _):
Question = apps.get_model("elections", "Question")
Vote = apps.get_model("elections", "Vote")
votes = set()
for q in Question.objects.filter(election__tallied=True).prefetch_related(
"options__vote_set"
):
for v in q.voters.all():
pseudonym = generate_password(16)
for opt in q.options.all():
for vote in opt.vote_set.filter(user=v):
vote.pseudonymous_user = pseudonym
vote.user = None
votes.add(vote)
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
class Migration(migrations.Migration):
dependencies = [
("elections", "0034_vote_pseudonymous_user_alter_vote_user"),
]
operations = [migrations.RunPython(pseudonymize_users)]

View file

@ -1,32 +1,23 @@
from typing import Any
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Q, QuerySet from django.db.models import Q
from django.http.request import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from elections.typing import AuthenticatedRequest
from .models import Election, Option, Question from .models import Election, Option, Question
class AdminOnlyMixin(PermissionRequiredMixin): class AdminOnlyMixin(PermissionRequiredMixin):
"""Restreint l'accès aux admins""" """Restreint l'accès aux admins"""
request: AuthenticatedRequest
permission_required = "elections.election_admin" permission_required = "elections.election_admin"
class SelectElectionMixin: class SelectElectionMixin:
"""Sélectionne automatiquement les foreignkeys voulues""" """Sélectionne automatiquement les foreignkeys voulues"""
model: type def get_queryset(self):
qs = super().get_queryset()
def get_queryset(self) -> QuerySet:
qs = super().get_queryset() # pyright: ignore
if self.model is Question: if self.model is Question:
return qs.select_related("election") return qs.select_related("election")
elif self.model is Option: elif self.model is Option:
@ -37,19 +28,15 @@ class SelectElectionMixin:
class RestrictAccessMixin(SelectElectionMixin): class RestrictAccessMixin(SelectElectionMixin):
"""Permet de restreindre l'accès à des élections/questions/options""" """Permet de restreindre l'accès à des élections/questions/options"""
f_prefixes = { f_prefixes = {Election: "", Question: "election__", Option: "question__election__"}
Election: "",
Question: "election__",
Option: "question__election__",
}
def get_f_prefix(self) -> str: def get_f_prefix(self):
return self.f_prefixes.get(self.model, "") return self.f_prefixes.get(self.model, None)
def get_filters(self) -> dict[str, Any]: def get_filters(self):
return {} return {}
def get_queryset(self) -> QuerySet: def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()
if self.model in self.f_prefixes: if self.model in self.f_prefixes:
return qs.filter(**self.get_filters()) return qs.filter(**self.get_filters())
@ -60,7 +47,7 @@ class RestrictAccessMixin(SelectElectionMixin):
class OpenElectionOnlyMixin(RestrictAccessMixin): class OpenElectionOnlyMixin(RestrictAccessMixin):
"""N'autorise la vue que lorsque l'élection est ouverte""" """N'autorise la vue que lorsque l'élection est ouverte"""
def get_filters(self) -> dict[str, Any]: def get_filters(self):
f_prefix = self.get_f_prefix() f_prefix = self.get_f_prefix()
# On ne peut modifier que les élections qui n'ont pas commencé, et # On ne peut modifier que les élections qui n'ont pas commencé, et
# accessoirement qui ne sont pas dépouillées ou archivées # accessoirement qui ne sont pas dépouillées ou archivées
@ -80,7 +67,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
def get_next_url(self): def get_next_url(self):
return reverse("kadenios") return reverse("kadenios")
def get_filters(self) -> dict[str, Any]: def get_filters(self):
filters = super().get_filters() filters = super().get_filters()
# TODO: change the way we collect the user according to the model used # TODO: change the way we collect the user according to the model used
filters[self.get_f_prefix() + "created_by"] = self.request.user filters[self.get_f_prefix() + "created_by"] = self.request.user
@ -90,7 +77,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
class CreatorOnlyEditMixin(CreatorOnlyMixin): class CreatorOnlyEditMixin(CreatorOnlyMixin):
"""Permet au créateurice de modifier l'élection implicitement""" """Permet au créateurice de modifier l'élection implicitement"""
def get_filters(self) -> dict[str, Any]: def get_filters(self):
# On ne peut modifier que les élections qui n'ont pas commencé # On ne peut modifier que les élections qui n'ont pas commencé
filters = super().get_filters() filters = super().get_filters()
filters[self.get_f_prefix() + "start_date__gt"] = timezone.now() filters[self.get_f_prefix() + "start_date__gt"] = timezone.now()
@ -100,7 +87,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin):
class ClosedElectionMixin(CreatorOnlyMixin): class ClosedElectionMixin(CreatorOnlyMixin):
"""Permet d'agir sur une élection terminée""" """Permet d'agir sur une élection terminée"""
def get_filters(self) -> dict[str, Any]: def get_filters(self):
f_prefix = self.get_f_prefix() f_prefix = self.get_f_prefix()
# L'élection doit être terminée et non archivée # L'élection doit être terminée et non archivée
filters = super().get_filters() filters = super().get_filters()
@ -115,11 +102,9 @@ class NotArchivedMixin:
ou dont on est l'admin ou dont on est l'admin
""" """
request: HttpRequest def get_queryset(self):
def get_queryset(self) -> QuerySet:
user = self.request.user user = self.request.user
qs = super().get_queryset() # pyright: ignore qs = super().get_queryset()
if user.is_authenticated: if user.is_authenticated:
return qs.filter(Q(archived=False, visible=True) | Q(created_by=user)) return qs.filter(Q(archived=False, visible=True) | Q(created_by=user))

View file

@ -1,16 +1,12 @@
from typing import TYPE_CHECKING
from translated_fields import TranslatedFieldWithFallback from translated_fields import TranslatedFieldWithFallback
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models, transaction from django.db import models, transaction
from django.http.request import HttpRequest
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from shared.auth import CONNECTION_METHODS from shared.auth import CONNECTION_METHODS
from shared.auth.utils import generate_password
from shared.json import Serializer from shared.json import Serializer
from shared.utils import choices_length from shared.utils import choices_length
@ -29,20 +25,12 @@ from .utils import (
ValidateFunctions, ValidateFunctions,
) )
if TYPE_CHECKING:
from django.db.models.fields.related_descriptors import ManyRelatedManager
from django.utils.functional import _StrPromise
# ############################################################################# # #############################################################################
# Models regarding an election # Models regarding an election
# ############################################################################# # #############################################################################
class Election(models.Model): class Election(models.Model):
registered_voters: models.Manager["User"]
questions: models.Manager["Question"]
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255)) name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
short_name = models.SlugField(_("nom bref"), unique=True) short_name = models.SlugField(_("nom bref"), unique=True)
description = TranslatedFieldWithFallback( description = TranslatedFieldWithFallback(
@ -100,9 +88,6 @@ class Election(models.Model):
class Question(Serializer, models.Model): class Question(Serializer, models.Model):
options: models.Manager["Option"]
duels: models.Manager["Duel"]
election = models.ForeignKey( election = models.ForeignKey(
Election, related_name="questions", on_delete=models.CASCADE Election, related_name="questions", on_delete=models.CASCADE
) )
@ -128,42 +113,22 @@ class Question(Serializer, models.Model):
serializable_fields = ["text_en", "text_fr", "type"] serializable_fields = ["text_en", "text_fr", "type"]
def is_form_valid(self, vote_form) -> bool: def is_form_valid(self, vote_form):
validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type]) validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type])
return vote_form.is_valid() and validate_function(vote_form) return vote_form.is_valid() and validate_function(vote_form)
@transaction.atomic @transaction.atomic
def cast_ballot(self, user: "User", vote_form) -> None: def cast_ballot(self, user, vote_form):
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type]) cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
cast_function(user, vote_form) cast_function(user, vote_form)
@transaction.atomic @transaction.atomic
def tally(self) -> None: def tally(self):
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type]) tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
tally_function(self) tally_function(self)
@transaction.atomic
def pseudonymize(self):
"""
Generates a random id for each voter
"""
options = list(self.options.prefetch_related("vote_set"))
votes: set[Vote] = set()
for v in self.voters.all():
pseudonym = generate_password(16)
for opt in options:
for vote in opt.vote_set.filter(user=v):
vote.pseudonymous_user = pseudonym
vote.user = None
votes.add(vote)
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
@property @property
def results(self) -> str: def results(self):
return render_to_string( return render_to_string(
f"elections/results/{self.vote_type}_export.txt", {"question": self} f"elections/results/{self.vote_type}_export.txt", {"question": self}
) )
@ -185,16 +150,14 @@ class Question(Serializer, models.Model):
def vote_type(self): def vote_type(self):
return BALLOT_TYPE[self.type] return BALLOT_TYPE[self.type]
def __str__(self) -> str: def __str__(self):
return str(self.text) return self.text
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
class Option(Serializer, models.Model): class Option(Serializer, models.Model):
vote_set: models.Manager["Vote"]
question = models.ForeignKey( question = models.ForeignKey(
Question, related_name="options", on_delete=models.CASCADE Question, related_name="options", on_delete=models.CASCADE
) )
@ -205,7 +168,7 @@ class Option(Serializer, models.Model):
voters = models.ManyToManyField( voters = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
related_name="votes", related_name="votes",
through="elections.Vote", through="Vote",
blank=True, blank=True,
) )
# For now, we store the amount of votes received after the election is tallied # For now, we store the amount of votes received after the election is tallied
@ -219,13 +182,13 @@ class Option(Serializer, models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_abbr(self, default: str) -> str: def get_abbr(self, default):
return self.abbreviation or default return self.abbreviation or default
def __str__(self) -> str: def __str__(self):
if self.abbreviation: if self.abbreviation:
return f"{self.abbreviation} - {self.text}" return self.abbreviation + " - " + self.text
return str(self.text) return self.text
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
@ -233,22 +196,12 @@ class Option(Serializer, models.Model):
class Vote(models.Model): class Vote(models.Model):
option = models.ForeignKey(Option, on_delete=models.CASCADE) option = models.ForeignKey(Option, on_delete=models.CASCADE)
user = models.ForeignKey( user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True
)
pseudonymous_user = models.CharField(max_length=16, blank=True)
class Meta: class Meta:
ordering = ["option"] ordering = ["option"]
class RankedVote(Vote):
rank: "Rank"
class Meta:
abstract = True
class Rank(models.Model): class Rank(models.Model):
vote = models.OneToOneField(Vote, on_delete=models.CASCADE) vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
rank = models.PositiveSmallIntegerField(_("rang de l'option")) rank = models.PositiveSmallIntegerField(_("rang de l'option"))
@ -276,10 +229,6 @@ class Duel(models.Model):
class User(AbstractUser): class User(AbstractUser):
cast_elections: "ManyRelatedManager[Election]"
cast_questions: "ManyRelatedManager[Question]"
votes: "ManyRelatedManager[Vote]"
election = models.ForeignKey( election = models.ForeignKey(
Election, Election,
related_name="registered_voters", related_name="registered_voters",
@ -291,30 +240,25 @@ class User(AbstractUser):
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None) has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
@property @property
def base_username(self) -> str: def base_username(self):
return "__".join(self.username.split("__")[1:]) return "__".join(self.username.split("__")[1:])
def can_vote(self, request: HttpRequest, election: Election) -> bool: def can_vote(self, request, election):
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
# ouvertes à tou·te·s # ouvertes à tou·te·s
if self.election is None: if self.election is None:
# If the user is connected via CAS, request.session["CASCONNECTED"] is set # If the user is connected via CAS, request.session["CASCONNECTED"] is set
# to True by authens # to True by authens
return not election.restricted and request.session.get( return not election.restricted and request.session.get("CASCONNECTED")
"CASCONNECTED", False
)
# Pour les élections restreintes, il faut y être associé # Pour les élections restreintes, il faut y être associé
return election.restricted and (self.election == election) return election.restricted and (self.election == election)
def is_admin(self, election: Election) -> bool: def get_prefix(self):
return election.created_by == self or self.is_staff
def get_prefix(self) -> str:
return self.username.split("__")[0] return self.username.split("__")[0]
@property @property
def connection_method(self) -> "_StrPromise": def connection_method(self):
method = self.username.split("__")[0] method = self.username.split("__")[0]
return CONNECTION_METHODS.get(method, _("identifiants spécifiques")) return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))

View file

@ -1,24 +1,27 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
MAIL_VOTERS = """Dear {full_name}, MAIL_VOTERS = (
"Dear {full_name},\n"
"\n"
"\n"
"Election URL: {election_url}\n"
"The election will take place from {start} to {end}.\n"
"\n"
"Your voter ID: {username}\n"
"Your password: {password}\n"
"\n"
"-- \n"
"Kadenios"
)
Election URL: {election_url} MAIL_VOTE_DELETED = (
The election will take place from {start} to {end}. "Dear {full_name},\n"
"\n"
Your voter ID: {username} "Your vote for {election_name} has been removed."
Your password: {password} "\n"
"-- \n"
-- "Kadenios"
Kadenios )
"""
MAIL_VOTE_DELETED = """Dear {full_name},
Your vote for {election_name} has been removed.
--
Kadenios
"""
QUESTION_TYPES = [ QUESTION_TYPES = [
("assentiment", _("Assentiment")), ("assentiment", _("Assentiment")),

View file

@ -5,16 +5,8 @@ from .utils import send_mail
@background @background
def send_election_mail(election_pk: int, subject: str, body: str, reply_to: str): def send_election_mail(election_pk, subject, body, reply_to):
election = Election.objects.get(pk=election_pk) election = Election.objects.get(pk=election_pk)
send_mail(election, subject, body, reply_to) send_mail(election, subject, body, reply_to)
election.sent_mail = True election.sent_mail = True
election.save(update_fields=["sent_mail"]) election.save(update_fields=["sent_mail"])
@background
def pseudonimize_election(election_pk: int):
election = Election.objects.get(pk=election_pk)
for q in election.questions.all():
q.pseudonymize()

View file

@ -4,66 +4,17 @@
{% block custom_js %} {% block custom_js %}
<script> <script>
const _fm = b => { (() => {
b.addEventListener('click', () => { const initButtons = (f, e) => {
const f = _$('form', _id(b.dataset.target), false); _$('.del', e).forEach(remove);
f.dataset.next = b.dataset.next; initModal(f, e);
f.dataset.origin = b.dataset.parent }
const d = JSON.parse(b.dataset.json); _$('.del').forEach(remove);
for (const [k, v] of Object.entries(d)) { submitForm(id('form-option'), initButtons);
_$(`[name='${k}']`, f, false).value = v; submitForm(id('form-question'), initButtons);
} })()
});
}
_$('.modal-button').forEach(_fm);
const _del = d => {
d.addEventListener('click', () => {
_get(d.dataset.url, r => {
if (r.success && r.action == 'delete') {
_id(d.dataset.target).remove()
}
if (r.message) {
_notif(r.message.content, r.message.class);
}
});
});
}
_$('.del').forEach(_del);
_$('form').forEach(f => {
f.addEventListener('submit', event => {
event.preventDefault();
_post(f.action, f, r => {
if (r.success) {
const e = document.createElement('div');
e.innerHTML = r.html;
// On initialise les boutons
_$('.modal-button', e).forEach(b => {
_om(b);
_fm(b);
});
_$('.del', e).forEach(_del);
if (r.action == 'create') {
_id(f.dataset.next).appendChild(e.firstElementChild);
} else if (r.action == 'update') {
const n = _id(f.dataset.origin);
n.parentNode.replaceChild(e.firstElementChild, n);
}
// On ferme le modal
document.documentElement.classList.remove('is-clipped');
_id(f.dataset.modal).classList.remove('is-active');
}
});
});
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -3,224 +3,176 @@
{% block custom_js %} {% block custom_js %}
{% if can_delete %} {% if can_delete %}
<script> <script>
_$('.modal-button').forEach(b => { _$('.modal-button').forEach(b => {
b.addEventListener('click', () => { b.addEventListener('click', () => {
const f = _$('form', _id(b.dataset.target), false); const f = _$('form', _id(b.dataset.target), false);
f.dataset.target = b.dataset.origin; f.dataset.target = b.dataset.origin;
_$('[name="delete"]', f, false).value = 'non'; _$('[name="delete"]', f, false).value = 'non';
}); });
}); });
_$('form').forEach(f => { _$('form').forEach(f => {
f.addEventListener('submit', event => { f.addEventListener('submit', event => {
event.preventDefault(); event.preventDefault();
if (_$('[name="delete"]', f, false).value == 'oui') { if (_$('[name="delete"]', f, false).value == 'oui') {
_get(f.action, r => { get(f.action).then(r => {
if (r.success && r.action == 'delete') { if (r.success && r.action == 'delete') {
{% if election.restricted %} {% if election.restricted %}
const r = _id(f.dataset.target); const r = _id(f.dataset.target);
_$('.modal-button', r, false).remove(); _$('.modal-button', r, false).remove();
const i = _$('.fas', r, false); const i = _$('.fas', r, false);
i.classList.remove('fa-check'); i.classList.remove('fa-check');
i.classList.add('fa-times'); i.classList.add('fa-times');
{% else %} {% else %}
_id(f.dataset.target).remove() id(f.dataset.target).remove()
{% endif %} {% endif %}
// On ferme le modal // On ferme le modal
document.documentElement.classList.remove('is-clipped');
_id(f.dataset.modal).classList.remove('is-active');
}
if (r.message) {
_notif(r.message.content, r.message.class);
}
});
} else {
document.documentElement.classList.remove('is-clipped'); document.documentElement.classList.remove('is-clipped');
_id(f.dataset.modal).classList.remove('is-active'); id(f.dataset.modal).classList.remove('is-active');
}
if (r.message) {
notify(r.message.content, r.message.class);
} }
}); });
}); } else {
document.documentElement.classList.remove('is-clipped');
id(f.dataset.modal).classList.remove('is-active');
}
});
});
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="level is-mobile"> <div class="level is-mobile">
{# Titre de l'élection #} {# Titre de l'élection #}
<div class="level-left is-flex-shrink-1 mr-3"> <div class="level-left is-flex-shrink-1 mr-3">
<h1 class="title">{{ election.name }}</h1> <h1 class="title">{{ election.name }}</h1>
</div>
<div class="level-right">
<div class="level-item">
<a class="button is-primary" href="{% if from_admin %}{% url 'election.admin' election.pk %}{% else %}{% url 'election.view' election.pk %}{% endif %}">
<span class="icon">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</div> </div>
<div class="level"> <div class="level-right">
<div class="level-left"> <div class="level-item">
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3> <a class="button is-primary" href="{% if from_admin %}{% url 'election.admin' election.pk %}{% else %}{% url 'election.view' election.pk %}{% endif %}">
</div>
</div>
<hr>
{# Précisions sur les modalités de vote #}
{% if election.vote_restrictions %}
<div class="message is-warning">
<div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
</div>
{% endif %}
<div class="message is-warning">
<div class="message-body">
{% if election.restricted %}
{% trans "Seules les personnes présentes sur cette liste peuvent voter, vous avez dû recevoir un mail avec vos identifiants de connexion." %}
{% 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>
{% if can_vote or is_admin %}
<div class="columns is-centered">
<div class="column is-narrow">
{% if can_delete %}
{% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
{% endif %}
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>{% trans "Nom" %}</th>
<th class="has-text-centered">{% trans "Vote enregistré" %}</th>
{% if can_delete %}
<th class="has-text-centered">{% trans "Supprimer" %}</th>
{% endif %}
</tr>
</thead>
<tbody>
{% if election.restricted %}
{% for v in election.registered_voters.all %}
<tr id="v_{{ forloop.counter }}">
<td>{{ v.full_name }} ({{ v.base_username }})</td>
{% if v in voters %}
<td class="has-text-centered">
<span class="icon">
<i class="fas fa-check"></i>
</span>
</td>
{% if can_delete %}
<td class="has-text-centered">
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
<a class="tag is-danger modal-button delete-vote" data-target="modal-delete" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}" data-title="{{ v_delete }}" data-origin="v_{{ forloop.counter }}">
<span class="icon">
<i class="fas fa-user-minus"></i>
</span>
</a>
</td>
{% endif %}
{% else %}
<td class="has-text-centered">
<span class="icon">
<i class="fas fa-times"></i>
</span>
</td>
{% if can_delete %}
<td></td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
{% else %}
{% for v in voters %}
<tr id="v_{{ forloop.counter }}">
<td>{{ v.full_name }} ({{ v.base_username }})</td>
<td class="has-text-centered">
<span class="icon">
<i class="fas fa-check"></i>
</span>
</td>
{% if can_delete %}
<td class="has-text-centered">
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
<a class="tag is-danger modal-button delete-vote" data-target="modal-delete" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}" data-title="{{ v_delete }}" data-origin="v_{{ forloop.counter }}">
<span class="icon">
<i class="fas fa-user-minus"></i>
</span>
</a>
</td>
{% endif %}
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="notification is-danger is-light has-text-centered">
<b>{% trans "Pour voir la liste des votant·e·s vous devez être connecté·e." %}</b>
{% if election.restricted %}
<br>
<span class="icon"> <span class="icon">
<i class="fas fa-info-circle"></i> <i class="fas fa-undo-alt"></i>
</span> </span>
<i>{% trans "La connexion doit s'effectuer via les identifiants reçus par mail." %}</i> <span>{% trans "Retour" %}</span>
{% endif %} </a>
</div> </div>
</div>
</div>
<div class="columns is-centered"> <div class="level">
<div class="column is-half"> <div class="level-left">
<div class="tile is-ancestor"> <h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
<div class="tile is-parent"> </div>
{% if election.restricted %} </div>
<a class="tile is-child notification is-primary" href="{% url 'auth.election' election.pk %}?next={% url 'election.voters' election.pk %}"> <hr>
<div class="subtitle has-text-centered mb-2">
<span class="icon-text"> {# Précisions sur les modalités de vote #}
<span class="icon has-text-white"> {% if election.vote_restrictions %}
<i class="fas fa-unlock"></i> <div class="message is-warning">
</span> <div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
<span class="ml-3">{% trans "Connexion par identifiants" %}</span> </div>
</span> {% endif %}
</div>
</a> <div class="message is-warning">
{% else %} <div class="message-body">
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}?next={% url 'election.voters' election.pk %}"> {% if election.restricted %}
<div class="subtitle has-text-centered mb-2"> {% trans "Seules les personnes présentes sur cette liste peuvent voter, vous avez dû recevoir un mail avec vos identifiants de connexion." %}
<span class="icon-text"> {% else %}
<span class="icon has-text-white"> {% 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." %}
<i class="fas fa-school"></i> {% endif %}
</span> </div>
<span class="ml-3">{% trans "Connexion via CAS" %}</span> </div>
</span>
</div> <div class="columns is-centered">
</a> <div class="column is-narrow">
{% endif %} {% if can_delete %}
</div> {% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
</div> {% endif %}
</div>
</div> <table class="table is-striped is-fullwidth">
{% endif %} <thead>
<tr>
<th>{% trans "Nom" %}</th>
<th class="has-text-centered">{% trans "Vote enregistré" %}</th>
{% if can_delete %}
<th class="has-text-centered">{% trans "Supprimer" %}</th>
{% endif %}
<tr>
</thead>
<tbody>
{% if election.restricted %}
{% for v in election.registered_voters.all %}
<tr id="v_{{ forloop.counter }}">
<td>{{ v.full_name }} ({{ v.base_username }})</td>
{% if v in voters %}
<td class="has-text-centered">
<span class="icon">
<i class="fas fa-check"></i>
</span>
</td>
{% if can_delete %}
<td class="has-text-centered">
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
<a class="tag is-danger modal-button delete-vote" data-target="modal-delete" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}" data-title="{{ v_delete }}" data-origin="v_{{ forloop.counter }}">
<span class="icon">
<i class="fas fa-user-minus"></i>
</span>
</a>
</td>
{% endif %}
{% else %}
<td class="has-text-centered">
<span class="icon">
<i class="fas fa-times"></i>
</span>
</td>
{% if can_delete %}
<td></td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
{% else %}
{% for v in voters %}
<tr id="v_{{ forloop.counter }}">
<td>{{ v.full_name }} ({{ v.base_username }})</td>
<td class="has-text-centered">
<span class="icon">
<i class="fas fa-check"></i>
</span>
</td>
{% if can_delete %}
<td class="has-text-centered">
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
<a class="tag is-danger modal-button delete-vote" data-target="modal-delete" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}" data-title="{{ v_delete }}" data-origin="v_{{ forloop.counter }}">
<span class="icon">
<i class="fas fa-user-minus"></i>
</span>
</a>
</td>
{% endif %}
</tr>
{% endfor %}
{% endif %}
</table>
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -51,15 +51,14 @@
</div> </div>
<div class="modal" id="modal-confirm"> <div class="modal" id="modal-confirm">
<div class="modal-background"></div> <div class="modal-background" data-closes="modal-confirm"></div>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">{% trans "Confirmation du vote" %}</p> <p class="modal-card-title">{% trans "Confirmation du vote" %}</p>
<a class="delete" aria-label="close"></a> <a class="delete" aria-label="close" data-closes="modal-confirm"></a>
</header> </header>
<section class="modal-card-body" id="modal-body"> <section class="modal-card-body" id="modal-body"></section>
</section>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<button class="button is-fullwidth is-outlined is-primary is-light" type="submit"> <button class="button is-fullwidth is-outlined is-primary is-light" type="submit">
@ -69,7 +68,7 @@
<span>{% trans "Confirmer" %}</span> <span>{% trans "Confirmer" %}</span>
</button> </button>
<a class="button is-primary button-close"> <a class="button is-primary button-close" data-closes="modal-confirm">
<span class="icon"> <span class="icon">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</span> </span>

View file

@ -26,7 +26,7 @@
// On déplace les options // On déplace les options
while (rank_zones[next_rank].childElementCount > 1) { while (rank_zones[next_rank].childElementCount > 1) {
const t = rank_zones[next_rank].lastChild; const t = rank_zones[next_rank].lastChild;
const i = _id(t.dataset.input); const i = id(t.dataset.input);
i.value = j.toString(); i.value = j.toString();
rank_zones[j].append(t); rank_zones[j].append(t);
} }
@ -50,7 +50,7 @@
_$('.control .input').forEach(i => { _$('.control .input').forEach(i => {
// On rajoute la tuile dans le classement ou dans les non classées // On rajoute la tuile dans le classement ou dans les non classées
const r = parseInt(i.value); const r = parseInt(i.value);
const t = _id(`tile-${i.id}`); const t = id(`tile-${i.id}`);
if (!(typeof r === 'undefined') && r > 0 && r <= nb_options) { if (!(typeof r === 'undefined') && r > 0 && r <= nb_options) {
rank_zones[r].appendChild(t); rank_zones[r].appendChild(t);
@ -82,8 +82,8 @@
const d = event.target.closest('.drop-zone'); const d = event.target.closest('.drop-zone');
const r = d.dataset.rank; const r = d.dataset.rank;
const t = _id(data); const t = id(data);
const i = _id(t.dataset.input); const i = id(t.dataset.input);
// Si on ne change pas de rang, pas besoin de déplacer l'option // Si on ne change pas de rang, pas besoin de déplacer l'option
if (i.value != r) { if (i.value != r) {
@ -99,7 +99,7 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Affiche le modal et remplit le récapitulatif // Affiche le modal et remplit le récapitulatif
_id('confirm-button').addEventListener('click', () => { id('confirm-button').addEventListener('click', () => {
const ranks = new Array(nb_options + 1); const ranks = new Array(nb_options + 1);
_$('.control .input').forEach(i => { _$('.control .input').forEach(i => {
@ -127,7 +127,7 @@
trs += `<tr><th>${j}</th><td><div>${option_list}</div></td></tr>\n` trs += `<tr><th>${j}</th><td><div>${option_list}</div></td></tr>\n`
} }
_id('modal-body').innerHTML = ` id('modal-body').innerHTML = `
<table class="table is-fullwidth is-striped"> <table class="table is-fullwidth is-striped">
<thead> <thead>
<tr> <tr>
@ -142,10 +142,10 @@
}); });
// Change le mode de remplissge de formulaire (input vs drag & drop) // Change le mode de remplissge de formulaire (input vs drag & drop)
_id('change-method').addEventListener('click', () => { id('change-method').addEventListener('click', () => {
const h = _id('hide-form'); const h = id('hide-form');
const d = _id('drag-zone'); const d = id('drag-zone');
const b = _id('change-method'); const b = id('change-method');
// On échange ce qui est visible // On échange ce qui est visible
h.classList.toggle('is-hidden'); h.classList.toggle('is-hidden');
@ -162,10 +162,10 @@
}); });
// Initialise les éléments pour le formulaire interactif // Initialise les éléments pour le formulaire interactif
$unranked = _id('unranked'); $unranked = id('unranked');
for (let i = 1; i <= nb_options; i++) { for (let i = 1; i <= nb_options; i++) {
rank_zones[i] = _id(`rank-${i}`); rank_zones[i] = id(`rank-${i}`);
} }
_$('.control .input').forEach(i => { _$('.control .input').forEach(i => {

View file

@ -2,30 +2,16 @@
{% load i18n %} {% load i18n %}
{% block extra_head %} {% block custom_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { id('confirm-button').addEventListener('click', () => {
_id('confirm-button').addEventListener('click', () => { let selected_rows = '';
let selected_rows = '';
_$('.checkbox input').forEach(c => { _$('.checkbox input').filter(c => c.checked).forEach(c => {
if (c.checked) { selected_rows += `<tr><td>${c.nextSibling.textContent.trim()}</td></tr>\n`;
selected_rows += `<tr><td>${c.nextSibling.textContent.trim()}</td></tr>\n`;
}
});
_id('modal-body').innerHTML = `
<table class="table is-fullwidth">
<thead>
<tr>
<th>{% trans "Option(s) selectionnée(s)" %}</th>
</tr>
</thead>
<tbody>
${selected_rows}
</tbody>
</table>`;
}); });
id('modal-body').innerHTML = `<table class="table is-fullwidth"><thead><tr><th>{% trans "Option(s) selectionnée(s)" %}</th></tr></thead><tbody>${selected_rows}</tbody></table>`;
}); });
</script> </script>

View file

@ -1,5 +1,3 @@
from typing import TYPE_CHECKING
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
@ -7,10 +5,7 @@ from django.utils.translation import gettext_lazy as _
from .test_utils import create_election from .test_utils import create_election
if TYPE_CHECKING: User = get_user_model()
from elections.typing import User
else:
User = get_user_model()
class UserTests(TestCase): class UserTests(TestCase):
@ -45,11 +40,8 @@ class UserTests(TestCase):
session["CASCONNECTED"] = True session["CASCONNECTED"] = True
session.save() session.save()
assert session.session_key is not None
# On sauvegarde le cookie de session # On sauvegarde le cookie de session
session_cookie_name = settings.SESSION_COOKIE_NAME session_cookie_name = settings.SESSION_COOKIE_NAME
self.client.cookies[session_cookie_name] = session.session_key self.client.cookies[session_cookie_name] = session.session_key
self.assertFalse(self.cas_user.can_vote(self.client, self.election_1)) self.assertFalse(self.cas_user.can_vote(self.client, self.election_1))

View file

@ -1,16 +1,11 @@
from typing import TYPE_CHECKING from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from .test_utils import create_election from .test_utils import create_election
if TYPE_CHECKING: User = get_user_model()
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
class AdminViewsTest(TestCase): class AdminViewsTest(TestCase):

View file

@ -1,7 +0,0 @@
from django.http.request import HttpRequest
from elections.models import User
class AuthenticatedRequest(HttpRequest):
user: User

View file

@ -1,46 +1,31 @@
import csv import csv
import io import io
import smtplib import smtplib
from typing import TYPE_CHECKING, TypeGuard
import networkx as nx import networkx as nx
import numpy as np import numpy as np
from networkx.algorithms.dag import ancestors, descendants from networkx.algorithms.dag import ancestors, descendants
from numpy._typing import NDArray
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import File
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.core.validators import validate_email from django.core.validators import validate_email
from django.forms import BaseFormSet
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from shared.auth.utils import generate_password from shared.auth.utils import generate_password
if TYPE_CHECKING:
from elections.forms import RankVoteForm, SelectVoteForm
from elections.models import Election, Question, RankedVote, Vote
from elections.typing import User
# ############################################################################# # #############################################################################
# Classes pour différencier les différents types de questions # Classes pour différencier les différents types de questions
# ############################################################################# # #############################################################################
def has_rank(v: "Vote") -> TypeGuard["RankedVote"]:
return hasattr(v, "rank")
class CastFunctions: class CastFunctions:
"""Classe pour enregistrer les votes""" """Classe pour enregistrer les votes"""
@staticmethod def cast_select(user, vote_form):
def cast_select(user: "User", vote_form: "BaseFormSet[SelectVoteForm]"):
"""On enregistre un vote classique""" """On enregistre un vote classique"""
selected, n_selected = [], [] selected, n_selected = [], []
for v in vote_form: for v in vote_form:
@ -52,8 +37,7 @@ class CastFunctions:
user.votes.add(*selected) user.votes.add(*selected)
user.votes.remove(*n_selected) user.votes.remove(*n_selected)
@staticmethod def cast_rank(user, vote_form):
def cast_rank(user: "User", vote_form: "BaseFormSet[RankVoteForm]"):
"""On enregistre un vote par classement""" """On enregistre un vote par classement"""
from .models import Rank, Vote from .models import Rank, Vote
@ -69,8 +53,7 @@ class CastFunctions:
for v in vote_form: for v in vote_form:
vote = votes[v.instance] vote = votes[v.instance]
if hasattr(vote, "rank"):
if has_rank(vote):
vote.rank.rank = v.cleaned_data["rank"] vote.rank.rank = v.cleaned_data["rank"]
ranks_update.append(vote.rank) ranks_update.append(vote.rank)
else: else:
@ -83,8 +66,7 @@ class CastFunctions:
class TallyFunctions: class TallyFunctions:
"""Classe pour gérer les dépouillements""" """Classe pour gérer les dépouillements"""
@staticmethod def tally_select(question):
def tally_select(question: "Question") -> None:
"""On dépouille un vote classique""" """On dépouille un vote classique"""
from .models import Option from .models import Option
@ -104,8 +86,7 @@ class TallyFunctions:
Option.objects.bulk_update(options, ["nb_votes", "winner"]) Option.objects.bulk_update(options, ["nb_votes", "winner"])
@staticmethod def tally_schultze(question):
def tally_schultze(question: "Question") -> None:
"""On dépouille un vote par classement et on crée la matrice des duels""" """On dépouille un vote par classement et on crée la matrice des duels"""
from .models import Duel, Option, Rank from .models import Duel, Option, Rank
@ -121,12 +102,12 @@ class TallyFunctions:
else: else:
ranks_by_user[user] = [r] ranks_by_user[user] = [r]
ballots: list[NDArray[np.int_]] = [] ballots = []
# Pour chaque votant·e, on regarde son classement # Pour chaque votant·e, on regarde son classement
for user in ranks_by_user: for user in ranks_by_user:
votes = ranks_by_user[user] votes = ranks_by_user[user]
ballot = np.zeros((nb_options, nb_options), dtype=int) ballot = np.zeros((nb_options, nb_options))
for i in range(nb_options): for i in range(nb_options):
for j in range(i): for j in range(i):
@ -140,9 +121,6 @@ class TallyFunctions:
# des duels # des duels
duels = sum(ballots) duels = sum(ballots)
# As ballots is not empty, sum cannot be 0
assert duels != 0
# Configuration du graphe # Configuration du graphe
graph = nx.DiGraph() graph = nx.DiGraph()
@ -185,11 +163,11 @@ class TallyFunctions:
# le plus faible # le plus faible
min_weight = min(nx.get_edge_attributes(graph, "weight").values()) min_weight = min(nx.get_edge_attributes(graph, "weight").values())
min_edges = [] min_edges = []
for u, v in graph.edges(): for (u, v) in graph.edges():
if graph[u][v]["weight"] == min_weight: if graph[u][v]["weight"] == min_weight:
min_edges.append((u, v)) min_edges.append((u, v))
for u, v in min_edges: for (u, v) in min_edges:
graph.remove_edge(u, v) graph.remove_edge(u, v)
# Les options gagnantes sont celles encore présentes dans le graphe # Les options gagnantes sont celles encore présentes dans le graphe
@ -203,31 +181,29 @@ class TallyFunctions:
class ValidateFunctions: class ValidateFunctions:
"""Classe pour valider les formsets selon le type de question""" """Classe pour valider les formsets selon le type de question"""
@staticmethod def always_true(vote_form):
def always_true(_) -> bool: """Retourne True pour les votes sans validation particulière"""
"""Renvoie True pour les votes sans validation particulière"""
return True return True
@staticmethod def unique_selected(vote_form):
def unique_selected(vote_form: "BaseFormSet[SelectVoteForm]") -> bool:
"""Vérifie qu'une seule option est choisie""" """Vérifie qu'une seule option est choisie"""
nb_selected = 0
nb_selected = sum(v.cleaned_data["selected"] for v in vote_form) for v in vote_form:
nb_selected += v.cleaned_data["selected"]
if nb_selected == 0: if nb_selected == 0:
vote_form._non_form_errors.append( # pyright: ignore vote_form._non_form_errors.append(
ValidationError(_("Vous devez sélectionnner une option.")) ValidationError(_("Vous devez sélectionnner une option."))
) )
return False return False
elif nb_selected > 1: elif nb_selected > 1:
vote_form._non_form_errors.append( # pyright: ignore vote_form._non_form_errors.append(
ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option.")) ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option."))
) )
return False return False
return True return True
@staticmethod def limit_ranks(vote_form):
def limit_ranks(vote_form: "BaseFormSet[RankVoteForm]"):
"""Limite le classement au nombre d'options""" """Limite le classement au nombre d'options"""
nb_options = len(vote_form) nb_options = len(vote_form)
valid = True valid = True
@ -253,13 +229,11 @@ class ValidateFunctions:
class ResultsData: class ResultsData:
"""Classe pour afficher des informations supplémentaires après la fin d'une élection""" """Classe pour afficher des informations supplémentaires après la fin d'une élection"""
@staticmethod def select(question):
def select(_: "Question") -> str:
"""On renvoie l'explication des couleurs""" """On renvoie l'explication des couleurs"""
return render_to_string("elections/results/select.html") return render_to_string("elections/results/select.html")
@staticmethod def rank(question):
def rank(question: "Question") -> str:
"""On récupère la matrice des résultats et on l'affiche""" """On récupère la matrice des résultats et on l'affiche"""
duels = question.duels.all() duels = question.duels.all()
options = list(question.options.all()) options = list(question.options.all())
@ -296,40 +270,38 @@ class ResultsData:
class BallotsData: class BallotsData:
"""Classe pour afficher les bulletins d'une question""" """Classe pour afficher les bulletins d'une question"""
@staticmethod def select(question):
def select(question: "Question") -> str:
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin""" """Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
from .models import Vote from .models import Vote
votes = Vote.objects.filter(option__question=question) votes = Vote.objects.filter(option__question=question).select_related("user")
options = list(question.options.all()) options = list(question.options.all())
ballots = {} ballots = {}
for v in votes: for v in votes:
ballot = ballots.get(v.pseudonymous_user, [False] * len(options)) ballot = ballots.get(v.user, [False] * len(options))
ballot[options.index(v.option)] = True ballot[options.index(v.option)] = True
ballots[v.pseudonymous_user] = ballot ballots[v.user] = ballot
return render_to_string( return render_to_string(
"elections/ballots/select.html", "elections/ballots/select.html",
{"options": options, "ballots": sorted(ballots.values(), reverse=True)}, {"options": options, "ballots": sorted(ballots.values(), reverse=True)},
) )
@staticmethod def rank(question):
def rank(question: "Question") -> str:
"""Renvoie un tableau contenant les classements des options par bulletin""" """Renvoie un tableau contenant les classements des options par bulletin"""
from .models import Rank from .models import Rank
options = list(question.options.all()) options = list(question.options.all())
ranks = Rank.objects.select_related("vote").filter( ranks = Rank.objects.select_related("vote__user").filter(
vote__option__in=options vote__option__in=options
) )
ranks_by_user = {} ranks_by_user = {}
for r in ranks: for r in ranks:
user = r.vote.pseudonymous_user user = r.vote.user
if user in ranks_by_user: if user in ranks_by_user:
ranks_by_user[user].append(r.rank) ranks_by_user[user].append(r.rank)
else: else:
@ -346,7 +318,7 @@ class BallotsData:
# ############################################################################# # #############################################################################
def create_users(election: "Election", csv_file: File): def create_users(election, csv_file):
"""Crée les votant·e·s pour l'élection donnée, en remplissant les champs """Crée les votant·e·s pour l'élection donnée, en remplissant les champs
`username`, `election` et `full_name`. `username`, `election` et `full_name`.
""" """
@ -359,9 +331,10 @@ def create_users(election: "Election", csv_file: File):
users = [ users = [
User( User(
election=election, election=election,
username=f"{election.pk}__{username}", username=f"{election.id}__{username}",
email=email, email=email,
full_name=full_name, full_name=full_name,
is_active=False,
) )
for (username, full_name, email) in reader for (username, full_name, email) in reader
] ]
@ -369,7 +342,7 @@ def create_users(election: "Election", csv_file: File):
User.objects.bulk_create(users) User.objects.bulk_create(users)
def check_csv(csv_file: File): def check_csv(csv_file):
"""Vérifie que le fichier donnant la liste de votant·e·s est bien formé""" """Vérifie que le fichier donnant la liste de votant·e·s est bien formé"""
try: try:
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8")) dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
@ -422,14 +395,15 @@ def check_csv(csv_file: File):
return errors return errors
def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> None: def send_mail(election, subject, body, reply_to):
"""Envoie le mail d'annonce de l'élection avec identifiants et mot de passe """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é. aux votant·e·s, le mdp est généré en même temps que le mail est envoyé.
""" """
User = get_user_model()
# On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un # On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un
voters = list(election.registered_voters.exclude(has_valid_email=True)) voters = list(election.registered_voters.exclude(has_valid_email=True))
e_url = reverse("election.view", args=[election.pk]) e_url = reverse("election.view", args=[election.id])
url = f"https://vote.eleves.ens.fr{e_url}" url = f"https://vote.eleves.ens.fr{e_url}"
start = election.start_date.strftime("%d/%m/%Y %H:%M %Z") start = election.start_date.strftime("%d/%m/%Y %H:%M %Z")
end = election.end_date.strftime("%d/%m/%Y %H:%M %Z") end = election.end_date.strftime("%d/%m/%Y %H:%M %Z")
@ -452,17 +426,18 @@ def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> N
to=[v.email], to=[v.email],
reply_to=[reply_to], reply_to=[reply_to],
# On modifie l'adresse de retour d'erreur # On modifie l'adresse de retour d'erreur
headers={"From": "Kadenios <klub-dev@ens.fr>"}, headers={"Return-Path": "kadenios@www.eleves.ens.fr"},
), ),
v, v,
) )
) )
for m, v in messages: for (m, v) in messages:
try: try:
m.send() m.send()
v.has_valid_email = True
except smtplib.SMTPException: except smtplib.SMTPException:
v.has_valid_email = False v.has_valid_email = False
else:
v.has_valid_email = True
v.save() User.objects.bulk_update(voters, ["password", "has_valid_email"])

View file

@ -1,7 +1,7 @@
import csv import csv
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.db import transaction from django.db import transaction
@ -19,7 +19,6 @@ from django.views.generic import (
View, View,
) )
from elections.typing import AuthenticatedRequest
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
from shared.views import BackgroundUpdateView, TimeMixin from shared.views import BackgroundUpdateView, TimeMixin
@ -41,15 +40,10 @@ from .mixins import (
) )
from .models import Election, Option, Question, Vote from .models import Election, Option, Question, Vote
from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES
from .tasks import pseudonimize_election, send_election_mail from .tasks import send_election_mail
from .utils import create_users from .utils import create_users
if TYPE_CHECKING: User = get_user_model()
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
# TODO: access control *everywhere* # TODO: access control *everywhere*
@ -59,8 +53,6 @@ else:
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
object: Election
model = Election model = Election
form_class = ElectionForm form_class = ElectionForm
success_message = _("Élection créée avec succès !") success_message = _("Élection créée avec succès !")
@ -69,7 +61,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
def get_success_url(self): def get_success_url(self):
return reverse("election.admin", args=[self.object.pk]) return reverse("election.admin", args=[self.object.pk])
def form_valid(self, form: ElectionForm): def form_valid(self, form):
# We need to add the short name and the creator od the election # We need to add the short name and the creator od the election
form.instance.short_name = slugify( form.instance.short_name = slugify(
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
@ -83,11 +75,11 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
model = Election model = Election
pattern_name = "election.list" pattern_name = "election.list"
def get_object(self): def get_object(self, queryset=None):
obj: Election = super().get_object() obj = self.get_object()
# On ne peut supprimer que les élections n'ayant pas eu de vote et dont # On ne peut supprimer que les élections n'ayant pas eu de vote et dont
# le mail d'annonce n'a pas été fait # le mail d'annonce n'a pas été fait
if obj.voters.exists() or obj.sent_mail: if obj.voters.exists() or obj.send_election_mail:
raise Http404 raise Http404
return obj return obj
@ -97,8 +89,6 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView): class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
object: Election
model = Election model = Election
template_name = "elections/election_admin.html" template_name = "elections/election_admin.html"
@ -125,7 +115,7 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
success_message = _("Élection visible !") success_message = _("Élection visible !")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.election: Election = self.get_object() self.election = self.get_object()
self.election.visible = True self.election.visible = True
self.election.save() self.election.save()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -242,8 +232,6 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView): class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
voter: User
model = Election model = Election
def get_message(self): def get_message(self):
@ -300,9 +288,6 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
election.tallied = True election.tallied = True
election.time_tallied = timezone.now() election.time_tallied = timezone.now()
election.save() election.save()
pseudonimize_election(election.pk)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -431,7 +416,7 @@ class ElectionView(NotArchivedMixin, DetailView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["current_time"] = timezone.now() context["current_time"] = timezone.now()
if user.is_authenticated and isinstance(user, User): if user.is_authenticated:
context["can_vote"] = user.can_vote(self.request, context["election"]) context["can_vote"] = user.can_vote(self.request, context["election"])
context["cast_questions"] = user.cast_questions.all() context["cast_questions"] = user.cast_questions.all()
context["has_voted"] = user.cast_elections.filter( context["has_voted"] = user.cast_elections.filter(
@ -459,9 +444,7 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
election = context["election"] election = context["election"]
voters = list(election.voters.all()) voters = list(election.voters.all())
if user.is_authenticated and isinstance(user, User): if user.is_authenticated:
context["can_vote"] = user.can_vote(self.request, context["election"])
context["is_admin"] = user.is_admin(election)
can_delete = ( can_delete = (
election.created_by == user election.created_by == user
and election.end_date < timezone.now() and election.end_date < timezone.now()
@ -491,8 +474,6 @@ class ElectionBallotsView(NotArchivedMixin, DetailView):
class VoteView(OpenElectionOnlyMixin, DetailView): class VoteView(OpenElectionOnlyMixin, DetailView):
request: AuthenticatedRequest
model = Question model = Question
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):

5
kadenios/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib.staticfiles.apps import StaticFilesConfig
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]

1
kadenios/settings/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
secret.py

151
kadenios/settings/common.py Normal file
View file

@ -0,0 +1,151 @@
"""
Paramètres communs entre dev et prod
"""
import os
import sys
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
# #############################################################################
# Secrets
# #############################################################################
try:
from . import secret
except ImportError:
raise ImportError(
"The secret.py file is missing.\n"
"For a development environment, simply copy secret_example.py"
)
def import_secret(name):
"""
Shorthand for importing a value from the secret module and raising an
informative exception if a secret is missing.
"""
try:
return getattr(secret, name)
except AttributeError:
raise RuntimeError("Secret missing: {}".format(name))
SECRET_KEY = import_secret("SECRET_KEY")
ADMINS = import_secret("ADMINS")
SERVER_EMAIL = import_secret("SERVER_EMAIL")
EMAIL_HOST = import_secret("EMAIL_HOST")
# #############################################################################
# Paramètres par défaut pour Django
# #############################################################################
DEBUG = False
TESTING = len(sys.argv) > 1 and sys.argv[1] == "test"
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"kadenios.apps.IgnoreSrcStaticFilesConfig",
"background_task",
"shared",
"elections",
"faqs",
"authens",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "kadenios.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "kadenios.wsgi.application"
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
DEFAULT_FROM_EMAIL = "Kadenios <klub-dev@ens.fr>"
# #############################################################################
# Paramètres d'authentification
# #############################################################################
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
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
# #############################################################################
# Paramètres de langage
# #############################################################################
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "Europe/Paris"
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGES = [
("fr", _("Français")),
("en", _("Anglais")),
]
LOCALE_PATHS = [os.path.join(BASE_DIR, "shared", "locale")]
# #############################################################################
# Paramètres des fichiers statiques
# #############################################################################
STATIC_URL = "/static/"

View file

@ -0,0 +1,55 @@
"""
Paramètre pour le développement local
"""
import os
from .common import * # noqa
from .common import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
# #############################################################################
# Paramètres Django
# #############################################################################
ALLOWED_HOSTS = []
DEBUG = True
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
STATIC_URL = "/static/"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
# Use the default cache backend for local development
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
# Pas besoin de sécurité en local
AUTH_PASSWORD_VALIDATORS = []
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
# #############################################################################
# Paramètres pour la Django Debug Toolbar
# #############################################################################
def show_toolbar(request):
"""
On active la debug-toolbar en mode développement local sauf :
- dans l'admin où ça ne sert pas à grand chose;
- si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver
sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal
qui lance `./manage.py runserver`.
"""
env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None))
return DEBUG and not env_no_ddt and not request.path.startswith("/admin/")
if not TESTING:
INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar}

68
kadenios/settings/prod.py Normal file
View file

@ -0,0 +1,68 @@
"""
Paramètres pour la mise en production
"""
import os
from .common import * # noqa
from .common import BASE_DIR, import_secret
# #############################################################################
# Secrets de production
# #############################################################################
REDIS_PASSWD = import_secret("REDIS_PASSWD")
REDIS_DB = import_secret("REDIS_DB")
REDIS_HOST = import_secret("REDIS_HOST")
REDIS_PORT = import_secret("REDIS_PORT")
DBNAME = import_secret("DBNAME")
DBUSER = import_secret("DBUSER")
DBPASSWD = import_secret("DBPASSWD")
# #############################################################################
# À modifier possiblement lors de la mise en production
# #############################################################################
ALLOWED_HOSTS = ["vote.eleves.ens.fr"]
STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static")
# #############################################################################
# Paramètres du cache
# #############################################################################
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://:{passwd}@{host}:{port}/{db}".format(
passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB
),
}
}
# #############################################################################
# Paramètres de la base de données
# #############################################################################
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": DBNAME,
"USER": DBUSER,
"PASSWORD": DBPASSWD,
"HOST": os.environ.get("DBHOST", ""),
}
}
# #############################################################################
# Paramètres Https
# #############################################################################
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_PRELOAD = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True

View file

@ -0,0 +1,14 @@
SECRET_KEY = "f*!6tw8c74)&k_&4$toiw@e=8m00xv_(tmjf9_#wq30wg_7n^8"
ADMINS = None
SERVER_EMAIL = "root@localhost"
EMAIL_HOST = None
DBUSER = "kadenios"
DBNAME = "kadenios"
DBPASSWD = "O1LxCADDA6Px5SiKvifjvdp3DSjfbp"
REDIS_PASSWD = "dummy"
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_HOST = "127.0.0.1"

View file

@ -1,5 +1,4 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
@ -17,8 +16,7 @@ urlpatterns = [
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [ urlpatterns += [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("__reload__/", include("django_browser_reload.urls")), ]
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if "debug_toolbar" in settings.INSTALLED_APPS: if "debug_toolbar" in settings.INSTALLED_APPS:
from debug_toolbar import urls as djdt_urls from debug_toolbar import urls as djdt_urls

View file

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kadenios.settings')
application = get_wsgi_application() application = get_wsgi_application()

View file

@ -5,7 +5,7 @@ import sys
def main(): def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kadenios.settings.local")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:

View file

@ -1,80 +0,0 @@
# Generated by npins. Do not modify; will be overwritten regularly
let
data = builtins.fromJSON (builtins.readFile ./sources.json);
version = data.version;
mkSource =
spec:
assert spec ? type;
let
path =
if spec.type == "Git" then
mkGitSource spec
else if spec.type == "GitRelease" then
mkGitSource spec
else if spec.type == "PyPi" then
mkPyPiSource spec
else if spec.type == "Channel" then
mkChannelSource spec
else
builtins.throw "Unknown source type ${spec.type}";
in
spec // { outPath = path; };
mkGitSource =
{
repository,
revision,
url ? null,
hash,
branch ? null,
...
}:
assert repository ? type;
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
# In the latter case, there we will always be an url to the tarball
if url != null then
(builtins.fetchTarball {
inherit url;
sha256 = hash;
})
else
assert repository.type == "Git";
let
urlToName =
url: rev:
let
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
short = builtins.substring 0 7 rev;
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
in
"${if matched == null then "source" else builtins.head matched}${appendShort}";
name = urlToName repository.url revision;
in
builtins.fetchGit {
url = repository.url;
rev = revision;
inherit name;
narHash = hash;
};
mkPyPiSource =
{ url, hash, ... }:
builtins.fetchurl {
inherit url;
sha256 = hash;
};
mkChannelSource =
{ url, hash, ... }:
builtins.fetchTarball {
inherit url;
sha256 = hash;
};
in
if version == 4 then
builtins.mapAttrs (_: mkSource) data.pins
else
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"

View file

@ -1,22 +0,0 @@
{
"pins": {
"nix-pkgs": {
"type": "Git",
"repository": {
"type": "Git",
"url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs.git"
},
"branch": "main",
"revision": "22e90684e355bdd1e257c661b6275c7490f8c50b",
"url": null,
"hash": "sha256-yEZAv3bK7+gxNM8/31ONwdPIXlyQ5QnNnPDnWl3bXZo="
},
"nixpkgs": {
"type": "Channel",
"name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre646460.0aeab749216e/nixexprs.tar.xz",
"hash": "0xa73bs0n28x731hf6ipqrlji0p3qf2a42vfm6g8snnhaab9mfwj"
}
},
"version": 4
}

View file

@ -1,3 +0,0 @@
[tool.pyright]
reportIncompatibleMethodOverride = false
reportIncompatibleVariableOverride = false

View file

@ -4,4 +4,5 @@ authens>=0.1b2
markdown markdown
numpy numpy
networkx networkx
python-csv
django-background-tasks django-background-tasks

View file

@ -1,5 +0,0 @@
from django.contrib.staticfiles.apps import StaticFilesConfig
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]

View file

@ -1,7 +1,6 @@
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.sites import AlreadyRegistered
if settings.DEBUG: if settings.DEBUG:
models = apps.get_models() models = apps.get_models()
@ -9,5 +8,5 @@ if settings.DEBUG:
for model in models: for model in models:
try: try:
admin.site.register(model) admin.site.register(model)
except AlreadyRegistered: except admin.sites.AlreadyRegistered:
pass pass

View file

@ -1,5 +1,3 @@
from .staticdefs import CONNECTION_METHODS from .staticdefs import CONNECTION_METHODS
__all__ = [ __all__ = [CONNECTION_METHODS]
"CONNECTION_METHODS",
]

View file

@ -1,15 +1,9 @@
from typing import TYPE_CHECKING
from authens.backends import ENSCASBackend from authens.backends import ENSCASBackend
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from django.core.exceptions import PermissionDenied
if TYPE_CHECKING: User = get_user_model()
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
class CASBackend(ENSCASBackend): class CASBackend(ENSCASBackend):
@ -24,12 +18,6 @@ class CASBackend(ENSCASBackend):
return User.objects.create_user(username=username, email=email, full_name=name) return User.objects.create_user(username=username, email=email, full_name=name)
def _get_or_create(self, cas_login, attributes):
try:
return super()._get_or_create(cas_login, attributes)
except ValueError:
raise PermissionDenied
class PwdBackend(ModelBackend): class PwdBackend(ModelBackend):
"""Password authentication""" """Password authentication"""

View file

@ -1,16 +1,11 @@
from typing import TYPE_CHECKING
from django import forms from django import forms
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth import forms as auth_forms from django.contrib.auth import forms as auth_forms
from django.contrib.auth import get_user_model
from django.core.validators import validate_email from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
if TYPE_CHECKING: User = get_user_model()
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
class ElectionAuthForm(forms.Form): class ElectionAuthForm(forms.Form):

View file

@ -4,10 +4,12 @@ import random
# Fonctions universelles # Fonctions universelles
# ############################################################################# # #############################################################################
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def generate_password(size=15): def generate_password(size=15):
random.seed() random.seed()
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
password = ""
for i in range(size):
password += random.choice(alphabet)
return "".join(random.choice(alphabet) for _ in range(size)) return password

View file

@ -1,25 +1,17 @@
from typing import TYPE_CHECKING from django.contrib.auth import get_user_model
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.db.models import QuerySet
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, FormView, ListView, TemplateView from django.views.generic import CreateView, FormView, ListView, TemplateView
from elections.typing import AuthenticatedRequest
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
from .utils import generate_password from .utils import generate_password
if TYPE_CHECKING: User = get_user_model()
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model()
# ############################################################################# # #############################################################################
@ -33,8 +25,6 @@ class StaffMemberMixin(UserPassesTestMixin):
n'est pas connectée, renvoie sur la page d'authentification n'est pas connectée, renvoie sur la page d'authentification
""" """
request: AuthenticatedRequest
def test_func(self): def test_func(self):
return self.request.user.is_active and self.request.user.is_staff return self.request.user.is_active and self.request.user.is_staff
@ -95,7 +85,7 @@ class AccountListView(StaffMemberMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
qs: QuerySet = self.get_queryset() # pyright: ignore qs = self.get_queryset()
ctx["cas_users"] = qs.filter(username__startswith="cas__") ctx["cas_users"] = qs.filter(username__startswith="cas__")
ctx["pwd_users"] = qs.filter(username__startswith="pwd__") ctx["pwd_users"] = qs.filter(username__startswith="pwd__")
@ -153,16 +143,16 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
# Election admin # Election admin
election_perm = Permission.objects.get(codename="election_admin") election_perm = Permission.objects.get(codename="election_admin")
if form.cleaned_data["election_admin"]: if form.cleaned_data["election_admin"]:
election_perm.user_set.add(user) # pyright: ignore election_perm.user_set.add(user)
else: else:
election_perm.user_set.remove(user) # pyright: ignore election_perm.user_set.remove(user)
# FAQ admin # FAQ admin
faq_perm = Permission.objects.get(codename="faq_admin") faq_perm = Permission.objects.get(codename="faq_admin")
if form.cleaned_data["faq_admin"]: if form.cleaned_data["faq_admin"]:
faq_perm.user_set.add(user) # pyright: ignore faq_perm.user_set.add(user)
else: else:
faq_perm.user_set.remove(user) # pyright: ignore faq_perm.user_set.remove(user)
user.save() user.save()
return super().form_valid(form) return super().form_valid(form)

View file

@ -1,7 +1,4 @@
from typing import Any
from django.http import JsonResponse from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic.base import TemplateResponseMixin, View from django.views.generic.base import TemplateResponseMixin, View
@ -51,17 +48,10 @@ class JsonMessageMixin:
def get_data(self, **kwargs): def get_data(self, **kwargs):
kwargs.update(message=self.get_message()) kwargs.update(message=self.get_message())
return super().get_data(**kwargs) # pyright: ignore return super().get_data(**kwargs)
class TypedResponseMixin(TemplateResponseMixin): class JsonDetailView(JsonMixin, SingleObjectMixin, TemplateResponseMixin, View):
def render_to_response(
self, context: dict[str, Any], **response_kwargs: Any
) -> TemplateResponse:
return super().render_to_response(context, **response_kwargs) # pyright: ignore
class JsonDetailView(JsonMixin, SingleObjectMixin, TypedResponseMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
@ -79,7 +69,7 @@ class JsonDeleteView(JsonMessageMixin, JsonDetailView):
@method_decorator(require_POST, name="dispatch") @method_decorator(require_POST, name="dispatch")
class JsonCreateView( class JsonCreateView(
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView
): ):
def render_to_json(self, **kwargs): def render_to_json(self, **kwargs):
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
@ -91,7 +81,7 @@ class JsonCreateView(
@method_decorator(require_POST, name="dispatch") @method_decorator(require_POST, name="dispatch")
class JsonUpdateView( class JsonUpdateView(
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView
): ):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()

View file

@ -2,22 +2,21 @@
# Copyright (C) 2021 Klub Dev ENS # Copyright (C) 2021 Klub Dev ENS
# This file is distributed under the same license as the kadenios package. # This file is distributed under the same license as the kadenios package.
# Klub Dev ENS <klub-dev@ens.fr>, 2021. # Klub Dev ENS <klub-dev@ens.fr>, 2021.
# Tom Hubrecht <tom.hubrecht@ens.fr>, 2022.
# #
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: 0.1\n" "Project-Id-Version: 0.1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-02 21:17+0200\n" "POT-Creation-Date: 2021-12-20 20:08+0100\n"
"PO-Revision-Date: 2022-04-02 21:23+0200\n" "PO-Revision-Date: 2021-12-20 20:10+0100\n"
"Last-Translator: Tom Hubrecht <tom.hubrecht@ens.fr>\n" "Last-Translator: Test Translator <test@translator>\n"
"Language-Team: French <klub-dev@ens.fr>\n" "Language-Team: \n"
"Language: fr\n" "Language: en\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 3.0.1\n" "X-Generator: Poedit 3.0\n"
#: elections/forms.py:19 #: elections/forms.py:19
msgid "Impossible de faire débuter l'élection dans le passé" msgid "Impossible de faire débuter l'élection dans le passé"
@ -61,7 +60,7 @@ msgstr "Delete the vote of {} ({}) ?"
#: elections/forms.py:118 elections/templates/elections/admin/option.html:6 #: elections/forms.py:118 elections/templates/elections/admin/option.html:6
#: elections/templates/elections/admin/question.html:20 #: elections/templates/elections/admin/question.html:20
#: elections/templates/elections/election_voters.html:111 #: elections/templates/elections/election_voters.html:110
msgid "Supprimer" msgid "Supprimer"
msgstr "Delete" msgstr "Delete"
@ -177,23 +176,23 @@ msgstr "Name and surname"
msgid "email valide" msgid "email valide"
msgstr "valid e-mail" msgstr "valid e-mail"
#: elections/models.py:266 elections/tests/test_models.py:57 #: elections/models.py:263 elections/tests/test_models.py:57
msgid "identifiants spécifiques" msgid "identifiants spécifiques"
msgstr "dedicated credentials" msgstr "dedicated credentials"
#: elections/staticdefs.py:27 #: elections/staticdefs.py:26
msgid "Assentiment" msgid "Assentiment"
msgstr "Assent" msgstr "Assent"
#: elections/staticdefs.py:28 #: elections/staticdefs.py:27
msgid "Uninominal" msgid "Uninominal"
msgstr "Uninominal" msgstr "Uninominal"
#: elections/staticdefs.py:29 #: elections/staticdefs.py:28
msgid "Condorcet" msgid "Condorcet"
msgstr "Condorcet" msgstr "Condorcet"
#: elections/staticdefs.py:40 #: elections/staticdefs.py:39
msgid "" msgid ""
"Le mode de scrutin pour cette question est un vote par assentiment. Vous " "Le mode de scrutin pour cette question est un vote par assentiment. Vous "
"pouvez donc sélectionner autant d'options que vous souhaitez. Vous pouvez " "pouvez donc sélectionner autant d'options que vous souhaitez. Vous pouvez "
@ -202,7 +201,7 @@ msgstr ""
"The voting method for this question is a assent vote. You can therefore " "The voting method for this question is a assent vote. You can therefore "
"select as many options as you wish. You can also select no options at all." "select as many options as you wish. You can also select no options at all."
#: elections/staticdefs.py:45 #: elections/staticdefs.py:44
msgid "" msgid ""
"Le mode de scrutin pour cette question est un vote uninominal. Vous ne " "Le mode de scrutin pour cette question est un vote uninominal. Vous ne "
"pouvez donc sélectionner qu'une seule option." "pouvez donc sélectionner qu'une seule option."
@ -210,7 +209,7 @@ msgstr ""
"The voting method for this question is a uninominal vote. You can therefore " "The voting method for this question is a uninominal vote. You can therefore "
"only select one option." "only select one option."
#: elections/staticdefs.py:49 #: elections/staticdefs.py:48
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"Le mode de scrutin pour cette question est un vote de type condorcet. Vous " "Le mode de scrutin pour cette question est un vote de type condorcet. Vous "
@ -330,12 +329,10 @@ msgstr ""
"to vote." "to vote."
#: elections/templates/elections/election.html:197 #: elections/templates/elections/election.html:197
#: elections/templates/elections/election_voters.html:203
msgid "Connexion par identifiants" msgid "Connexion par identifiants"
msgstr "Login with credentials" msgstr "Login with credentials"
#: elections/templates/elections/election.html:208 #: elections/templates/elections/election.html:208
#: elections/templates/elections/election_voters.html:214
#: shared/templates/authens/login_switch.html:20 #: shared/templates/authens/login_switch.html:20
msgid "Connexion via CAS" msgid "Connexion via CAS"
msgstr "Login via CAS" msgstr "Login via CAS"
@ -406,7 +403,7 @@ msgstr "Add a question"
#: elections/templates/elections/election_voters.html:68 #: elections/templates/elections/election_voters.html:68
#: elections/templates/elections/vote.html:49 #: elections/templates/elections/vote.html:49
#: shared/templates/auth/create-user.html:32 #: shared/templates/auth/create-user.html:32
#: shared/templates/auth/election_login.html:48 #: shared/templates/auth/election_login.html:34
#: shared/templates/auth/permission-management.html:38 #: shared/templates/auth/permission-management.html:38
#: shared/templates/authens/pwd_login.html:47 #: shared/templates/authens/pwd_login.html:47
#: shared/templates/authens/pwd_reset.html:34 #: shared/templates/authens/pwd_reset.html:34
@ -521,7 +518,7 @@ msgid "Login"
msgstr "Login" msgstr "Login"
#: elections/templates/elections/election_upload_voters.html:152 #: elections/templates/elections/election_upload_voters.html:152
#: elections/templates/elections/election_voters.html:108 #: elections/templates/elections/election_voters.html:107
msgid "Nom" msgid "Nom"
msgstr "Name" msgstr "Name"
@ -538,25 +535,16 @@ msgstr ""
"Only people on this list can vote, you should have received an e-mail with " "Only people on this list can vote, you should have received an e-mail with "
"your login credentials." "your login credentials."
#: elections/templates/elections/election_voters.html:109 #: elections/templates/elections/election_voters.html:108
msgid "Vote enregistré" msgid "Vote enregistré"
msgstr "Recorded vote" msgstr "Recorded vote"
#: elections/templates/elections/election_voters.html:129 #: elections/templates/elections/election_voters.html:128
#: elections/templates/elections/election_voters.html:162 #: elections/templates/elections/election_voters.html:161
#, python-format #, python-format
msgid "Supprimer le vote de %(v_name)s" msgid "Supprimer le vote de %(v_name)s"
msgstr "Delete the vote of %(v_name)s" msgstr "Delete the vote of %(v_name)s"
#: elections/templates/elections/election_voters.html:182
msgid "Pour voir la liste des votant·e·s vous devez être connecté·e."
msgstr "To see the list of voters you must be logged in."
#: elections/templates/elections/election_voters.html:188
#: shared/templates/auth/election_login.html:22
msgid "La connexion doit s'effectuer via les identifiants reçus par mail."
msgstr "The connection must be made via the credentials received by e-mail."
#: elections/templates/elections/results/rank.html:38 #: elections/templates/elections/results/rank.html:38
#, python-format #, python-format
msgid "" msgid ""
@ -583,7 +571,7 @@ msgstr "Vote for the question:"
#: elections/templates/elections/vote.html:40 #: elections/templates/elections/vote.html:40
#: shared/templates/auth/create-user.html:23 #: shared/templates/auth/create-user.html:23
#: shared/templates/auth/election_login.html:39 #: shared/templates/auth/election_login.html:25
#: shared/templates/auth/permission-management.html:29 #: shared/templates/auth/permission-management.html:29
#: shared/templates/authens/pwd_login.html:38 #: shared/templates/authens/pwd_login.html:38
#: shared/templates/authens/pwd_reset_confirm.html:25 #: shared/templates/authens/pwd_reset_confirm.html:25
@ -727,7 +715,7 @@ msgstr "Question deleted!"
msgid "Option supprimée !" msgid "Option supprimée !"
msgstr "Option deleted!" msgstr "Option deleted!"
#: elections/views.py:541 #: elections/views.py:539
msgid "Votre choix a bien été enregistré !" msgid "Votre choix a bien été enregistré !"
msgstr "Your choice has been recorded!" msgstr "Your choice has been recorded!"
@ -827,7 +815,7 @@ msgstr "Invalid username format, only CAS or password accounts are editable"
msgid "Pas d'utilisateur·rice avec ce login" msgid "Pas d'utilisateur·rice avec ce login"
msgstr "No user with this username" msgstr "No user with this username"
#: shared/auth/views.py:68 #: shared/auth/views.py:70
msgid "Compte créé avec succès" msgid "Compte créé avec succès"
msgstr "Account successfully created" msgstr "Account successfully created"
@ -874,7 +862,7 @@ msgstr "Password accounts"
#: shared/templates/auth/account-list.html:45 #: shared/templates/auth/account-list.html:45
#: shared/templates/auth/account-list.html:90 #: shared/templates/auth/account-list.html:90
msgid "Search" msgid "Search"
msgstr "Search" msgstr ""
#: shared/templates/auth/account-list.html:85 #: shared/templates/auth/account-list.html:85
msgid "Comptes CAS" msgid "Comptes CAS"

View file

@ -1,53 +0,0 @@
from typing import TYPE_CHECKING
from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.core.management.base import BaseCommand, CommandError
if TYPE_CHECKING:
from elections.typing import User
else:
User = get_user_model()
class Command(BaseCommand):
help = "Creates an administrator role with the specified credentials"
def add_arguments(self, parser):
# Credentials
parser.add_argument("base_username", type=str, help="Username")
parser.add_argument("password", type=str, help="Password")
parser.add_argument("full_name", nargs="?", type=str, help="Full name")
parser.add_argument(
"--superuser", action="store_true", help="Create a superuser account"
)
def handle(self, *args, **kwargs):
base_username = kwargs["base_username"]
password = kwargs["password"]
user, created = User.objects.get_or_create(username=f"pwd__{base_username}")
if not created:
raise CommandError("Un utilisateur avec ce nom existe déjà")
user.is_staff = True
user.password = make_password(password)
if kwargs["full_name"]:
user.full_name = kwargs["full_name"]
if kwargs["superuser"]:
user.is_superuser = True
user.save()
Permission.objects.get(
codename="election_admin"
).user_set.add( # pyright: ignore
user
)
Permission.objects.get(codename="faq_admin").user_set.add( # pyright: ignore
user
)

View file

@ -10591,6 +10591,59 @@ body {
cursor: move; cursor: move;
} }
.fade-out {
transition: opacity 1s;
opacity: 0;
}
form.is-loading {
pointer-events: none;
opacity: 0.5;
}
form.is-loading::after {
opacity: 1;
animation: spin 750ms infinite linear;
border: 5px solid #242424;
border-radius: 150px;
border-right-color: transparent;
border-top-color: transparent;
content: "";
display: block;
height: 4em;
position: absolute !important;
width: 4em;
left: calc(50% - 2em);
top: calc(50% - 2em);
}
#notifications {
position: fixed;
z-index: 100;
width: 100%;
padding-top: 1.75em;
}
#notifications .notification {
box-shadow: 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 2px rgba(10, 10, 10, 0.02);
margin-left: 22.5%;
margin-right: 22.5%;
font-size: 1.5em;
display: flex;
align-items: center;
justify-content: center;
}
@media screen and (max-width: 1152px) {
#notifications .notification {
margin-left: 12.5%;
margin-right: 12.5%;
}
}
@media screen and (max-width: 768px) {
#notifications .notification {
margin-left: 5%;
margin-right: 5%;
}
}
#scroll-button { #scroll-button {
position: fixed; position: fixed;
bottom: 1em; bottom: 1em;

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,219 @@
const _ = undefined
// Select elements with the given selector
const _$ = (s, e = document, a = true) => {
const r = Array.from(e.querySelectorAll(s));
return a ? r : r[0];
}
// Selects an element with the given id
const id = s => document.getElementById(s);
// Debounce utility
const debounce = (f, t = 200) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(f, t, ...args);
};
}
// Returns the data received after a GET request
const get = u => fetch(u).then(r => r.json()).catch(e => notify(e, 'danger'))
// Returns the data received after a POST request
const post = (u, d) => fetch(u, {
method: 'POST',
body: new FormData(d)
}).then(r => r.json()).catch(e => notify(e, 'danger'))
// Creates a new element
const element = (t = 'template', c = [], h = '') => {
const e = document.createElement(t);
e.classList.add(...c);
e.innerHTML = h;
return e;
}
// Add a delete button to the given element
const addDelete = e => {
const b = element('button', ["delete"]);
b.addEventListener('click', () => e.remove());
e.appendChild(b);
}
// Send a notification
const notify = (m, c) => {
const n = element('div', ['notification'], `<b>${m}</b>`);
c ? n.classList.add(`is-${c}`) : _;
id('notifications').insertBefore(n, id('notifications').firstChild)
addDelete(n);
setTimeout(() => {
n.classList.add('fade-out');
setTimeout(() => n.remove(), 1000)
}, 5000);
}
// Add a listener to remove the target
const remove = d => d.addEventListener('click', () => {
get(d.dataset.url).then(r => {
if (r.success && r.action === 'delete') {
id(d.dataset.target).remove()
}
r.message ? notify(r.message.content, r.message.class) : _;
});
})
const openModal = b => b.addEventListener('click', () => {
const m = id(b.dataset.target);
if ('post_url' in b.dataset) {
_$('form', m, false).action = b.dataset.post_url;
};
if ('title' in b.dataset) {
_$('.modal-card-title', m, false).innerHTML = b.dataset.title;
};
document.documentElement.classList.add('is-clipped');
m.classList.add('is-active');
})
const closeModal = b => b.addEventListener('click', () => {
document.documentElement.classList.remove('is-clipped');
id(b.dataset.closes).classList.remove('is-active')
})
// Add error to input
const addError = (i, e) => {
const s = element('span', ['help', 'is-danger'], e);
i.classList.add('is-danger');
i.insertAdjacentElement('afterend', s);
};
// Remove error from input
const removeError = i => {
i.classList.remove('is-danger');
_$('span.help.is-danger', i.parentNode).forEach(e => e.remove());
}
// Form autofill
const autoFill = (d, f) => {
for (const [k, v] of Object.entries(d)) {
const fd = _$(`[name='${k}']`, f, false);
if (typeof(v) === 'boolean') {
fd.checked = v;
} else {
if (fd.value !== undefined) {
fd.value = v;
} else {
fd.innerHTML = v;
}
}
}
}
const initForm = b => b.addEventListener('click', () => {
const f = _$('form', id(b.dataset.target), false);
if (!f) {
return;
}
f.dataset.next = b.dataset.next;
f.dataset.origin = b.dataset.parent
f.dataset.modal = b.dataset.target;
_$('input,select', f).forEach(removeError);
b.dataset.json ? autoFill(JSON.parse(b.dataset.json), f) : _;
if (b.dataset.json_url) {
f.classList.add('is-loading');
get(b.dataset.json_url).then(r => {
if (r.success) {
autoFill(r.data, f);
f.classList.remove('is-loading');
}
});
}
})
// Form submission
const submitForm = (f, i, s) => f.addEventListener('submit', event => {
event.preventDefault();
event.submitter.classList.add('is-loading');
post(f.action, f).then(r => {
event.submitter.classList.remove('is-loading');
// On enlève les erreurs
_$('input,select', f).forEach(removeError);
if (r.success) {
// On crée le résultat
const e = element('template', [], r.html).content;
i(f, e);
switch (r.action) {
case 'create':
id(f.dataset.next).appendChild(e);
break;
case 'update':
const n = id(f.dataset.origin);
n.parentNode.replaceChild(e, n);
break;
case 'delete':
id(f.dataset.origin).remove();
break;
}
// On ferme le modal
if (f.dataset.modal) {
document.documentElement.classList.remove('is-clipped');
id(f.dataset.modal).classList.remove('is-active');
}
} else {
for (const [n, e] of Object.entries(r.errors)) {
if (n === '__all__') {
e.forEach(m => notify(m, 'danger'));
} else {
addError(_$(`[name='${n}']`, f, false), e);
}
}
}
s ? s(r) : _;
// On affiche un message si besoin
r.message.content ? notify(r.message.content, r.message.class) : _;
});
})
// Modal initialisation
const initModal = (f, e) => {
_$('.modal-button', e).forEach(openModal);
_$('.modal-button[json],.modal-button[json_url]', e).forEach(initForm);
}
// Element deletion
const _dt = (d, f) => d.addEventListener('click', () => {
get(d.dataset.url).then(r => {
if (r.success && r.action == 'delete') {
id(d.dataset.target).remove();
f ? f() : _;
}
r.message ? notify(r.message.content, r.message.class) : _;
});
})
// Pluralization
const pluralize = (s, n) => n == 1 ? s : `${s}s`

View file

@ -0,0 +1,52 @@
const _smc = '.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button-close';
document.addEventListener('DOMContentLoaded', () => {
// Interact with dropdowns
const ds = _$('.dropdown:not(.is-hoverable)');
ds.forEach(d => {
d.addEventListener('click', event => {
event.stopPropagation();
d.classList.toggle('is-active');
});
});
document.addEventListener('click', () => {
ds.forEach(d => {
d.classList.remove('is-active');
});
});
// Interact with modals
const ms = _$('.modal');
const mcs = _$(_smc);
_$('.modal-button').forEach(openModal);
_$('.modal-button[data-json],.modal-button[data-json_url]').forEach(initForm);
mcs.forEach(closeModal);
document.addEventListener('keydown', ev => {
const e = ev || window.event;
if (e.keyCode === 27) {
ds.forEach(d => {
d.classList.remove('is-active');
});
document.documentElement.classList.remove('is-clipped');
ms.forEach(m => {
m.classList.remove('is-active');
});
}
});
// Language selection
_$('.dropdown-item.lang-selector').forEach(l => {
l.addEventListener('click', () => {
_id('lang-input').value = l.dataset.lang;
_id('lang-form').submit();
});
});
});

View file

@ -1,167 +0,0 @@
const _$ = (s, e = document, a = true) => {
const r = e.querySelectorAll(s) || [];
if (!a) {
return r.item(0);
}
return r;
};
const _id = s => document.getElementById(s);
const _get = (u, f) => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.addEventListener('load', () => {
f(xhr.response);
});
xhr.open('GET', u);
xhr.send();
};
const _post = (u, d, f) => {
const xhr = new XMLHttpRequest();
const fd = new FormData(d);
xhr.responseType = 'json';
xhr.addEventListener('load', () => {
f(xhr.response);
});
xhr.open('POST', u);
xhr.send(fd);
};
const _notif = (m, c) => {
const n = document.createElement('div');
n.classList.add('notification', 'is-light');
if (c !== undefined) {
n.classList.add(`is-${c}`);
}
n.innerHTML = `${m}<button class="delete"></button>`;
_id('notifications').insertBefore(n, _id('content'))
_$('.delete', n, false).addEventListener('click', () => {
n.remove();
});
}
const _om = b => {
b.addEventListener('click', () => {
const m = _id(b.dataset.target);
if ('post_url' in b.dataset) {
_$('form', m, false).action = b.dataset.post_url;
};
if ('title' in b.dataset) {
_$('.modal-card-title', m, false).innerHTML = b.dataset.title;
};
document.documentElement.classList.add('is-clipped');
m.classList.add('is-active');
});
}
const _cm = b => {
b.addEventListener('click', () => {
document.documentElement.classList.remove('is-clipped');
_id(b.dataset.closes).classList.remove('is-active')
});
}
const _sm = '.modal';
const _smb = '.modal-button';
const _smc = '.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button-close';
document.addEventListener('DOMContentLoaded', () => {
// Delete notifications
_$('.notification .delete').forEach(d => {
const n = d.parentNode;
d.addEventListener('click', () => {
n.remove();
});
});
// Interact with dropdowns
const ds = _$('.dropdown:not(.is-hoverable)');
ds.forEach(d => {
d.addEventListener('click', e => {
e.stopPropagation();
d.classList.toggle('is-active');
});
});
document.addEventListener('click', () => {
ds.forEach(d => {
d.classList.remove('is-active');
});
});
// Interact with modals
const ms = _$(_sm);
const mbs = _$(_smb);
const mcs = _$(_smc);
mbs.forEach(_om);
mcs.forEach(_cm);
document.addEventListener('keydown', ev => {
const e = ev || window.event;
if (e.keyCode === 27) {
ds.forEach(d => {
d.classList.remove('is-active');
});
document.documentElement.classList.remove('is-clipped');
ms.forEach(m => {
m.classList.remove('is-active');
});
}
});
// Language selection
_$('.dropdown-item.lang-selector').forEach(l => {
l.addEventListener('click', () => {
_id('lang-input').value = l.dataset.lang;
_id('lang-form').submit();
});
});
// Disable button after form submission
_$('form').forEach(f => {
f.addEventListener('submit', () => {
_$('button[type=submit]', f).forEach(b => {
b.classList.add('is-loading');
setTimeout(() => {
b.classList.remove('is-loading');
}, 1000);
});
});
});
// Scroll to top button
const up = _id('scroll-button');
if (document.documentElement.scrollTop >= 100) {
up.classList.remove('is-hidden');
}
window.onscroll = () => {
if (document.documentElement.scrollTop >= 100) {
up.classList.remove('is-hidden');
} else {
up.classList.add('is-hidden');
}
}
up.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
});
});

View file

@ -51,6 +51,51 @@ body
.is-grabable .is-grabable
cursor: move cursor: move
.fade-out
transition: opacity 1s
opacity: 0
form.is-loading
pointer-events: none
opacity: 0.5
&::after
opacity: 1
animation: spin 750ms infinite linear
border: 5px solid $black-ter
border-radius: 150px
border-right-color: transparent
border-top-color: transparent
content: ""
display: block
height: 4em
position: absolute !important
width: 4em
left: calc(50% - 2em)
top: calc(50% - 2em)
#notifications
position: fixed
z-index: 100
width: 100%
padding-top: 1.75em
.notification
box-shadow: 0 0.5em 1em -0.125em rgba($scheme-invert, 0.1), 0 0px 0 2px rgba($scheme-invert, 0.02)
margin-left: 22.5%
margin-right: 22.5%
font-size: 1.5em
display: flex
align-items: center
justify-content: center
@media screen and (max-width: 1152px)
margin-left: 12.5%
margin-right: 12.5%
@media screen and (max-width: 768px)
margin-left: 5%
margin-right: 5%
#scroll-button #scroll-button
position: fixed position: fixed
bottom: 1em bottom: 1em

View file

@ -4,21 +4,21 @@
{% block custom_js %} {% block custom_js %}
<script> <script>
function initSearch(input) { function initSearch(i) {
const s = _id(input); const input = id(i);
const us = _$('a.panel-block', s.closest('div.panel')); const users = _$('a.panel-block', input.closest('div.panel'));
s.addEventListener('input', () => { input.addEventListener('input', debounce(() => {
const username = s.value.toLowerCase(); const search = input.value.toLowerCase();
us.forEach(u => { users.forEach(u => {
if (u.id.includes(username)) { if (u.id.includes(search) || u.dataset.name.includes(search)) {
u.classList.remove('is-hidden'); u.classList.remove('is-hidden');
} else { } else {
u.classList.add('is-hidden'); u.classList.add('is-hidden');
} }
}); });
}); }));
} }
initSearch('pwd_search'); initSearch('pwd_search');
@ -51,7 +51,7 @@
{# List of users #} {# List of users #}
{% for u in pwd_users %} {% for u in pwd_users %}
<a class="panel-block" href="{% url 'auth.permissions' %}?user={{ u.username }}" id={{ u.base_username|lower }}> <a class="panel-block" href="{% url 'auth.permissions' %}?user={{ u.username }}" id={{ u.base_username|lower }} data-name="{{ u.full_name|lower }}">
<div class="level is-mobile is-flex-grow-1"> <div class="level is-mobile is-flex-grow-1">
<div class="level-left is-flex-shrink-1 pr-3"> <div class="level-left is-flex-shrink-1 pr-3">
<span class="panel-icon"> <span class="panel-icon">
@ -96,7 +96,7 @@
{# List of users #} {# List of users #}
{% for u in cas_users %} {% for u in cas_users %}
<a class="panel-block" href="{% url 'auth.permissions' %}?user={{ u.username }}" id={{ u.base_username|lower }}> <a class="panel-block" href="{% url 'auth.permissions' %}?user={{ u.username }}" id={{ u.base_username|lower }} data-name="{{ u.full_name|lower }}">
<div class="level is-mobile is-flex-grow-1"> <div class="level is-mobile is-flex-grow-1">
<div class="level-left is-flex-shrink-1 pr-3"> <div class="level-left is-flex-shrink-1 pr-3">
<span class="panel-icon"> <span class="panel-icon">

View file

@ -6,51 +6,37 @@
{% block content %} {% block content %}
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1> <h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
<hr> <hr>
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
<div class="tile is-ancestor py-3"> {% include "forms/form.html" with errors=True %}
<div class="tile is-parent">
<div class="tile is-child notification is-primary is-light"> <div class="field is-grouped is-centered">
<div class="has-text-centered"> <div class="control is-expanded">
<span class="icon"> <button class="button is-fullwidth is-outlined is-primary is-light" type="submit">
<i class="fas fa-info"></i> <span class="icon">
</span> <i class="fas fa-check"></i>
<span>{% trans "La connexion doit s'effectuer via les identifiants reçus par mail." %}</span> </span>
</div> <span>{% trans "Enregistrer" %}</span>
</div> </button>
</div>
<div class="control">
<a class="button is-primary" href="{% url 'election.view' election_id %}">
<span class="icon">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div> </div>
</div> </div>
</form>
<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" type="submit">
<span class="icon">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Enregistrer" %}</span>
</button>
</div>
<div class="control">
<a class="button is-primary" href="{{ request.GET.next }}">
<span class="icon">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</form>
</div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View file

@ -21,20 +21,13 @@
<link rel="stylesheet" href="{% static 'vendor/font-awesome/css/font-awesome.min.css' %}"> <link rel="stylesheet" href="{% static 'vendor/font-awesome/css/font-awesome.min.css' %}">
<link rel="stylesheet" href="{% static 'vendor/font-awesome/css/solid.min.css' %}"> <link rel="stylesheet" href="{% static 'vendor/font-awesome/css/solid.min.css' %}">
<script src="{% static 'js/main.js' %}"></script> <script src="{% static 'js/framework.js' %}"></script>
{% block extra_head %}{% endblock extra_head %} {% block extra_head %}{% endblock extra_head %}
</head> </head>
<body> <body>
{# Scrool to top #}
<button id="scroll-button" class="button is-rounded is-large is-hidden has-tooltip" data-tooltip="{% trans "Revenir en haut de la page" %}">
<span class="icon is-large has-text-primary">
<i class="fas fa-2x fa-chevron-circle-up"></i>
</span>
</button>
{# Sélection de la langue #} {# Sélection de la langue #}
<form action="{% url "set_language" %}" method="POST" id="lang-form" class="is-hidden"> <form action="{% url "set_language" %}" method="POST" id="lang-form" class="is-hidden">
{% csrf_token %} {% csrf_token %}
@ -219,39 +212,69 @@
</div> </div>
</nav> </nav>
{% block layout %} <div id="notifications"></div>
<div class="main-content"> <script>
(() => {
const messages = [{% for message in messages %}['{{ message }}', '{{ message.level_tag|bulma_message_tag }}'] {% endfor %}];
for (const [m, c] of messages) {
notify(m, c);
}
})()
</script>
<div class="main-content mb-5">
{% block layout %}
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds-fullhd is-12-desktop is-12-widescreen"> <div class="column is-two-thirds-fullhd is-12-desktop is-12-widescreen">
<section id="notifications" class="section pt-0"> <div id="content" class="box">
{% block content %}{% endblock content %}
{% for message in messages %} </div>
<div class="notification is-{{ message.level_tag|bulma_message_tag }} is-light">
{% if 'safe' in message.tags %}
{{ message|safe }}
{% else %}
{{ message }}
{% endif %}
<button class="delete"></button>
</div>
{% endfor %}
<div id="content" class="box">
{% block content %}
{% endblock content %}
</div>
</section>
</div> </div>
</div> </div>
{% endblock layout %}
</div> </div>
{% endblock layout %}
<footer class="footer"> <footer class="footer">
<p class="has-text-centered"> <p class="has-text-centered">
{% blocktrans %}Développé par <a class="tag is-light is-danger" href="https://www.eleves.ens.fr/kde">KDEns</a>. En cas de pépin, contacter <span class="tag is-info is-light">klub-dev [at] ens [dot] fr</span>.{% endblocktrans %} {% blocktrans %}Développé par <a class="tag is-light is-danger" href="https://www.eleves.ens.fr/kde">KDEns</a>. En cas de pépin, contacter <span class="tag is-info is-light">klub-dev [at] ens [dot] fr</span>.{% endblocktrans %}
</p> </p>
</footer> </footer>
<script src="{% static 'js/kadenios.js' %}"></script>
{% block custom_js %}{% endblock %} {% block custom_js %}{% endblock %}
{# Scrool to top #}
<button id="scroll-button" class="button is-rounded is-large is-hidden has-tooltip" data-tooltip="{% trans "Revenir en haut de la page" %}">
<span class="icon is-large has-text-primary">
<i class="fas fa-2x fa-chevron-circle-up"></i>
</span>
</button>
<script>
(() => {
const up = id('scroll-button');
if (document.documentElement.scrollTop >= 100) {
up.classList.remove('is-hidden');
}
window.onscroll = () => {
if (document.documentElement.scrollTop >= 100) {
up.classList.remove('is-hidden');
} else {
up.classList.add('is-hidden');
}
}
up.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
});
})()
</script>
</body> </body>
</html> </html>

View file

@ -5,5 +5,7 @@
def choices_length(choices): def choices_length(choices):
"""Renvoie la longueur maximale des choix de choices""" """Renvoie la longueur maximale des choix de choices"""
m = 0
return max(len(c[0]) for c in choices) for c in choices:
m = max(m, len(c[0]))
return m

View file

@ -23,4 +23,4 @@ class BackgroundUpdateView(RedirectView):
class TimeMixin: class TimeMixin:
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update(current_time=timezone.now()) kwargs.update(current_time=timezone.now())
return super().get_context_data(**kwargs) # pyright: ignore return super().get_context_data(**kwargs)

View file

@ -1 +0,0 @@
(import ./. { }).devShell