Compare commits
1 commit
main
...
thubrecht/
Author | SHA1 | Date | |
---|---|---|---|
cd5ec164dc |
68 changed files with 1103 additions and 1458 deletions
|
@ -1 +0,0 @@
|
|||
localhost
|
|
@ -1 +0,0 @@
|
|||
Kadenios <kadenios@localhost>
|
|
@ -1 +0,0 @@
|
|||
insecure-secret-key
|
|
@ -1 +0,0 @@
|
|||
kadenios@localhost
|
1
.envrc
1
.envrc
|
@ -1 +0,0 @@
|
|||
use nix
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -11,9 +11,7 @@
|
|||
|
||||
venv/
|
||||
.python-version
|
||||
pyrightconfig.json
|
||||
|
||||
*.sqlite3
|
||||
|
||||
.vscode
|
||||
.direnv
|
||||
|
|
|
@ -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
21
LICENSE
|
@ -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.
|
19
README.md
19
README.md
|
@ -15,7 +15,7 @@ Debian et dérivées (Ubuntu, ...) :
|
|||
sudo apt-get install python3-pip python3-dev python3-venv sqlite3
|
||||
|
||||
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 :
|
||||
|
||||
python3 -m venv venv
|
||||
|
@ -26,18 +26,11 @@ Pour l'activer, il faut taper
|
|||
|
||||
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
|
||||
`requirements-dev.txt` :
|
||||
`requirements-devel.txt` :
|
||||
|
||||
pip install -U pip
|
||||
pip install -r requirements-dev.txt
|
||||
pip install -U pip # parfois nécessaire la première fois
|
||||
pip install -r requirements-devel.txt
|
||||
|
||||
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
|
||||
|
@ -53,11 +46,11 @@ Il ne vous reste plus qu'à initialiser les modèles de Django :
|
|||
|
||||
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
|
||||
|
||||
./manage.py runserver
|
||||
python manage.py runserver
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
|
|
210
app/settings.py
210
app/settings.py
|
@ -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>"}
|
67
default.nix
67
default.nix
|
@ -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
|
||||
'';
|
||||
};
|
||||
}
|
|
@ -14,9 +14,6 @@ class ElectionForm(forms.ModelForm):
|
|||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
assert cleaned_data is not None
|
||||
|
||||
if cleaned_data["start_date"] < timezone.now():
|
||||
self.add_error(
|
||||
"start_date", _("Impossible de faire débuter l'élection dans le passé")
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)]
|
|
@ -1,32 +1,23 @@
|
|||
from typing import Any
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.http.request import HttpRequest
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from elections.typing import AuthenticatedRequest
|
||||
|
||||
from .models import Election, Option, Question
|
||||
|
||||
|
||||
class AdminOnlyMixin(PermissionRequiredMixin):
|
||||
"""Restreint l'accès aux admins"""
|
||||
|
||||
request: AuthenticatedRequest
|
||||
|
||||
permission_required = "elections.election_admin"
|
||||
|
||||
|
||||
class SelectElectionMixin:
|
||||
"""Sélectionne automatiquement les foreignkeys voulues"""
|
||||
|
||||
model: type
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
qs = super().get_queryset() # pyright: ignore
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.model is Question:
|
||||
return qs.select_related("election")
|
||||
elif self.model is Option:
|
||||
|
@ -37,19 +28,15 @@ class SelectElectionMixin:
|
|||
class RestrictAccessMixin(SelectElectionMixin):
|
||||
"""Permet de restreindre l'accès à des élections/questions/options"""
|
||||
|
||||
f_prefixes = {
|
||||
Election: "",
|
||||
Question: "election__",
|
||||
Option: "question__election__",
|
||||
}
|
||||
f_prefixes = {Election: "", Question: "election__", Option: "question__election__"}
|
||||
|
||||
def get_f_prefix(self) -> str:
|
||||
return self.f_prefixes.get(self.model, "")
|
||||
def get_f_prefix(self):
|
||||
return self.f_prefixes.get(self.model, None)
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
return {}
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.model in self.f_prefixes:
|
||||
return qs.filter(**self.get_filters())
|
||||
|
@ -60,7 +47,7 @@ class RestrictAccessMixin(SelectElectionMixin):
|
|||
class OpenElectionOnlyMixin(RestrictAccessMixin):
|
||||
"""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()
|
||||
# On ne peut modifier que les élections qui n'ont pas commencé, et
|
||||
# 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):
|
||||
return reverse("kadenios")
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
filters = super().get_filters()
|
||||
# TODO: change the way we collect the user according to the model used
|
||||
filters[self.get_f_prefix() + "created_by"] = self.request.user
|
||||
|
@ -90,7 +77,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
|
|||
class CreatorOnlyEditMixin(CreatorOnlyMixin):
|
||||
"""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é
|
||||
filters = super().get_filters()
|
||||
filters[self.get_f_prefix() + "start_date__gt"] = timezone.now()
|
||||
|
@ -100,7 +87,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin):
|
|||
class ClosedElectionMixin(CreatorOnlyMixin):
|
||||
"""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()
|
||||
# L'élection doit être terminée et non archivée
|
||||
filters = super().get_filters()
|
||||
|
@ -115,11 +102,9 @@ class NotArchivedMixin:
|
|||
ou dont on est l'admin
|
||||
"""
|
||||
|
||||
request: HttpRequest
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = super().get_queryset() # pyright: ignore
|
||||
qs = super().get_queryset()
|
||||
if user.is_authenticated:
|
||||
return qs.filter(Q(archived=False, visible=True) | Q(created_by=user))
|
||||
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from translated_fields import TranslatedFieldWithFallback
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models, transaction
|
||||
from django.http.request import HttpRequest
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from shared.auth import CONNECTION_METHODS
|
||||
from shared.auth.utils import generate_password
|
||||
from shared.json import Serializer
|
||||
from shared.utils import choices_length
|
||||
|
||||
|
@ -29,20 +25,12 @@ from .utils import (
|
|||
ValidateFunctions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models.fields.related_descriptors import ManyRelatedManager
|
||||
from django.utils.functional import _StrPromise
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Models regarding an election
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class Election(models.Model):
|
||||
registered_voters: models.Manager["User"]
|
||||
questions: models.Manager["Question"]
|
||||
|
||||
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
|
||||
short_name = models.SlugField(_("nom bref"), unique=True)
|
||||
description = TranslatedFieldWithFallback(
|
||||
|
@ -100,9 +88,6 @@ class Election(models.Model):
|
|||
|
||||
|
||||
class Question(Serializer, models.Model):
|
||||
options: models.Manager["Option"]
|
||||
duels: models.Manager["Duel"]
|
||||
|
||||
election = models.ForeignKey(
|
||||
Election, related_name="questions", on_delete=models.CASCADE
|
||||
)
|
||||
|
@ -128,42 +113,22 @@ class Question(Serializer, models.Model):
|
|||
|
||||
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])
|
||||
return vote_form.is_valid() and validate_function(vote_form)
|
||||
|
||||
@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(user, vote_form)
|
||||
|
||||
@transaction.atomic
|
||||
def tally(self) -> None:
|
||||
def tally(self):
|
||||
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
|
||||
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
|
||||
def results(self) -> str:
|
||||
def results(self):
|
||||
return render_to_string(
|
||||
f"elections/results/{self.vote_type}_export.txt", {"question": self}
|
||||
)
|
||||
|
@ -185,16 +150,14 @@ class Question(Serializer, models.Model):
|
|||
def vote_type(self):
|
||||
return BALLOT_TYPE[self.type]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.text)
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
||||
|
||||
class Option(Serializer, models.Model):
|
||||
vote_set: models.Manager["Vote"]
|
||||
|
||||
question = models.ForeignKey(
|
||||
Question, related_name="options", on_delete=models.CASCADE
|
||||
)
|
||||
|
@ -205,7 +168,7 @@ class Option(Serializer, models.Model):
|
|||
voters = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
related_name="votes",
|
||||
through="elections.Vote",
|
||||
through="Vote",
|
||||
blank=True,
|
||||
)
|
||||
# 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)
|
||||
|
||||
def get_abbr(self, default: str) -> str:
|
||||
def get_abbr(self, default):
|
||||
return self.abbreviation or default
|
||||
|
||||
def __str__(self) -> str:
|
||||
def __str__(self):
|
||||
if self.abbreviation:
|
||||
return f"{self.abbreviation} - {self.text}"
|
||||
return str(self.text)
|
||||
return self.abbreviation + " - " + self.text
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
@ -233,22 +196,12 @@ class Option(Serializer, models.Model):
|
|||
|
||||
class Vote(models.Model):
|
||||
option = models.ForeignKey(Option, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True
|
||||
)
|
||||
pseudonymous_user = models.CharField(max_length=16, blank=True)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ["option"]
|
||||
|
||||
|
||||
class RankedVote(Vote):
|
||||
rank: "Rank"
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class Rank(models.Model):
|
||||
vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
|
||||
rank = models.PositiveSmallIntegerField(_("rang de l'option"))
|
||||
|
@ -276,10 +229,6 @@ class Duel(models.Model):
|
|||
|
||||
|
||||
class User(AbstractUser):
|
||||
cast_elections: "ManyRelatedManager[Election]"
|
||||
cast_questions: "ManyRelatedManager[Question]"
|
||||
votes: "ManyRelatedManager[Vote]"
|
||||
|
||||
election = models.ForeignKey(
|
||||
Election,
|
||||
related_name="registered_voters",
|
||||
|
@ -291,30 +240,25 @@ class User(AbstractUser):
|
|||
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
|
||||
|
||||
@property
|
||||
def base_username(self) -> str:
|
||||
def base_username(self):
|
||||
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
|
||||
# ouvertes à tou·te·s
|
||||
if self.election is None:
|
||||
# If the user is connected via CAS, request.session["CASCONNECTED"] is set
|
||||
# to True by authens
|
||||
return not election.restricted and request.session.get(
|
||||
"CASCONNECTED", False
|
||||
)
|
||||
return not election.restricted and request.session.get("CASCONNECTED")
|
||||
|
||||
# Pour les élections restreintes, il faut y être associé
|
||||
return election.restricted and (self.election == election)
|
||||
|
||||
def is_admin(self, election: Election) -> bool:
|
||||
return election.created_by == self or self.is_staff
|
||||
|
||||
def get_prefix(self) -> str:
|
||||
def get_prefix(self):
|
||||
return self.username.split("__")[0]
|
||||
|
||||
@property
|
||||
def connection_method(self) -> "_StrPromise":
|
||||
def connection_method(self):
|
||||
method = self.username.split("__")[0]
|
||||
return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))
|
||||
|
||||
|
|
|
@ -1,24 +1,27 @@
|
|||
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}
|
||||
The election will take place from {start} to {end}.
|
||||
|
||||
Your voter ID: {username}
|
||||
Your password: {password}
|
||||
|
||||
--
|
||||
Kadenios
|
||||
"""
|
||||
|
||||
MAIL_VOTE_DELETED = """Dear {full_name},
|
||||
|
||||
Your vote for {election_name} has been removed.
|
||||
|
||||
--
|
||||
Kadenios
|
||||
"""
|
||||
MAIL_VOTE_DELETED = (
|
||||
"Dear {full_name},\n"
|
||||
"\n"
|
||||
"Your vote for {election_name} has been removed."
|
||||
"\n"
|
||||
"-- \n"
|
||||
"Kadenios"
|
||||
)
|
||||
|
||||
QUESTION_TYPES = [
|
||||
("assentiment", _("Assentiment")),
|
||||
|
|
|
@ -5,16 +5,8 @@ from .utils import send_mail
|
|||
|
||||
|
||||
@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)
|
||||
send_mail(election, subject, body, reply_to)
|
||||
election.sent_mail = True
|
||||
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()
|
||||
|
|
|
@ -4,66 +4,17 @@
|
|||
|
||||
{% block custom_js %}
|
||||
<script>
|
||||
const _fm = b => {
|
||||
b.addEventListener('click', () => {
|
||||
const f = _$('form', _id(b.dataset.target), false);
|
||||
f.dataset.next = b.dataset.next;
|
||||
f.dataset.origin = b.dataset.parent
|
||||
(() => {
|
||||
const initButtons = (f, e) => {
|
||||
_$('.del', e).forEach(remove);
|
||||
initModal(f, e);
|
||||
}
|
||||
|
||||
const d = JSON.parse(b.dataset.json);
|
||||
_$('.del').forEach(remove);
|
||||
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
_$(`[name='${k}']`, f, false).value = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_$('.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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
submitForm(id('form-option'), initButtons);
|
||||
submitForm(id('form-question'), initButtons);
|
||||
})()
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -3,224 +3,176 @@
|
|||
|
||||
|
||||
{% block custom_js %}
|
||||
{% if can_delete %}
|
||||
<script>
|
||||
_$('.modal-button').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
const f = _$('form', _id(b.dataset.target), false);
|
||||
f.dataset.target = b.dataset.origin;
|
||||
_$('[name="delete"]', f, false).value = 'non';
|
||||
});
|
||||
});
|
||||
{% if can_delete %}
|
||||
<script>
|
||||
_$('.modal-button').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
const f = _$('form', _id(b.dataset.target), false);
|
||||
f.dataset.target = b.dataset.origin;
|
||||
_$('[name="delete"]', f, false).value = 'non';
|
||||
});
|
||||
});
|
||||
|
||||
_$('form').forEach(f => {
|
||||
f.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
_$('form').forEach(f => {
|
||||
f.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
|
||||
if (_$('[name="delete"]', f, false).value == 'oui') {
|
||||
_get(f.action, r => {
|
||||
if (r.success && r.action == 'delete') {
|
||||
{% if election.restricted %}
|
||||
const r = _id(f.dataset.target);
|
||||
_$('.modal-button', r, false).remove();
|
||||
const i = _$('.fas', r, false);
|
||||
i.classList.remove('fa-check');
|
||||
i.classList.add('fa-times');
|
||||
{% else %}
|
||||
_id(f.dataset.target).remove()
|
||||
{% endif %}
|
||||
if (_$('[name="delete"]', f, false).value == 'oui') {
|
||||
get(f.action).then(r => {
|
||||
if (r.success && r.action == 'delete') {
|
||||
{% if election.restricted %}
|
||||
const r = _id(f.dataset.target);
|
||||
_$('.modal-button', r, false).remove();
|
||||
const i = _$('.fas', r, false);
|
||||
i.classList.remove('fa-check');
|
||||
i.classList.add('fa-times');
|
||||
{% else %}
|
||||
id(f.dataset.target).remove()
|
||||
{% endif %}
|
||||
|
||||
// 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 {
|
||||
// On ferme le modal
|
||||
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>
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level is-mobile">
|
||||
{# Titre de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1 mr-3">
|
||||
<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 class="level is-mobile">
|
||||
{# Titre de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1 mr-3">
|
||||
<h1 class="title">{{ election.name }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
|
||||
</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>
|
||||
<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-info-circle"></i>
|
||||
<i class="fas fa-undo-alt"></i>
|
||||
</span>
|
||||
<i>{% trans "La connexion doit s'effectuer via les identifiants reçus par mail." %}</i>
|
||||
{% endif %}
|
||||
<span>{% trans "Retour" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-parent">
|
||||
{% if election.restricted %}
|
||||
<a class="tile is-child notification is-primary" href="{% url 'auth.election' election.pk %}?next={% url 'election.voters' election.pk %}">
|
||||
<div class="subtitle has-text-centered mb-2">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-white">
|
||||
<i class="fas fa-unlock"></i>
|
||||
</span>
|
||||
<span class="ml-3">{% trans "Connexion par identifiants" %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}?next={% url 'election.voters' election.pk %}">
|
||||
<div class="subtitle has-text-centered mb-2">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-white">
|
||||
<i class="fas fa-school"></i>
|
||||
</span>
|
||||
<span class="ml-3">{% trans "Connexion via CAS" %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
|
||||
</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>
|
||||
|
||||
<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 %}
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -51,15 +51,14 @@
|
|||
</div>
|
||||
|
||||
<div class="modal" id="modal-confirm">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-background" data-closes="modal-confirm"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<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>
|
||||
|
||||
<section class="modal-card-body" id="modal-body">
|
||||
</section>
|
||||
<section class="modal-card-body" id="modal-body"></section>
|
||||
|
||||
<footer class="modal-card-foot">
|
||||
<button class="button is-fullwidth is-outlined is-primary is-light" type="submit">
|
||||
|
@ -69,7 +68,7 @@
|
|||
<span>{% trans "Confirmer" %}</span>
|
||||
</button>
|
||||
|
||||
<a class="button is-primary button-close">
|
||||
<a class="button is-primary button-close" data-closes="modal-confirm">
|
||||
<span class="icon">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
// On déplace les options
|
||||
while (rank_zones[next_rank].childElementCount > 1) {
|
||||
const t = rank_zones[next_rank].lastChild;
|
||||
const i = _id(t.dataset.input);
|
||||
const i = id(t.dataset.input);
|
||||
i.value = j.toString();
|
||||
rank_zones[j].append(t);
|
||||
}
|
||||
|
@ -50,7 +50,7 @@
|
|||
_$('.control .input').forEach(i => {
|
||||
// On rajoute la tuile dans le classement ou dans les non classées
|
||||
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) {
|
||||
rank_zones[r].appendChild(t);
|
||||
|
@ -82,8 +82,8 @@
|
|||
const d = event.target.closest('.drop-zone');
|
||||
|
||||
const r = d.dataset.rank;
|
||||
const t = _id(data);
|
||||
const i = _id(t.dataset.input);
|
||||
const t = id(data);
|
||||
const i = id(t.dataset.input);
|
||||
|
||||
// Si on ne change pas de rang, pas besoin de déplacer l'option
|
||||
if (i.value != r) {
|
||||
|
@ -99,7 +99,7 @@
|
|||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 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);
|
||||
|
||||
_$('.control .input').forEach(i => {
|
||||
|
@ -127,7 +127,7 @@
|
|||
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">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -142,10 +142,10 @@
|
|||
});
|
||||
|
||||
// Change le mode de remplissge de formulaire (input vs drag & drop)
|
||||
_id('change-method').addEventListener('click', () => {
|
||||
const h = _id('hide-form');
|
||||
const d = _id('drag-zone');
|
||||
const b = _id('change-method');
|
||||
id('change-method').addEventListener('click', () => {
|
||||
const h = id('hide-form');
|
||||
const d = id('drag-zone');
|
||||
const b = id('change-method');
|
||||
|
||||
// On échange ce qui est visible
|
||||
h.classList.toggle('is-hidden');
|
||||
|
@ -162,10 +162,10 @@
|
|||
});
|
||||
|
||||
// Initialise les éléments pour le formulaire interactif
|
||||
$unranked = _id('unranked');
|
||||
$unranked = id('unranked');
|
||||
|
||||
for (let i = 1; i <= nb_options; i++) {
|
||||
rank_zones[i] = _id(`rank-${i}`);
|
||||
rank_zones[i] = id(`rank-${i}`);
|
||||
}
|
||||
|
||||
_$('.control .input').forEach(i => {
|
||||
|
|
|
@ -2,30 +2,16 @@
|
|||
{% load i18n %}
|
||||
|
||||
|
||||
{% block extra_head %}
|
||||
{% block custom_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_id('confirm-button').addEventListener('click', () => {
|
||||
let selected_rows = '';
|
||||
id('confirm-button').addEventListener('click', () => {
|
||||
let selected_rows = '';
|
||||
|
||||
_$('.checkbox input').forEach(c => {
|
||||
if (c.checked) {
|
||||
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>`;
|
||||
_$('.checkbox input').filter(c => c.checked).forEach(c => {
|
||||
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>`;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
@ -7,10 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from .test_utils import create_election
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserTests(TestCase):
|
||||
|
@ -45,11 +40,8 @@ class UserTests(TestCase):
|
|||
session["CASCONNECTED"] = True
|
||||
session.save()
|
||||
|
||||
assert session.session_key is not None
|
||||
|
||||
# On sauvegarde le cookie de session
|
||||
session_cookie_name = settings.SESSION_COOKIE_NAME
|
||||
|
||||
self.client.cookies[session_cookie_name] = session.session_key
|
||||
|
||||
self.assertFalse(self.cas_user.can_vote(self.client, self.election_1))
|
||||
|
|
|
@ -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.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from .test_utils import create_election
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AdminViewsTest(TestCase):
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
from django.http.request import HttpRequest
|
||||
|
||||
from elections.models import User
|
||||
|
||||
|
||||
class AuthenticatedRequest(HttpRequest):
|
||||
user: User
|
|
@ -1,46 +1,31 @@
|
|||
import csv
|
||||
import io
|
||||
import smtplib
|
||||
from typing import TYPE_CHECKING, TypeGuard
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
from networkx.algorithms.dag import ancestors, descendants
|
||||
from numpy._typing import NDArray
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import File
|
||||
from django.core.mail import EmailMessage
|
||||
from django.core.validators import validate_email
|
||||
from django.forms import BaseFormSet
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
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
|
||||
# #############################################################################
|
||||
|
||||
|
||||
def has_rank(v: "Vote") -> TypeGuard["RankedVote"]:
|
||||
return hasattr(v, "rank")
|
||||
|
||||
|
||||
class CastFunctions:
|
||||
"""Classe pour enregistrer les votes"""
|
||||
|
||||
@staticmethod
|
||||
def cast_select(user: "User", vote_form: "BaseFormSet[SelectVoteForm]"):
|
||||
def cast_select(user, vote_form):
|
||||
"""On enregistre un vote classique"""
|
||||
selected, n_selected = [], []
|
||||
for v in vote_form:
|
||||
|
@ -52,8 +37,7 @@ class CastFunctions:
|
|||
user.votes.add(*selected)
|
||||
user.votes.remove(*n_selected)
|
||||
|
||||
@staticmethod
|
||||
def cast_rank(user: "User", vote_form: "BaseFormSet[RankVoteForm]"):
|
||||
def cast_rank(user, vote_form):
|
||||
"""On enregistre un vote par classement"""
|
||||
from .models import Rank, Vote
|
||||
|
||||
|
@ -69,8 +53,7 @@ class CastFunctions:
|
|||
|
||||
for v in vote_form:
|
||||
vote = votes[v.instance]
|
||||
|
||||
if has_rank(vote):
|
||||
if hasattr(vote, "rank"):
|
||||
vote.rank.rank = v.cleaned_data["rank"]
|
||||
ranks_update.append(vote.rank)
|
||||
else:
|
||||
|
@ -83,8 +66,7 @@ class CastFunctions:
|
|||
class TallyFunctions:
|
||||
"""Classe pour gérer les dépouillements"""
|
||||
|
||||
@staticmethod
|
||||
def tally_select(question: "Question") -> None:
|
||||
def tally_select(question):
|
||||
"""On dépouille un vote classique"""
|
||||
from .models import Option
|
||||
|
||||
|
@ -104,8 +86,7 @@ class TallyFunctions:
|
|||
|
||||
Option.objects.bulk_update(options, ["nb_votes", "winner"])
|
||||
|
||||
@staticmethod
|
||||
def tally_schultze(question: "Question") -> None:
|
||||
def tally_schultze(question):
|
||||
"""On dépouille un vote par classement et on crée la matrice des duels"""
|
||||
from .models import Duel, Option, Rank
|
||||
|
||||
|
@ -121,12 +102,12 @@ class TallyFunctions:
|
|||
else:
|
||||
ranks_by_user[user] = [r]
|
||||
|
||||
ballots: list[NDArray[np.int_]] = []
|
||||
ballots = []
|
||||
|
||||
# Pour chaque votant·e, on regarde son classement
|
||||
for user in ranks_by_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 j in range(i):
|
||||
|
@ -140,9 +121,6 @@ class TallyFunctions:
|
|||
# des duels
|
||||
duels = sum(ballots)
|
||||
|
||||
# As ballots is not empty, sum cannot be 0
|
||||
assert duels != 0
|
||||
|
||||
# Configuration du graphe
|
||||
graph = nx.DiGraph()
|
||||
|
||||
|
@ -185,11 +163,11 @@ class TallyFunctions:
|
|||
# le plus faible
|
||||
min_weight = min(nx.get_edge_attributes(graph, "weight").values())
|
||||
min_edges = []
|
||||
for u, v in graph.edges():
|
||||
for (u, v) in graph.edges():
|
||||
if graph[u][v]["weight"] == min_weight:
|
||||
min_edges.append((u, v))
|
||||
|
||||
for u, v in min_edges:
|
||||
for (u, v) in min_edges:
|
||||
graph.remove_edge(u, v)
|
||||
|
||||
# Les options gagnantes sont celles encore présentes dans le graphe
|
||||
|
@ -203,31 +181,29 @@ class TallyFunctions:
|
|||
class ValidateFunctions:
|
||||
"""Classe pour valider les formsets selon le type de question"""
|
||||
|
||||
@staticmethod
|
||||
def always_true(_) -> bool:
|
||||
"""Renvoie True pour les votes sans validation particulière"""
|
||||
def always_true(vote_form):
|
||||
"""Retourne True pour les votes sans validation particulière"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def unique_selected(vote_form: "BaseFormSet[SelectVoteForm]") -> bool:
|
||||
def unique_selected(vote_form):
|
||||
"""Vérifie qu'une seule option est choisie"""
|
||||
|
||||
nb_selected = sum(v.cleaned_data["selected"] for v in vote_form)
|
||||
nb_selected = 0
|
||||
for v in vote_form:
|
||||
nb_selected += v.cleaned_data["selected"]
|
||||
|
||||
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."))
|
||||
)
|
||||
return False
|
||||
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."))
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def limit_ranks(vote_form: "BaseFormSet[RankVoteForm]"):
|
||||
def limit_ranks(vote_form):
|
||||
"""Limite le classement au nombre d'options"""
|
||||
nb_options = len(vote_form)
|
||||
valid = True
|
||||
|
@ -253,13 +229,11 @@ class ValidateFunctions:
|
|||
class ResultsData:
|
||||
"""Classe pour afficher des informations supplémentaires après la fin d'une élection"""
|
||||
|
||||
@staticmethod
|
||||
def select(_: "Question") -> str:
|
||||
def select(question):
|
||||
"""On renvoie l'explication des couleurs"""
|
||||
return render_to_string("elections/results/select.html")
|
||||
|
||||
@staticmethod
|
||||
def rank(question: "Question") -> str:
|
||||
def rank(question):
|
||||
"""On récupère la matrice des résultats et on l'affiche"""
|
||||
duels = question.duels.all()
|
||||
options = list(question.options.all())
|
||||
|
@ -296,40 +270,38 @@ class ResultsData:
|
|||
class BallotsData:
|
||||
"""Classe pour afficher les bulletins d'une question"""
|
||||
|
||||
@staticmethod
|
||||
def select(question: "Question") -> str:
|
||||
def select(question):
|
||||
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
|
||||
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())
|
||||
|
||||
ballots = {}
|
||||
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
|
||||
|
||||
ballots[v.pseudonymous_user] = ballot
|
||||
ballots[v.user] = ballot
|
||||
|
||||
return render_to_string(
|
||||
"elections/ballots/select.html",
|
||||
{"options": options, "ballots": sorted(ballots.values(), reverse=True)},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rank(question: "Question") -> str:
|
||||
def rank(question):
|
||||
"""Renvoie un tableau contenant les classements des options par bulletin"""
|
||||
from .models import Rank
|
||||
|
||||
options = list(question.options.all())
|
||||
ranks = Rank.objects.select_related("vote").filter(
|
||||
ranks = Rank.objects.select_related("vote__user").filter(
|
||||
vote__option__in=options
|
||||
)
|
||||
ranks_by_user = {}
|
||||
|
||||
for r in ranks:
|
||||
user = r.vote.pseudonymous_user
|
||||
user = r.vote.user
|
||||
if user in ranks_by_user:
|
||||
ranks_by_user[user].append(r.rank)
|
||||
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
|
||||
`username`, `election` et `full_name`.
|
||||
"""
|
||||
|
@ -359,9 +331,10 @@ def create_users(election: "Election", csv_file: File):
|
|||
users = [
|
||||
User(
|
||||
election=election,
|
||||
username=f"{election.pk}__{username}",
|
||||
username=f"{election.id}__{username}",
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
is_active=False,
|
||||
)
|
||||
for (username, full_name, email) in reader
|
||||
]
|
||||
|
@ -369,7 +342,7 @@ def create_users(election: "Election", csv_file: File):
|
|||
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é"""
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
|
||||
|
@ -422,14 +395,15 @@ def check_csv(csv_file: File):
|
|||
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
|
||||
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
|
||||
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}"
|
||||
start = election.start_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],
|
||||
reply_to=[reply_to],
|
||||
# On modifie l'adresse de retour d'erreur
|
||||
headers={"From": "Kadenios <klub-dev@ens.fr>"},
|
||||
headers={"Return-Path": "kadenios@www.eleves.ens.fr"},
|
||||
),
|
||||
v,
|
||||
)
|
||||
)
|
||||
|
||||
for m, v in messages:
|
||||
for (m, v) in messages:
|
||||
try:
|
||||
m.send()
|
||||
v.has_valid_email = True
|
||||
except smtplib.SMTPException:
|
||||
v.has_valid_email = False
|
||||
else:
|
||||
v.has_valid_email = True
|
||||
|
||||
v.save()
|
||||
User.objects.bulk_update(voters, ["password", "has_valid_email"])
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import csv
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db import transaction
|
||||
|
@ -19,7 +19,6 @@ from django.views.generic import (
|
|||
View,
|
||||
)
|
||||
|
||||
from elections.typing import AuthenticatedRequest
|
||||
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
|
||||
from shared.views import BackgroundUpdateView, TimeMixin
|
||||
|
||||
|
@ -41,15 +40,10 @@ from .mixins import (
|
|||
)
|
||||
from .models import Election, Option, Question, Vote
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
# TODO: access control *everywhere*
|
||||
|
||||
|
@ -59,8 +53,6 @@ else:
|
|||
|
||||
|
||||
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
||||
object: Election
|
||||
|
||||
model = Election
|
||||
form_class = ElectionForm
|
||||
success_message = _("Élection créée avec succès !")
|
||||
|
@ -69,7 +61,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
|||
def get_success_url(self):
|
||||
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
|
||||
form.instance.short_name = slugify(
|
||||
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
|
||||
|
@ -83,11 +75,11 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
|
|||
model = Election
|
||||
pattern_name = "election.list"
|
||||
|
||||
def get_object(self):
|
||||
obj: Election = super().get_object()
|
||||
def get_object(self, queryset=None):
|
||||
obj = self.get_object()
|
||||
# On ne peut supprimer que les élections n'ayant pas eu de vote et dont
|
||||
# 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
|
||||
return obj
|
||||
|
||||
|
@ -97,8 +89,6 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
|
|||
|
||||
|
||||
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
|
||||
object: Election
|
||||
|
||||
model = Election
|
||||
template_name = "elections/election_admin.html"
|
||||
|
||||
|
@ -125,7 +115,7 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
|
|||
success_message = _("Élection visible !")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.election: Election = self.get_object()
|
||||
self.election = self.get_object()
|
||||
self.election.visible = True
|
||||
self.election.save()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
@ -242,8 +232,6 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
|
|||
|
||||
|
||||
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
|
||||
voter: User
|
||||
|
||||
model = Election
|
||||
|
||||
def get_message(self):
|
||||
|
@ -300,9 +288,6 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
|
|||
election.tallied = True
|
||||
election.time_tallied = timezone.now()
|
||||
election.save()
|
||||
|
||||
pseudonimize_election(election.pk)
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
|
@ -431,7 +416,7 @@ class ElectionView(NotArchivedMixin, DetailView):
|
|||
context = super().get_context_data(**kwargs)
|
||||
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["cast_questions"] = user.cast_questions.all()
|
||||
context["has_voted"] = user.cast_elections.filter(
|
||||
|
@ -459,9 +444,7 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
|
|||
election = context["election"]
|
||||
voters = list(election.voters.all())
|
||||
|
||||
if user.is_authenticated and isinstance(user, User):
|
||||
context["can_vote"] = user.can_vote(self.request, context["election"])
|
||||
context["is_admin"] = user.is_admin(election)
|
||||
if user.is_authenticated:
|
||||
can_delete = (
|
||||
election.created_by == user
|
||||
and election.end_date < timezone.now()
|
||||
|
@ -491,8 +474,6 @@ class ElectionBallotsView(NotArchivedMixin, DetailView):
|
|||
|
||||
|
||||
class VoteView(OpenElectionOnlyMixin, DetailView):
|
||||
request: AuthenticatedRequest
|
||||
|
||||
model = Question
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
|
5
kadenios/apps.py
Normal file
5
kadenios/apps.py
Normal 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
1
kadenios/settings/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
secret.py
|
151
kadenios/settings/common.py
Normal file
151
kadenios/settings/common.py
Normal 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/"
|
55
kadenios/settings/local.py
Normal file
55
kadenios/settings/local.py
Normal 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
68
kadenios/settings/prod.py
Normal 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
|
14
kadenios/settings/secret_example.py
Normal file
14
kadenios/settings/secret_example.py
Normal 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"
|
|
@ -1,5 +1,4 @@
|
|||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
|
@ -17,8 +16,7 @@ urlpatterns = [
|
|||
if settings.DEBUG:
|
||||
urlpatterns += [
|
||||
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:
|
||||
from debug_toolbar import urls as djdt_urls
|
|
@ -11,6 +11,6 @@ import os
|
|||
|
||||
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()
|
|
@ -5,7 +5,7 @@ import sys
|
|||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kadenios.settings.local")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
@ -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`"
|
|
@ -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
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
[tool.pyright]
|
||||
reportIncompatibleMethodOverride = false
|
||||
reportIncompatibleVariableOverride = false
|
|
@ -4,4 +4,5 @@ authens>=0.1b2
|
|||
markdown
|
||||
numpy
|
||||
networkx
|
||||
python-csv
|
||||
django-background-tasks
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
||||
|
||||
|
||||
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
|
||||
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]
|
|
@ -1,7 +1,6 @@
|
|||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.sites import AlreadyRegistered
|
||||
|
||||
if settings.DEBUG:
|
||||
models = apps.get_models()
|
||||
|
@ -9,5 +8,5 @@ if settings.DEBUG:
|
|||
for model in models:
|
||||
try:
|
||||
admin.site.register(model)
|
||||
except AlreadyRegistered:
|
||||
except admin.sites.AlreadyRegistered:
|
||||
pass
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
from .staticdefs import CONNECTION_METHODS
|
||||
|
||||
__all__ = [
|
||||
"CONNECTION_METHODS",
|
||||
]
|
||||
__all__ = [CONNECTION_METHODS]
|
||||
|
|
|
@ -1,15 +1,9 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from authens.backends import ENSCASBackend
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CASBackend(ENSCASBackend):
|
||||
|
@ -24,12 +18,6 @@ class CASBackend(ENSCASBackend):
|
|||
|
||||
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):
|
||||
"""Password authentication"""
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth import forms as auth_forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.validators import validate_email
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ElectionAuthForm(forms.Form):
|
||||
|
|
|
@ -4,10 +4,12 @@ import random
|
|||
# Fonctions universelles
|
||||
# #############################################################################
|
||||
|
||||
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
|
||||
def generate_password(size=15):
|
||||
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
|
||||
|
|
|
@ -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.hashers import make_password
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, FormView, ListView, TemplateView
|
||||
|
||||
from elections.typing import AuthenticatedRequest
|
||||
|
||||
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
|
||||
from .utils import generate_password
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = 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
|
||||
"""
|
||||
|
||||
request: AuthenticatedRequest
|
||||
|
||||
def test_func(self):
|
||||
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):
|
||||
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["pwd_users"] = qs.filter(username__startswith="pwd__")
|
||||
|
@ -153,16 +143,16 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
|
|||
# Election admin
|
||||
election_perm = Permission.objects.get(codename="election_admin")
|
||||
if form.cleaned_data["election_admin"]:
|
||||
election_perm.user_set.add(user) # pyright: ignore
|
||||
election_perm.user_set.add(user)
|
||||
else:
|
||||
election_perm.user_set.remove(user) # pyright: ignore
|
||||
election_perm.user_set.remove(user)
|
||||
|
||||
# FAQ admin
|
||||
faq_perm = Permission.objects.get(codename="faq_admin")
|
||||
if form.cleaned_data["faq_admin"]:
|
||||
faq_perm.user_set.add(user) # pyright: ignore
|
||||
faq_perm.user_set.add(user)
|
||||
else:
|
||||
faq_perm.user_set.remove(user) # pyright: ignore
|
||||
faq_perm.user_set.remove(user)
|
||||
|
||||
user.save()
|
||||
return super().form_valid(form)
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
from typing import Any
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic.base import TemplateResponseMixin, View
|
||||
|
@ -51,17 +48,10 @@ class JsonMessageMixin:
|
|||
|
||||
def get_data(self, **kwargs):
|
||||
kwargs.update(message=self.get_message())
|
||||
return super().get_data(**kwargs) # pyright: ignore
|
||||
return super().get_data(**kwargs)
|
||||
|
||||
|
||||
class TypedResponseMixin(TemplateResponseMixin):
|
||||
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):
|
||||
class JsonDetailView(JsonMixin, SingleObjectMixin, TemplateResponseMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
context = self.get_context_data(object=self.object)
|
||||
|
@ -79,7 +69,7 @@ class JsonDeleteView(JsonMessageMixin, JsonDetailView):
|
|||
|
||||
@method_decorator(require_POST, name="dispatch")
|
||||
class JsonCreateView(
|
||||
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
|
||||
JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView
|
||||
):
|
||||
def render_to_json(self, **kwargs):
|
||||
context = self.get_context_data(object=self.object)
|
||||
|
@ -91,7 +81,7 @@ class JsonCreateView(
|
|||
|
||||
@method_decorator(require_POST, name="dispatch")
|
||||
class JsonUpdateView(
|
||||
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
|
||||
JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView
|
||||
):
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
|
Binary file not shown.
|
@ -2,22 +2,21 @@
|
|||
# Copyright (C) 2021 Klub Dev ENS
|
||||
# This file is distributed under the same license as the kadenios package.
|
||||
# Klub Dev ENS <klub-dev@ens.fr>, 2021.
|
||||
# Tom Hubrecht <tom.hubrecht@ens.fr>, 2022.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 0.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-04-02 21:17+0200\n"
|
||||
"PO-Revision-Date: 2022-04-02 21:23+0200\n"
|
||||
"Last-Translator: Tom Hubrecht <tom.hubrecht@ens.fr>\n"
|
||||
"Language-Team: French <klub-dev@ens.fr>\n"
|
||||
"Language: fr\n"
|
||||
"POT-Creation-Date: 2021-12-20 20:08+0100\n"
|
||||
"PO-Revision-Date: 2021-12-20 20:10+0100\n"
|
||||
"Last-Translator: Test Translator <test@translator>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Generator: Poedit 3.0.1\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Poedit 3.0\n"
|
||||
|
||||
#: elections/forms.py:19
|
||||
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/templates/elections/admin/question.html:20
|
||||
#: elections/templates/elections/election_voters.html:111
|
||||
#: elections/templates/elections/election_voters.html:110
|
||||
msgid "Supprimer"
|
||||
msgstr "Delete"
|
||||
|
||||
|
@ -177,23 +176,23 @@ msgstr "Name and surname"
|
|||
msgid "email valide"
|
||||
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"
|
||||
msgstr "dedicated credentials"
|
||||
|
||||
#: elections/staticdefs.py:27
|
||||
#: elections/staticdefs.py:26
|
||||
msgid "Assentiment"
|
||||
msgstr "Assent"
|
||||
|
||||
#: elections/staticdefs.py:28
|
||||
#: elections/staticdefs.py:27
|
||||
msgid "Uninominal"
|
||||
msgstr "Uninominal"
|
||||
|
||||
#: elections/staticdefs.py:29
|
||||
#: elections/staticdefs.py:28
|
||||
msgid "Condorcet"
|
||||
msgstr "Condorcet"
|
||||
|
||||
#: elections/staticdefs.py:40
|
||||
#: elections/staticdefs.py:39
|
||||
msgid ""
|
||||
"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 "
|
||||
|
@ -202,7 +201,7 @@ msgstr ""
|
|||
"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."
|
||||
|
||||
#: elections/staticdefs.py:45
|
||||
#: elections/staticdefs.py:44
|
||||
msgid ""
|
||||
"Le mode de scrutin pour cette question est un vote uninominal. Vous ne "
|
||||
"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 "
|
||||
"only select one option."
|
||||
|
||||
#: elections/staticdefs.py:49
|
||||
#: elections/staticdefs.py:48
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Le mode de scrutin pour cette question est un vote de type condorcet. Vous "
|
||||
|
@ -330,12 +329,10 @@ msgstr ""
|
|||
"to vote."
|
||||
|
||||
#: elections/templates/elections/election.html:197
|
||||
#: elections/templates/elections/election_voters.html:203
|
||||
msgid "Connexion par identifiants"
|
||||
msgstr "Login with credentials"
|
||||
|
||||
#: elections/templates/elections/election.html:208
|
||||
#: elections/templates/elections/election_voters.html:214
|
||||
#: shared/templates/authens/login_switch.html:20
|
||||
msgid "Connexion via CAS"
|
||||
msgstr "Login via CAS"
|
||||
|
@ -406,7 +403,7 @@ msgstr "Add a question"
|
|||
#: elections/templates/elections/election_voters.html:68
|
||||
#: elections/templates/elections/vote.html:49
|
||||
#: 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/authens/pwd_login.html:47
|
||||
#: shared/templates/authens/pwd_reset.html:34
|
||||
|
@ -521,7 +518,7 @@ msgid "Login"
|
|||
msgstr "Login"
|
||||
|
||||
#: elections/templates/elections/election_upload_voters.html:152
|
||||
#: elections/templates/elections/election_voters.html:108
|
||||
#: elections/templates/elections/election_voters.html:107
|
||||
msgid "Nom"
|
||||
msgstr "Name"
|
||||
|
||||
|
@ -538,25 +535,16 @@ msgstr ""
|
|||
"Only people on this list can vote, you should have received an e-mail with "
|
||||
"your login credentials."
|
||||
|
||||
#: elections/templates/elections/election_voters.html:109
|
||||
#: elections/templates/elections/election_voters.html:108
|
||||
msgid "Vote enregistré"
|
||||
msgstr "Recorded vote"
|
||||
|
||||
#: elections/templates/elections/election_voters.html:129
|
||||
#: elections/templates/elections/election_voters.html:162
|
||||
#: elections/templates/elections/election_voters.html:128
|
||||
#: elections/templates/elections/election_voters.html:161
|
||||
#, python-format
|
||||
msgid "Supprimer le vote de %(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
|
||||
#, python-format
|
||||
msgid ""
|
||||
|
@ -583,7 +571,7 @@ msgstr "Vote for the question:"
|
|||
|
||||
#: elections/templates/elections/vote.html:40
|
||||
#: 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/authens/pwd_login.html:38
|
||||
#: shared/templates/authens/pwd_reset_confirm.html:25
|
||||
|
@ -727,7 +715,7 @@ msgstr "Question deleted!"
|
|||
msgid "Option supprimée !"
|
||||
msgstr "Option deleted!"
|
||||
|
||||
#: elections/views.py:541
|
||||
#: elections/views.py:539
|
||||
msgid "Votre choix a bien été enregistré !"
|
||||
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"
|
||||
msgstr "No user with this username"
|
||||
|
||||
#: shared/auth/views.py:68
|
||||
#: shared/auth/views.py:70
|
||||
msgid "Compte créé avec succès"
|
||||
msgstr "Account successfully created"
|
||||
|
||||
|
@ -874,7 +862,7 @@ msgstr "Password accounts"
|
|||
#: shared/templates/auth/account-list.html:45
|
||||
#: shared/templates/auth/account-list.html:90
|
||||
msgid "Search"
|
||||
msgstr "Search"
|
||||
msgstr ""
|
||||
|
||||
#: shared/templates/auth/account-list.html:85
|
||||
msgid "Comptes CAS"
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -10591,6 +10591,59 @@ body {
|
|||
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 {
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
|
|
File diff suppressed because one or more lines are too long
219
shared/static/js/framework.js
Normal file
219
shared/static/js/framework.js
Normal 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`
|
52
shared/static/js/kadenios.js
Normal file
52
shared/static/js/kadenios.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -51,6 +51,51 @@ body
|
|||
.is-grabable
|
||||
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
|
||||
position: fixed
|
||||
bottom: 1em
|
||||
|
|
|
@ -4,21 +4,21 @@
|
|||
|
||||
{% block custom_js %}
|
||||
<script>
|
||||
function initSearch(input) {
|
||||
const s = _id(input);
|
||||
const us = _$('a.panel-block', s.closest('div.panel'));
|
||||
function initSearch(i) {
|
||||
const input = id(i);
|
||||
const users = _$('a.panel-block', input.closest('div.panel'));
|
||||
|
||||
s.addEventListener('input', () => {
|
||||
const username = s.value.toLowerCase();
|
||||
input.addEventListener('input', debounce(() => {
|
||||
const search = input.value.toLowerCase();
|
||||
|
||||
us.forEach(u => {
|
||||
if (u.id.includes(username)) {
|
||||
users.forEach(u => {
|
||||
if (u.id.includes(search) || u.dataset.name.includes(search)) {
|
||||
u.classList.remove('is-hidden');
|
||||
} else {
|
||||
u.classList.add('is-hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
initSearch('pwd_search');
|
||||
|
@ -51,7 +51,7 @@
|
|||
|
||||
{# List of 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-left is-flex-shrink-1 pr-3">
|
||||
<span class="panel-icon">
|
||||
|
@ -96,7 +96,7 @@
|
|||
|
||||
{# List of 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-left is-flex-shrink-1 pr-3">
|
||||
<span class="panel-icon">
|
||||
|
|
|
@ -6,51 +6,37 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
|
||||
<hr>
|
||||
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
|
||||
<hr>
|
||||
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-two-thirds">
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-two-thirds">
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="tile is-ancestor py-3">
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child notification is-primary is-light">
|
||||
<div class="has-text-centered">
|
||||
<span class="icon">
|
||||
<i class="fas fa-info"></i>
|
||||
</span>
|
||||
<span>{% trans "La connexion doit s'effectuer via les identifiants reçus par mail." %}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% 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="{% url 'election.view' election_id %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-undo-alt"></i>
|
||||
</span>
|
||||
<span>{% trans "Retour" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -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/solid.min.css' %}">
|
||||
|
||||
<script src="{% static 'js/main.js' %}"></script>
|
||||
<script src="{% static 'js/framework.js' %}"></script>
|
||||
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
|
||||
</head>
|
||||
|
||||
<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 #}
|
||||
<form action="{% url "set_language" %}" method="POST" id="lang-form" class="is-hidden">
|
||||
{% csrf_token %}
|
||||
|
@ -219,39 +212,69 @@
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
{% block layout %}
|
||||
<div class="main-content">
|
||||
<div id="notifications"></div>
|
||||
<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="column is-two-thirds-fullhd is-12-desktop is-12-widescreen">
|
||||
<section id="notifications" class="section pt-0">
|
||||
|
||||
{% for message in messages %}
|
||||
<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 id="content" class="box">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock layout %}
|
||||
</div>
|
||||
{% endblock layout %}
|
||||
|
||||
<footer class="footer">
|
||||
<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 %}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script src="{% static 'js/kadenios.js' %}"></script>
|
||||
{% 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>
|
||||
|
||||
</html>
|
||||
|
|
|
@ -5,5 +5,7 @@
|
|||
|
||||
def choices_length(choices):
|
||||
"""Renvoie la longueur maximale des choix de choices"""
|
||||
|
||||
return max(len(c[0]) for c in choices)
|
||||
m = 0
|
||||
for c in choices:
|
||||
m = max(m, len(c[0]))
|
||||
return m
|
||||
|
|
|
@ -23,4 +23,4 @@ class BackgroundUpdateView(RedirectView):
|
|||
class TimeMixin:
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs.update(current_time=timezone.now())
|
||||
return super().get_context_data(**kwargs) # pyright: ignore
|
||||
return super().get_context_data(**kwargs)
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
(import ./. { }).devShell
|
Loading…
Reference in a new issue