Compare commits

..

72 commits

Author SHA1 Message Date
4fd9e3a211
feat(elections): Pseudonimize votes after tallying 2024-07-11 15:02:47 +02:00
fb0e5a8a37
feat(elections): Pseudonimize tallied elections 2024-07-11 14:54:56 +02:00
3a0d499ba1
chore(npins): Update and install pyrage 2024-07-10 15:33:42 +02:00
1ca85957d2 chore(misc): More idiomatic 2024-07-10 13:52:24 +02:00
fd65aa36ad fix(send_mail): Save after each sent mail, as the bottleneck is in the SMTP 2024-07-10 13:52:03 +02:00
8f88eef5c7 feat(kadenios): Add typing 2024-07-10 13:51:24 +02:00
c6aa72e843 feat(settings): Add more email configuration 2024-07-09 10:15:35 +02:00
7ae43d4d7e feat(kadenios): Update project 2024-07-09 08:46:06 +02:00
1d7bace777 chore(kadenios): Rename kadenios -> app 2024-07-07 17:31:39 +02:00
3c81dea1c9 feat(nix): Update tooling 2024-07-07 13:09:05 +02:00
1383dc30eb fix(elections): Create active users 2023-12-20 23:21:20 +01:00
Tom Hubrecht
78c385110f Add LICENSE 2023-02-09 16:51:39 +01:00
b612664ae4 Change the return adress if the mail fails 2022-12-15 11:29:08 +01:00
Basile Clement
b38aadb0d8 Add SERVER_EMAIL 2022-12-07 15:11:48 +01:00
4fa3f96184 Add shell.nix 2022-08-01 13:33:30 +02:00
9d4902365f Remove unused requirement 2022-07-15 18:34:26 +02:00
85c7e1b750 Update .gitignore 2022-04-02 21:25:30 +02:00
de1cea0400 Traduction 2022-04-02 21:24:33 +02:00
6b34c052ea Ne montre la liste des participants qu'aux personnes autorisées 2022-04-02 21:24:23 +02:00
d98d7da399 Rajoute un message d'information et le retour renvoie sur la page d'où l'on vient (request.GET.next) 2022-04-02 21:07:30 +02:00
e4e4cff9e6 Return Http403 if the home directory is not parsable instead of creating an error 500 2022-02-22 17:26:21 +01:00
9f3f2a4664 Mise à jour du README 2022-01-11 17:27:20 +01:00
9c5f3b3a04 Permet de créer des superusers 2022-01-11 15:48:23 +01:00
932a20fd17 Add a management command to create an admin 2022-01-11 15:08:27 +01:00
f9271f5690 Ajoute les horaires de vote dans le mail d'annonce 2022-01-08 02:55:22 +01:00
b2c55b824a Fixe un bug de migration si on n'a pas de db initiale 2022-01-07 23:58:40 +01:00
0e0f29cba6 Ajoute un message pour indiquer que la connexion avec identifiants se fait sur la page du vote 2021-12-20 20:11:27 +01:00
ca1fdff3cb Ajoute un message lorsque le vote n'est pas encore ouvert 2021-12-20 17:19:49 +01:00
361ef6d80b On change l'adresse d'erreur 2021-12-14 12:30:26 +01:00
2c74a7866a On met l'adresse de l'orga en Return-Path pour ne pas que KDE reçoive tous les mails d'erreur 2021-12-14 10:17:05 +01:00
5dfb23bde5 On enlève le return None 2021-10-04 10:09:44 +02:00
408aa51e5b Crée des utilisateurs inactifs pour les élections avec liste, pour ne pas les inclure dans la réinitialisation de mdp 2021-10-04 10:02:16 +02:00
870b16cea5 Template update 2021-10-04 09:48:12 +02:00
c2931aa81f Fix JsonDetailView 2021-09-17 13:55:23 +02:00
a621bb8197 On sérialise les modèles pour transmettre en JSON, on améliore le formulaire modal 2021-09-16 16:43:27 +02:00
8a91648bac Suppression de l'adresse mail dans la liste des gestionnaires 2021-09-12 10:09:54 +02:00
f65c3a991a Rajoute une page listant les admins et des icônes pour voir plus facilement les permissions 2021-09-06 15:38:25 +02:00
77e085458c Fix DTP 2021-08-26 12:31:07 +02:00
3c91771e84 Bouton de retour depuis la liste des votant·e·s 2021-08-25 15:59:55 +02:00
04c14b37ec Répare la fermeture du datepicker 2021-08-25 09:53:49 +02:00
cd95f2c1e4 Mesage de succès sur la création d'un compte 2021-08-24 23:15:10 +02:00
064c5578c8 Centre le nom des options 2021-08-24 23:10:19 +02:00
c0b9025fcf On centre la matrice sur mobile 2021-08-24 23:07:56 +02:00
0e9b7e82cc On supprime l'ancienne vue 2021-08-24 23:07:36 +02:00
bca8d03400 Ajax pour la supression de vote et modifications de patrons 2021-08-24 18:01:44 +02:00
6a797d3357 Tweaks 2021-08-23 14:25:10 +02:00
9474fcd1d7 Template updates 2021-08-23 10:52:17 +02:00
7bb98f9cd8 Traduction 2021-08-23 01:19:25 +02:00
db2d69bc5f Better mobile menu 2021-08-22 03:52:57 +02:00
f2b4e9bcfe Use AJAX to edit options and questions 2021-08-21 22:58:01 +02:00
e14ceca91a Fix datetime picker modal detection and add _notif, _get and _post functions 2021-08-21 15:17:52 +02:00
c7bc1fbe16 let => const 2021-08-20 22:06:09 +02:00
ed15d137cc On utilise les nouvelles fonctions pour le vote 2021-08-20 21:14:01 +02:00
d8b0885772 Update js 2021-08-20 18:34:03 +02:00
78f2b66594 Bouton pour rafraîchir 2021-08-20 17:46:42 +02:00
9ab42a3a9d Update 2021-08-20 16:47:30 +02:00
2e1051bb2e Création des users d'un seul coup 2021-08-20 15:07:36 +02:00
d210fc88c4 Tweak the scroll button 2021-08-20 12:50:19 +02:00
b4ffd2ee36 Traduction 2021-08-20 12:22:08 +02:00
779bf7bf44 Rajoute un bouton pour revenir en haut de la page 2021-08-20 12:13:47 +02:00
b55d398bf8 Rafraîchit automatiquement pendant l'envoi du mail 2021-08-20 12:12:15 +02:00
2396c163bd Rajoute l'envoi des mails en tâche de fond, pour ne plus faire planter le serveur 2021-08-20 00:45:33 +02:00
3e683cd87e Use correct permissions 2021-07-13 02:44:40 +02:00
f56cd87358 Rajout de la gestion admin 2021-07-13 02:41:32 +02:00
287716276d Error pages update 2021-07-13 02:30:24 +02:00
684fe20d24 On n'autorise l'accès à la page des bulletins que quand l'élection est publiée 2021-07-10 20:05:09 +02:00
b79a7f6b0f On trie les bulletins avant l'affichage 2021-07-10 19:56:45 +02:00
ba863c757f On affiche uniquement le lien non trouvé, pas la page d'erreur 2021-07-08 10:44:14 +02:00
4b8ed181d8 Add timestamps 2021-06-28 22:43:35 +02:00
3601508ab2 On affiche l'abréviation dans le tableu des résultats 2021-06-28 21:33:07 +02:00
6ba5a5c620 On désactive l'interface admin en production, et on déplace la découverte des modèles dans shared 2021-06-27 13:44:01 +02:00
18383802d6 On renvoie sur la bonne page lors de la connexion 2021-06-21 16:49:03 +02:00
109 changed files with 3393 additions and 1717 deletions

1
.credentials/EMAIL_HOST Normal file
View file

@ -0,0 +1 @@
localhost

1
.credentials/FROM_EMAIL Normal file
View file

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

1
.credentials/SECRET_KEY Normal file
View file

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

View file

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

1
.envrc Normal file
View file

@ -0,0 +1 @@
use nix

2
.gitignore vendored
View file

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

11
01-authens.patch Normal file
View file

@ -0,0 +1,11 @@
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 Normal file
View file

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

View file

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

210
app/settings.py Normal file
View file

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

View file

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
@ -6,7 +7,6 @@ from .views import HomeView
urlpatterns = [ urlpatterns = [
path("", HomeView.as_view(), name="kadenios"), path("", HomeView.as_view(), name="kadenios"),
path("admin/", admin.site.urls),
path("elections/", include("elections.urls")), path("elections/", include("elections.urls")),
path("faqs/", include("faqs.urls")), path("faqs/", include("faqs.urls")),
path("auth/", include("shared.auth.urls")), path("auth/", include("shared.auth.urls")),
@ -14,6 +14,12 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
] ]
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: if "debug_toolbar" in settings.INSTALLED_APPS:
from debug_toolbar import urls as djdt_urls from debug_toolbar import urls as djdt_urls

View file

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

67
default.nix Normal file
View file

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

View file

@ -1,12 +0,0 @@
from django.contrib import admin
from django.apps import apps
# FIXME: this is a temp workaround to help for development
models = apps.get_models()
for model in models:
try:
admin.site.register(model)
except admin.sites.AlreadyRegistered:
pass

View file

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

View file

@ -0,0 +1,27 @@
# Generated by Django 3.2.4 on 2021-06-28 20:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("elections", "0029_alter_election_visible"),
]
operations = [
migrations.AddField(
model_name="election",
name="time_published",
field=models.DateTimeField(
default=None, null=True, verbose_name="date de publication"
),
),
migrations.AddField(
model_name="election",
name="time_tallied",
field=models.DateTimeField(
default=None, null=True, verbose_name="date du dépouillement"
),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 3.2.4 on 2021-07-12 16:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("elections", "0030_timestamps"),
]
operations = [
migrations.AlterModelOptions(
name="election",
options={
"ordering": ["-start_date", "-end_date"],
"permissions": [("election_admin", "Peut administrer des élections")],
},
),
]

View file

@ -1,4 +1,4 @@
# Generated by Django 3.2.4 on 2021-06-19 16:45 # Generated by Django 3.2.6 on 2021-08-19 22:23
from django.db import migrations, models from django.db import migrations, models
@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("elections", "0029_alter_election_visible"), ("elections", "0031_alter_election_options"),
] ]
operations = [ operations = [

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.6 on 2021-10-04 07:49
from django.db import migrations
def set_users_inactive(apps, schema_editor):
db_alias = schema_editor.connection.alias
User = apps.get_model("elections", "User")
User.objects.using(db_alias).filter(election__isnull=False).update(is_active=False)
class Migration(migrations.Migration):
dependencies = [
("elections", "0032_auto_20210820_0023"),
]
operations = [
migrations.RunPython(set_users_inactive, migrations.RunPython.noop),
]

View file

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

View file

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

View file

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

View file

@ -1,17 +1,22 @@
from typing import TYPE_CHECKING
from translated_fields import TranslatedFieldWithFallback from translated_fields import TranslatedFieldWithFallback
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models, transaction from django.db import models, transaction
from django.http.request import HttpRequest
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from shared.auth import CONNECTION_METHODS
from shared.auth.utils import generate_password
from shared.json import Serializer
from shared.utils import choices_length from shared.utils import choices_length
from .staticdefs import ( from .staticdefs import (
BALLOT_TYPE, BALLOT_TYPE,
CAST_FUNCTIONS, CAST_FUNCTIONS,
CONNECTION_METHODS,
QUESTION_TYPES, QUESTION_TYPES,
TALLY_FUNCTIONS, TALLY_FUNCTIONS,
VALIDATE_FUNCTIONS, VALIDATE_FUNCTIONS,
@ -24,12 +29,20 @@ from .utils import (
ValidateFunctions, ValidateFunctions,
) )
if TYPE_CHECKING:
from django.db.models.fields.related_descriptors import ManyRelatedManager
from django.utils.functional import _StrPromise
# ############################################################################# # #############################################################################
# Models regarding an election # Models regarding an election
# ############################################################################# # #############################################################################
class Election(models.Model): class Election(models.Model):
registered_voters: models.Manager["User"]
questions: models.Manager["Question"]
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255)) name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
short_name = models.SlugField(_("nom bref"), unique=True) short_name = models.SlugField(_("nom bref"), unique=True)
description = TranslatedFieldWithFallback( description = TranslatedFieldWithFallback(
@ -72,14 +85,24 @@ class Election(models.Model):
archived = models.BooleanField(_("archivée"), default=False) archived = models.BooleanField(_("archivée"), default=False)
time_tallied = models.DateTimeField(
_("date du dépouillement"), null=True, default=None
)
time_published = models.DateTimeField(
_("date de publication"), null=True, default=None
)
class Meta: class Meta:
permissions = [ permissions = [
("is_admin", _("Peut administrer des élections")), ("election_admin", _("Peut administrer des élections")),
] ]
ordering = ["-start_date", "-end_date"] ordering = ["-start_date", "-end_date"]
class Question(models.Model): class Question(Serializer, models.Model):
options: models.Manager["Option"]
duels: models.Manager["Duel"]
election = models.ForeignKey( election = models.ForeignKey(
Election, related_name="questions", on_delete=models.CASCADE Election, related_name="questions", on_delete=models.CASCADE
) )
@ -103,22 +126,44 @@ class Question(models.Model):
blank=True, blank=True,
) )
def is_form_valid(self, vote_form): serializable_fields = ["text_en", "text_fr", "type"]
def is_form_valid(self, vote_form) -> bool:
validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type]) validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type])
return vote_form.is_valid() and validate_function(vote_form) return vote_form.is_valid() and validate_function(vote_form)
@transaction.atomic @transaction.atomic
def cast_ballot(self, user, vote_form): def cast_ballot(self, user: "User", vote_form) -> None:
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type]) cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
cast_function(user, vote_form) cast_function(user, vote_form)
@transaction.atomic @transaction.atomic
def tally(self): def tally(self) -> None:
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type]) tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
tally_function(self) tally_function(self)
@transaction.atomic
def pseudonymize(self):
"""
Generates a random id for each voter
"""
options = list(self.options.prefetch_related("vote_set"))
votes: set[Vote] = set()
for v in self.voters.all():
pseudonym = generate_password(16)
for opt in options:
for vote in opt.vote_set.filter(user=v):
vote.pseudonymous_user = pseudonym
vote.user = None
votes.add(vote)
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
@property @property
def results(self): def results(self) -> str:
return render_to_string( return render_to_string(
f"elections/results/{self.vote_type}_export.txt", {"question": self} f"elections/results/{self.vote_type}_export.txt", {"question": self}
) )
@ -140,14 +185,16 @@ class Question(models.Model):
def vote_type(self): def vote_type(self):
return BALLOT_TYPE[self.type] return BALLOT_TYPE[self.type]
def __str__(self): def __str__(self) -> str:
return self.text return str(self.text)
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
class Option(models.Model): class Option(Serializer, models.Model):
vote_set: models.Manager["Vote"]
question = models.ForeignKey( question = models.ForeignKey(
Question, related_name="options", on_delete=models.CASCADE Question, related_name="options", on_delete=models.CASCADE
) )
@ -158,22 +205,27 @@ class Option(models.Model):
voters = models.ManyToManyField( voters = models.ManyToManyField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
related_name="votes", related_name="votes",
through="Vote", through="elections.Vote",
blank=True, blank=True,
) )
# For now, we store the amount of votes received after the election is tallied # For now, we store the amount of votes received after the election is tallied
nb_votes = models.PositiveSmallIntegerField(_("nombre de votes reçus"), default=0) nb_votes = models.PositiveSmallIntegerField(_("nombre de votes reçus"), default=0)
serializable_fields = ["text_fr", "text_en", "abbreviation"]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# On enlève les espaces et on passe tout en majuscules # On enlève les espaces et on passe tout en majuscules
self.abbreviation = "".join(self.abbreviation.upper().split()) self.abbreviation = "".join(self.abbreviation.upper().split())
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self): def get_abbr(self, default: str) -> str:
return self.abbreviation or default
def __str__(self) -> str:
if self.abbreviation: if self.abbreviation:
return self.abbreviation + " - " + self.text return f"{self.abbreviation} - {self.text}"
return self.text return str(self.text)
class Meta: class Meta:
ordering = ["id"] ordering = ["id"]
@ -181,12 +233,22 @@ class Option(models.Model):
class Vote(models.Model): class Vote(models.Model):
option = models.ForeignKey(Option, on_delete=models.CASCADE) option = models.ForeignKey(Option, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, 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)
class Meta: class Meta:
ordering = ["option"] ordering = ["option"]
class RankedVote(Vote):
rank: "Rank"
class Meta:
abstract = True
class Rank(models.Model): class Rank(models.Model):
vote = models.OneToOneField(Vote, on_delete=models.CASCADE) vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
rank = models.PositiveSmallIntegerField(_("rang de l'option")) rank = models.PositiveSmallIntegerField(_("rang de l'option"))
@ -214,6 +276,10 @@ class Duel(models.Model):
class User(AbstractUser): class User(AbstractUser):
cast_elections: "ManyRelatedManager[Election]"
cast_questions: "ManyRelatedManager[Question]"
votes: "ManyRelatedManager[Vote]"
election = models.ForeignKey( election = models.ForeignKey(
Election, Election,
related_name="registered_voters", related_name="registered_voters",
@ -225,25 +291,30 @@ class User(AbstractUser):
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None) has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
@property @property
def base_username(self): def base_username(self) -> str:
return "__".join(self.username.split("__")[1:]) return "__".join(self.username.split("__")[1:])
def can_vote(self, request, election): def can_vote(self, request: HttpRequest, election: Election) -> bool:
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections # Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
# ouvertes à tou·te·s # ouvertes à tou·te·s
if self.election is None: if self.election is None:
# If the user is connected via CAS, request.session["CASCONNECTED"] is set # If the user is connected via CAS, request.session["CASCONNECTED"] is set
# to True by authens # to True by authens
return not election.restricted and request.session.get("CASCONNECTED") return not election.restricted and request.session.get(
"CASCONNECTED", False
)
# Pour les élections restreintes, il faut y être associé # Pour les élections restreintes, il faut y être associé
return election.restricted and (self.election == election) return election.restricted and (self.election == election)
def get_prefix(self): def is_admin(self, election: Election) -> bool:
return election.created_by == self or self.is_staff
def get_prefix(self) -> str:
return self.username.split("__")[0] return self.username.split("__")[0]
@property @property
def connection_method(self): def connection_method(self) -> "_StrPromise":
method = self.username.split("__")[0] method = self.username.split("__")[0]
return CONNECTION_METHODS.get(method, _("identifiants spécifiques")) return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))

View file

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

View file

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

View file

@ -0,0 +1,40 @@
{% load i18n markdown %}
<div class="panel-block" id="o_{{ o.pk }}">
{% if o.question.election.start_date > current_time %}
<span class="tags has-addons mb-0">
<a class="tag is-danger is-light is-outlined has-tooltip-primary mb-0 del" data-tooltip="{% trans "Supprimer" %}" data-url="{% url 'election.del-option' o.pk %}" data-target="o_{{ o.pk }}">
<span class="icon">
<i class="fas fa-times"></i>
</span>
</a>
<a class="tag is-info is-light is-outlined has-tooltip-primary mb-0 modal-button" data-tooltip="{% trans "Modifier" %}" data-post_url="{% url 'election.mod-option' o.pk %}" data-target="modal-option" data-json='{{ o.to_json }}' data-title="{% trans "Modifier l'option" %}" data-parent="o_{{ o.pk }}">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</a>
</span>
{% elif o.question.election.tallied %}
<span class="tag {% if o.winner %}is-success{% else %}is-primary{% endif %}">
<span class="icon-text">
{% if q.vote_type == "select" %}
<span class="icon">
<i class="fas fa-vote-yea"></i>
</span>
<span>{{ o.nb_votes }}</span>
{% elif q.vote_type == "rank" %}
<span class="icon">
<i class="fas fa-layer-group"></i>
</span>
<span>{% if o.abbreviation %}{{ o.abbreviation }}{% else %}{{ forloop.counter }}{% endif %}</span>
{% endif %}
</span>
{% endif %}
</span>
<span class="ml-2">{{ o }}</span>
</div>

View file

@ -0,0 +1,67 @@
{% load i18n markdown %}
<div class="panel" id="q_{{ q.pk }}">
<div class="panel-heading is-size-6">
<div class="level is-mobile">
<div class="level-left is-flex-shrink-1 mr-2">
<span class="mr-2">
<span class="icon">
<i class="fas fa-poll-h"></i>
</span>
<span>{{ q }}</span>
</span>
{% if q.election.start_date > current_time %}
<a class="tag is-outlined is-light is-danger del" data-url="{% url 'election.del-question' q.pk %}" data-target="q_{{ q.pk }}">
<span class="icon-text">
<span class="icon">
<i class="fas fa-times"></i>
</span>
<span>{% trans "Supprimer" %}</span>
</span>
</a>
<a class="tag is-outlined is-light is-info ml-1 modal-button" data-post_url="{% url 'election.mod-question' q.pk %}" data-target="modal-question" data-json='{{ q.to_json }}' data-title="{% trans "Modifier la question" %}" data-parent="q_{{ q.pk }}">
<span class="icon-text">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>{% trans "Modifier" %}</span>
</span>
</a>
{% endif %}
</div>
<div class="level-right">
<span class="tag is-outlined is-primary is-light">{{ q.get_type_display }}</span>
</div>
</div>
</div>
{# Liste des options possibles #}
<div id="options_{{ q.pk }}">
{% for o in q.options.all %}
{% include 'elections/admin/option.html' %}
{% endfor %}
</div>
{# Permet d'afficher une ligne #}
<div class="panel-block py-0"></div>
{# Affiche plus d'informations sur le résultat #}
{% if q.election.tallied %}
{{ q.get_results_data }}
{% endif %}
{# Rajout d'une option #}
{% if q.election.start_date > current_time %}
<div class="panel-block">
<button class="button modal-button is-primary is-outlined is-fullwidth option" data-post_url="{% url 'election.add-option' q.pk %}" data-target="modal-option" data-title="{% trans "Rajouter une option" %}" data-json='{"text_fr": "", "text_en": "", "abbreviation": ""}' data-next="options_{{ q.pk }}">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>{% trans "Rajouter une option" %}</span>
</button>
</div>
{% endif %}
</div>

View file

@ -3,13 +3,13 @@
<thead> <thead>
<tr> <tr>
{% for o in options %} {% for o in options %}
<th>{{ o }}</th> <th class="has-text-centered">{{ o }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for ballot in ballots.values %} {% for ballot in ballots %}
<tr> <tr>
{% for r in ballot %} {% for r in ballot %}
<td class="has-text-centered">{{ r }}</td> <td class="has-text-centered">{{ r }}</td>

View file

@ -3,13 +3,13 @@
<thead> <thead>
<tr> <tr>
{% for o in options %} {% for o in options %}
<th>{{ o }}</th> <th class="has-text-centered">{{ o }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for ballot in ballots.values %} {% for ballot in ballots %}
<tr> <tr>
{% for v in ballot %} {% for v in ballot %}
<td class="has-text-centered"> <td class="has-text-centered">

View file

@ -4,38 +4,16 @@
{% block content %} {% block content %}
<div class="level"> <div class="level mb-2 is-mobile">
{# Titre de l'élection #} {# Titre de l'élection #}
<div class="level-left is-flex-shrink-1"> <div class="level-left is-flex-shrink-1 pr-3">
<h1 class="title">{{ election.name }}</h1> <h1 class="title">{{ election.name }}</h1>
</div> </div>
<div class="level-right"> <div class="level-right is-flex is-flex-shrink-1">
{# Liste des votant·e·s #}
<div class="level-item">
<a class="button is-primary is-light is-outlined" href="{% url 'election.voters' election.pk %}">
<span class="icon">
<i class="fas fa-clipboard-list"></i>
</span>
<span>{% trans "Votant·e·s" %}</span>
</a>
</div>
{# Liste des bulletins #}
{% if election.results_public %}
<div class="level-item">
<a class="button is-primary is-light is-outlined" href="{% url 'election.ballots' election.pk %}">
<span class="icon">
<i class="fas fa-list"></i>
</span>
<span>{% trans "Bulletins" %}</span>
</a>
</div>
{% endif %}
{% if election.start_date < current_time %}
{# Statut de l'élection #} {# Statut de l'élection #}
<div class="level-item"> {% if election.start_date < current_time %}
<div class="level-item is-flex-shrink-1">
<span class="tag is-medium is-outlined is-light is-primary"> <span class="tag is-medium is-outlined is-light is-primary">
{% if election.end_date < current_time %} {% if election.end_date < current_time %}
{% trans "Élection terminée" %} {% trans "Élection terminée" %}
@ -46,56 +24,111 @@
</div> </div>
{% endif %} {% endif %}
<div class="level-item">
<div class="dropdown is-right">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span class="icon">
<i class="fas fa-ellipsis-v" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
{# Lien vers la page d'administration #} {# Lien vers la page d'administration #}
{% if election.created_by == user %} {% if election.created_by == user %}
<div class="level-item"> <a class="dropdown-item" href="{% url 'election.admin' election.pk %}">
<a class="button has-tooltip-primary" href="{% url 'election.admin' election.pk %}" data-tooltip="{% trans "Administrer" %}">
<span class="icon"> <span class="icon">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
</span> </span>
<span>{% trans "Administrer" %}</span>
</a> </a>
</div>
<hr class="dropdown-divider">
{% endif %} {% endif %}
{# Liste des votant·e·s #}
<a class="dropdown-item" href="{% url 'election.voters' election.pk %}">
<span class="icon">
<i class="fas fa-clipboard-list"></i>
</span>
<span>{% trans "Votant·e·s" %}</span>
</a>
{# Liste des bulletins #}
{% if election.results_public %}
<a class="dropdown-item" href="{% url 'election.ballots' election.pk %}">
<span class="icon">
<i class="fas fa-list"></i>
</span>
<span>{% trans "Bulletins" %}</span>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="level"> <div class="level">
{# Dates d'ouverture de l'élection #} {# Dates d'ouverture de l'élection #}
<div class="level-left"> <div class="level-left is-flex-shrink-1 pr-3">
<div class="level is-mobile">
<div class="level-item"> <div class="level-item">
<span class="tag is-medium is-primary"> <span class="tag is-medium is-primary">
<span class="icon-text">
<span>{{ election.start_date|date:"d/m/Y H:i" }}</span> <span>{{ election.start_date|date:"d/m/Y H:i" }}</span>
<span class="icon"> <span class="icon">
<i class="fas fa-long-arrow-alt-right"></i> <i class="fas fa-long-arrow-alt-right"></i>
</span> </span>
<span>{{ election.end_date|date:"d/m/Y H:i" }}</span> <span>{{ election.end_date|date:"d/m/Y H:i" }}</span>
</span> </span>
</span>
</div> </div>
{# Créateurice de l'élection #} {# Créateurice de l'élection #}
<div class="level-item"> <div class="level-item is-flex-shrink-1">
<span class="tag is-primary is-light is-outlined">{% blocktrans with creator=election.created_by.full_name %}Créé par {{ creator }}{% endblocktrans %}</span> <span class="tag is-primary is-light is-outlined">{% blocktrans with creator=election.created_by.full_name %}Créé par {{ creator }}{% endblocktrans %}</span>
</div> </div>
</div> </div>
</div>
{# Confirmation de vote #} {# Confirmation de vote #}
{% if has_voted %} {% if has_voted %}
<div class="level-right"> <div class="level-right is-flex-shrink-1">
<div class="level-item"> <div class="level-item is-flex-shrink-1">
<div class="tag is-medium is-outlined is-success is-light"> <div class="tag is-medium is-outlined is-success is-light">
<span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
</span> </span>
<span>{% trans "Votre vote a bien été enregistré." %}</span> <span>{% trans "Votre vote a bien été enregistré." %}</span>
</span>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="level">
<div class="level-left is-flex">
{# Date du dépouillement #}
{% if election.time_tallied %}
<div class="level-item is-flex-grow-1 mb-0">
<span class="tag is-success is-light is-outlined">
{% blocktrans with timestamp=election.time_tallied|date:"d/m/Y H:i" %}Dépouillé le {{ timestamp }}{% endblocktrans %}
</span>
</div>
{% endif %}
{# Date de la publication #}
{% if election.time_published %}
<div class="level-item is-flex-grow-1 mb-0">
<span class="tag is-info is-light is-outlined">
{% blocktrans with timestamp=election.time_published|date:"d/m/Y H:i" %}Publié le {{ timestamp }}{% endblocktrans %}
</span>
</div>
{% endif %}
</div>
</div>
<hr> <hr>
{# Précisions sur les modalités de vote #} {# Précisions sur les modalités de vote #}
@ -106,7 +139,23 @@
{% endif %} {% endif %}
{# Indications de connexion #} {# Indications de connexion #}
{% if election.start_date < current_time and election.end_date > current_time %} {% if election.start_date > current_time %}
<div class="tile is-ancestor">
<div class="tile is-parent">
<div class="tile is-child notification is-primary is-light">
<div class="has-text-centered mb-2">
<p class="subtitle">
<span class="icon">
<i class="fas fa-clock"></i>
</span>
<span class="ml-3">{% blocktrans with _date=election.start_date|date:"d/m/Y" _time=election.start_date|date:_("H:i") %}Le vote ouvrira le <b>{{ _date }}</b> à <b>{{ _time }}</b>.{% endblocktrans %}</span>
</p>
<p>{% trans "Revenez sur cette page quand le vote sera ouvert pour vous connecter et participer." %}</p>
</div>
</div>
</div>
</div>
{% elif election.end_date > current_time %}
{% if can_vote %} {% if can_vote %}
<div class="columns is-centered tile is-ancestor"> <div class="columns is-centered tile is-ancestor">
<div class="column is-one-third tile is-parent"> <div class="column is-one-third tile is-parent">
@ -150,7 +199,7 @@
</div> </div>
</a> </a>
{% else %} {% else %}
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}"> <a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}?next={% url 'election.view' election.pk %}">
<div class="subtitle has-text-centered mb-2"> <div class="subtitle has-text-centered mb-2">
<span class="icon-text"> <span class="icon-text">
<span class="icon has-text-white"> <span class="icon has-text-white">
@ -179,9 +228,9 @@
{% for q in election.questions.all %} {% for q in election.questions.all %}
<div class="panel" id="q_{{ q.pk }}"> <div class="panel" id="q_{{ q.pk }}">
<div class="panel-heading is-size-6"> <div class="panel-heading is-size-6">
<div class="level"> <div class="level is-mobile">
<div class="level-left is-flex-shrink-1"> <div class="level-left is-flex-shrink-1">
<span class="icon-text"> <span class="mr-3">
<span class="icon"> <span class="icon">
<i class="fas fa-poll-h"></i> <i class="fas fa-poll-h"></i>
</span> </span>

View file

@ -2,27 +2,64 @@
{% load i18n markdown %} {% load i18n markdown %}
{% block extra_head %} {% block custom_js %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { const _fm = b => {
var $modalButtons = document.querySelectorAll('.modal-button') || []; b.addEventListener('click', () => {
const f = _$('form', _id(b.dataset.target), false);
f.dataset.next = b.dataset.next;
f.dataset.origin = b.dataset.parent
$modalButtons.forEach($el => { const d = JSON.parse(b.dataset.json);
$el.addEventListener('click', () => {
var $target = document.getElementById($el.dataset.target);
var $target_form = $target.querySelector("form");
var modal_title = '';
$target_form.action = $el.dataset.post_url;
$target.querySelector('.modal-card-title').innerHTML = $el.dataset.title;
if ($el.classList.contains('question')) { for (const [k, v] of Object.entries(d)) {
$target_form.querySelector('#id_text_fr').value = $el.dataset.q_fr || ''; _$(`[name='${k}']`, f, false).value = v;
$target_form.querySelector('#id_text_en').value = $el.dataset.q_en || ''; }
$target_form.querySelector('#id_type').value = $el.dataset.type || 'assentiment'; });
} else if ($el.classList.contains('option')) { }
$target_form.querySelector('#id_text_fr').value = $el.dataset.o_fr || '';
$target_form.querySelector('#id_text_en').value = $el.dataset.o_en || ''; _$('.modal-button').forEach(_fm);
$target_form.querySelector('#id_abbreviation').value = $el.dataset.abbr || '';
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');
} }
}); });
}); });
@ -42,6 +79,8 @@
<div class="level-right"> <div class="level-right">
{# Visibilité de l'élection #} {# Visibilité de l'élection #}
<div class="level-item">
<div class="level is-mobile">
<div class="level-item"> <div class="level-item">
{% if not election.visible %} {% if not election.visible %}
<span class="tag is-medium is-outlined is-warning is-light"> <span class="tag is-medium is-outlined is-warning is-light">
@ -65,12 +104,9 @@
<div class="dropdown is-right"> <div class="dropdown is-right">
<div class="dropdown-trigger"> <div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu"> <button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fas fa-cog" aria-hidden="true"></i> <i class="fas fa-cog" aria-hidden="true"></i>
</span> </span>
<span>{% trans "Actions" %}</span>
</span>
</button> </button>
</div> </div>
@ -127,7 +163,7 @@
{% if not election.tallied %} {% if not election.tallied %}
{# Liste des votants #} {# Liste des votants #}
<a class="dropdown-item" href="{% url 'election.voters' election.pk %}"> <a class="dropdown-item" href="{% url 'election.voters' election.pk %}?prev=admin">
<span class="icon"> <span class="icon">
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
</span> </span>
@ -178,7 +214,8 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -219,109 +256,11 @@
{% endif %} {% endif %}
{# Liste des questions #} {# Liste des questions #}
<div id="questions" class="block">
{% for q in election.questions.all %} {% for q in election.questions.all %}
<div class="panel" id="q_{{ q.pk }}"> {% include 'elections/admin/question.html' %}
<div class="panel-heading is-size-6">
<div class="level">
<div class="level-left is-flex-shrink-1">
<div class="level-item is-flex-shrink-1">
<span class="icon-text">
<span class="icon">
<i class="fas fa-poll-h"></i>
</span>
<span>{{ q }}</span>
</span>
</div>
{% if election.start_date > current_time %}
<div class="level-item">
<a class="tag is-outlined is-light is-danger" href="{% url 'election.del-question' q.pk %}">
<span class="icon-text">
<span class="icon">
<i class="fas fa-times"></i>
</span>
<span>{% trans "Supprimer" %}</span>
</span>
</a>
<a class="tag is-outlined is-light is-info ml-1 modal-button question" data-post_url="{% url 'election.mod-question' q.pk %}" data-target="modal-question" data-type="{{ q.type }}" data-q_en="{{ q.text_en }}" data-q_fr="{{ q.text_fr }}" data-title="{% trans "Modifier la question" %}">
<span class="icon-text">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>{% trans "Modifier" %}</span>
</span>
</a>
</div>
{% endif %}
</div>
<div class="level-right">
<span class="tag is-outlined is-primary is-light">{{ q.get_type_display }}</span>
</div>
</div>
</div>
{# Liste des options possibles #}
{% for o in q.options.all %}
<div class="panel-block" id="o_{{ o.pk }}">
{% if election.start_date > current_time %}
<span class="tags has-addons mb-0">
<a class="tag is-danger is-light is-outlined has-tooltip-primary mb-0" data-tooltip="{% trans "Supprimer" %}" href="{% url 'election.del-option' o.pk %}">
<span class="icon">
<i class="fas fa-times"></i>
</span>
</a>
<a class="tag is-info is-light is-outlined has-tooltip-primary mb-0 modal-button option" data-tooltip="{% trans "Modifier" %}" data-post_url="{% url 'election.mod-option' o.pk %}" data-target="modal-option" data-o_en="{{ o.text_en }}" data-o_fr="{{ o.text_fr }}" data-abbr="{{ o.abbreviation }}" data-title="{% trans "Modifier l'option" %}">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</a>
</span>
{% elif election.tallied %}
<span class="tag {% if o.winner %}is-success{% else %}is-primary{% endif %}">
<span class="icon-text">
{% if q.vote_type == "select" %}
<span class="icon">
<i class="fas fa-vote-yea"></i>
</span>
<span>{{ o.nb_votes }}</span>
{% elif q.vote_type == "rank" %}
<span class="icon">
<i class="fas fa-layer-group"></i>
</span>
<span>{% if o.abbreviation %}{{ o.abbreviation }}{% else %}{{ forloop.counter }}{% endif %}</span>
{% endif %}
</span>
{% endif %}
</span>
<span class="ml-2">{{ o }}</span>
</div>
{% endfor %} {% endfor %}
{# Affiche plus d'informations sur le résultat #}
{% if election.tallied %}
{{ q.get_results_data }}
{% endif %}
{# Rajout d'une option #}
{% if election.start_date > current_time %}
<div class="panel-block">
<button class="button modal-button is-primary is-outlined is-fullwidth option" data-post_url="{% url 'election.add-option' q.pk %}" data-target="modal-option" data-title="{% trans "Rajouter une option" %}">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>{% trans "Rajouter une option" %}</span>
</button>
</div> </div>
{% endif %}
</div>
{% endfor %}
{# Rajout d'une question #} {# Rajout d'une question #}
{% if election.start_date > current_time %} {% if election.start_date > current_time %}
@ -336,7 +275,7 @@
<div class="columns is-centered" id="q_add"> <div class="columns is-centered" id="q_add">
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<button class="button modal-button is-primary is-outlined is-fullwidth question" data-post_url="{% url 'election.add-question' election.pk %}" data-target="modal-question" data-title="{% trans "Rajouter une question" %}"> <button class="button modal-button is-primary is-outlined is-fullwidth question" data-post_url="{% url 'election.add-question' election.pk %}" data-target="modal-question" data-title="{% trans "Rajouter une question" %}" data-next="questions" data-json='{"text_fr": "", "text_en": "", "type": "assentiment"}'>
<span class="icon"> <span class="icon">
<i class="fas fa-question"></i> <i class="fas fa-question"></i>
</span> </span>

View file

@ -3,9 +3,9 @@
{% block content %} {% block content %}
<div class="level"> <div class="level is-mobile">
{# Titre de l'élection #} {# Titre de l'élection #}
<div class="level-left is-flex-shrink-1"> <div class="level-left is-flex-shrink-1 pr-3">
<h1 class="title">{{ election.name }}</h1> <h1 class="title">{{ election.name }}</h1>
</div> </div>

View file

@ -6,17 +6,17 @@
{# DateTimePicker #} {# DateTimePicker #}
<script src="{% static 'vendor/datetimepicker/picker.js' %}"></script> <script src="{% static 'vendor/datetimepicker/picker.js' %}"></script>
<link rel="stylesheet" href="{% static 'vendor/datetimepicker/picker.css' %}"> <link rel="stylesheet" href="{% static 'vendor/datetimepicker/picker.css' %}">
{% endblock %}
{% block custom_js %}
<script> <script>
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
document.addEventListener('DOMContentLoaded', () => {
new DateTimePicker('input[name=start_date]', { new DateTimePicker('input[name=start_date]', {
lang: '{{ LANGUAGE_CODE }}', lang: '{{ LANGUAGE_CODE }}',
}); });
new DateTimePicker('input[name=end_date]', { new DateTimePicker('input[name=end_date]', {
lang: '{{ LANGUAGE_CODE }}', lang: '{{ LANGUAGE_CODE }}',
}); });
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -4,14 +4,14 @@
{% block content %} {% block content %}
<div class="level"> <div class="level is-mobile">
<div class="level-left"> <div class="level-left is-flex-shrink-1 pr-3">
<div class="level-item"> <div class="level-item is-flex-shrink-1">
<h1 class="title">{% trans "Liste des élections" %}</h1> <h1 class="title">{% trans "Liste des élections" %}</h1>
</div> </div>
</div> </div>
{% if perms.elections.is_admin %} {% if perms.elections.election_admin %}
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
<a class="button is-light is-outlined is-primary" href={% url 'election.create' %}> <a class="button is-light is-outlined is-primary" href={% url 'election.create' %}>
@ -29,13 +29,13 @@
{% for e in election_list %} {% for e in election_list %}
<div class="panel is-primary is-radiusless"> <div class="panel is-primary is-radiusless">
<div class="panel-heading is-size-6 is-radiusless"> <div class="panel-heading is-size-6 is-radiusless">
<div class="level"> <div class="level is-mobile mb-0">
<div class="level-left is-flex-shrink-1"> <div class="level-left is-flex-shrink-1">
<div class="level-item is-flex-shrink-1"> <div class="level-item is-flex-shrink-1">
<a class="has-text-primary-light" href="{% url 'election.view' e.pk %}"><u>{{ e.name }}</u></a> <a class="has-text-primary-light" href="{% url 'election.view' e.pk %}"><u>{{ e.name }}</u></a>
</div> </div>
<div class="level-item"> <div class="level-item is-hidden-touch">
<span class="tag is-primary is-light"> <span class="tag is-primary is-light">
<span class="icon-text"> <span class="icon-text">
<span>{{ e.start_date|date:"d/m/Y H:i" }}</span> <span>{{ e.start_date|date:"d/m/Y H:i" }}</span>
@ -49,48 +49,73 @@
</div> </div>
<div class="level-right"> <div class="level-right">
{% if not e.visible %}
<div class="level-item"> <div class="level-item">
{% if not e.visible %}
<span class="tag is-warning is-light"> <span class="tag is-warning is-light">
<span class="icon"> <span class="icon">
<i class="fas fa-eye-slash"></i> <i class="fas fa-eye-slash"></i>
</span> </span>
<span>{% trans "Élection invisible" %}</span> <span>{% trans "Élection invisible" %}</span>
</span> </span>
</div>
{% endif %} {% endif %}
{% if e.tallied %}
<div class="level-item">
<span class="tag is-success is-light">{% trans "Élection dépouillée" %}</span>
</div>
{% endif %}
{% if e.results_public %}
<div class="level-item">
<span class="tag is-info is-light">{% trans "Élection publiée" %}</span>
</div>
{% endif %}
{% if e.archived %}
<div class="level-item">
<span class="tag is-danger is-light">{% trans "Élection archivée" %}</span>
</div>
{% endif %}
{% if e.created_by == user %} {% if e.created_by == user %}
<div class="level-item">
<a class="has-text-primary-light ml-3 has-tooltip-light" href="{% url 'election.admin' e.pk %}" data-tooltip="{% trans "Administrer" %}"> <a class="has-text-primary-light ml-3 has-tooltip-light" href="{% url 'election.admin' e.pk %}" data-tooltip="{% trans "Administrer" %}">
<span class="icon"> <span class="icon">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
</span> </span>
</a> </a>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="is-hidden-desktop mt-2">
<span class="tag is-primary is-light">
<span class="icon-text">
<span>{{ e.start_date|date:"d/m/Y H:i" }}</span>
<span class="icon has-text-primary">
<i class="fas fa-long-arrow-alt-right"></i>
</span>
<span>{{ e.end_date|date:"d/m/Y H:i" }}</span>
</span>
</span>
</div>
</div>
{% if e.tallied or e.results_public or e.archived %}
<div class="panel-block">
<div class="is-flex-grow-1">
<div class="tags">
{% if e.tallied %}
<span class="tag is-success is-light is-outlined">
{% if e.time_tallied %}
{% blocktrans with timestamp=e.time_tallied|date:"d/m/Y H:i" %}Élection dépouillée le {{ timestamp }}{% endblocktrans %}
{% else %}
{% trans "Élection dépouillée" %}
{% endif %}
</span>
{% endif %}
{% if e.results_public %}
<span class="tag is-info is-light is-outlined">
{% if e.time_published %}
{% blocktrans with timestamp=e.time_published|date:"d/m/Y H:i" %}Élection publiée le {{ timestamp }}{% endblocktrans %}
{% else %}
{% trans "Élection publiée" %}
{% endif %}
</span>
{% endif %}
{% if e.archived %}
<span class="tag is-danger is-light is-outlined">{% trans "Élection archivée" %}</span>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if e.description %} {% if e.description %}
<div class="panel-block"> <div class="panel-block">
<div class="content is-flex-grow-1"> <div class="content is-flex-grow-1">

View file

@ -6,17 +6,17 @@
{# DateTimePicker #} {# DateTimePicker #}
<script src="{% static 'vendor/datetimepicker/picker.js' %}"></script> <script src="{% static 'vendor/datetimepicker/picker.js' %}"></script>
<link rel="stylesheet" href="{% static 'vendor/datetimepicker/picker.css' %}"> <link rel="stylesheet" href="{% static 'vendor/datetimepicker/picker.css' %}">
{% endblock %}
{% block custom_js %}
<script> <script>
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
document.addEventListener('DOMContentLoaded', () => {
new DateTimePicker('input[name=start_date]', { new DateTimePicker('input[name=start_date]', {
lang: '{{ LANGUAGE_CODE }}', lang: '{{ LANGUAGE_CODE }}',
}); });
new DateTimePicker('input[name=end_date]', { new DateTimePicker('input[name=end_date]', {
lang: '{{ LANGUAGE_CODE }}', lang: '{{ LANGUAGE_CODE }}',
}); });
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -3,6 +3,11 @@
{% block extra_head %} {% block extra_head %}
{# Pendant l'envoi on rafraîchit automatiquement #}
{% if election.sent_mail is None %}
<meta http-equiv="refresh" content="20">
{% endif %}
<script> <script>
{% if not election.sent_mail %} {% if not election.sent_mail %}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -21,42 +26,38 @@
{% block content %} {% block content %}
<div class="level is-flex-widescreen"> <div class="level is-mobile">
<div class="level-left"> <div class="level-left is-flex-shrink-1">
<div class="item-level"> <div class="item-level is-flex-shrink-1 pr-3">
<h1 class="title">{% trans "Gestion de la liste de votant·e·s" %}</h1> <h1 class="title">{% trans "Gestion de la liste de votant·e·s" %}</h1>
</div> </div>
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item is-hidden-touch">
{% if election.sent_mail is False %} {% if election.sent_mail is False %}
<div class="level-item">
<a class="button is-light is-outlined is-primary" href="{% url 'election.mail-voters' election.pk %}"> <a class="button is-light is-outlined is-primary" href="{% url 'election.mail-voters' election.pk %}">
<span class="icon"> <span class="icon">
<i class="fas fa-envelope-open"></i> <i class="fas fa-envelope-open"></i>
</span> </span>
<span>{% trans "Envoyer le mail d'annonce" %}</span> <span>{% trans "Envoyer le mail d'annonce" %}</span>
</a> </a>
</div>
{% elif election.sent_mail is None %} {% elif election.sent_mail is None %}
<div class="level-item"> <a class="button is-light is-outlined is-warning" href="javascript:location.reload();">
<span class="button is-light is-outlined is-warning">
<span class="icon"> <span class="icon">
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
</span> </span>
<span>{% trans "Mail en cours de distribution" %}</span> <span>{% trans "Mail en cours de distribution" %}</span>
</span> </a>
</div>
{% else %} {% else %}
<div class="level-item">
<span class="button is-light is-outlined is-success"> <span class="button is-light is-outlined is-success">
<span class="icon"> <span class="icon">
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
</span> </span>
<span>{% trans "Mail envoyé" %}</span> <span>{% trans "Mail envoyé" %}</span>
</span> </span>
</div>
{% endif %} {% endif %}
</div>
<div class="level-item"> <div class="level-item">
<a class="button is-primary" href="{% url 'election.admin' election.pk %}"> <a class="button is-primary" href="{% url 'election.admin' election.pk %}">
@ -69,9 +70,34 @@
</div> </div>
</div> </div>
{# Si on a déjà envoyé le mail avec les identifiants, on ne peut plus changer la liste #} <div class="level-item is-hidden-desktop">
{% if election.sent_mail is False %} {% if election.sent_mail is False %}
<a class="button is-light is-outlined is-primary" href="{% url 'election.mail-voters' election.pk %}">
<span class="icon">
<i class="fas fa-envelope-open"></i>
</span>
<span>{% trans "Envoyer le mail d'annonce" %}</span>
</a>
{% elif election.sent_mail is None %}
<a class="button is-light is-outlined is-warning" href="javascript:location.reload();">
<span class="icon">
<i class="fas fa-sync-alt"></i>
</span>
<span>{% trans "Mail en cours de distribution" %}</span>
</a>
{% else %}
<span class="button is-light is-outlined is-success">
<span class="icon">
<i class="fas fa-check"></i>
</span>
<span>{% trans "Mail envoyé" %}</span>
</span>
{% endif %}
</div>
<hr> <hr>
{# Si on a déjà envoyé le mail avec les identifiants, on ne peut plus changer la liste #}
{% if not election.sent_mail %}
<div class="message is-warning"> <div class="message is-warning">
<div class="message-body"> <div class="message-body">
{% trans "Importez un fichier au format CSV, avec sur la première colonne le login, sur la deuxième, le nom et prénom et enfin l'adresse email sur la troisième. Soit :<br><br><pre>Login_1,Prénom/Nom_1,mail_1@machin.test<br>Login_2,Prénom/Nom_2,mail_2@bidule.test<br>...</pre>" %} {% trans "Importez un fichier au format CSV, avec sur la première colonne le login, sur la deuxième, le nom et prénom et enfin l'adresse email sur la troisième. Soit :<br><br><pre>Login_1,Prénom/Nom_1,mail_1@machin.test<br>Login_2,Prénom/Nom_2,mail_2@bidule.test<br>...</pre>" %}
@ -117,7 +143,8 @@
<hr> <hr>
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds"> <div class="column is-12">
<div class="table-container">
<table class="table is-fullwidth is-bordered is-striped has-text-centered"> <table class="table is-fullwidth is-bordered is-striped has-text-centered">
<thead> <thead>
<tr> <tr>
@ -150,6 +177,7 @@
</table> </table>
</div> </div>
</div> </div>
</div>
{% endif %} {% endif %}

View file

@ -2,38 +2,66 @@
{% load i18n markdown %} {% load i18n markdown %}
{% block extra_head %} {% block custom_js %}
{% if can_delete %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { _$('.modal-button').forEach(b => {
const $del_modal = document.getElementById('modal-delete'); b.addEventListener('click', () => {
const $del_title = $del_modal.querySelector('.modal-card-title'); const f = _$('form', _id(b.dataset.target), false);
const $del_form = $del_modal.querySelector('form'); f.dataset.target = b.dataset.origin;
_$('[name="delete"]', f, false).value = 'non';
$del_buttons = document.querySelectorAll('.modal-button.delete-vote')
$del_buttons.forEach($del => {
$del.addEventListener('click', () => {
$del_form.action = $del.dataset.post_url;
$del_title.innerHTML = $del.dataset.tooltip;
}); });
}); });
_$('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 %}
// On ferme le modal
document.documentElement.classList.remove('is-clipped');
_id(f.dataset.modal).classList.remove('is-active');
}
if (r.message) {
_notif(r.message.content, r.message.class);
}
});
} else {
document.documentElement.classList.remove('is-clipped');
_id(f.dataset.modal).classList.remove('is-active');
}
});
}); });
</script> </script>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="level"> <div class="level is-mobile">
{# Titre de l'élection #} {# Titre de l'élection #}
<div class="level-left is-flex-shrink-1"> <div class="level-left is-flex-shrink-1 mr-3">
<h1 class="title">{{ election.name }}</h1> <h1 class="title">{{ election.name }}</h1>
</div> </div>
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
<a class="button is-primary" href="{% if can_delete %}{% url 'election.admin' election.pk %}{% else %}{% url 'election.view' election.pk %}{% endif %}"> <a class="button is-primary" href="{% if from_admin %}{% url 'election.admin' election.pk %}{% else %}{% url 'election.view' election.pk %}{% endif %}">
<span class="icon"> <span class="icon">
<i class="fas fa-undo-alt"></i> <i class="fas fa-undo-alt"></i>
</span> </span>
@ -67,8 +95,9 @@
</div> </div>
</div> </div>
{% if can_vote or is_admin %}
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds"> <div class="column is-narrow">
{% if can_delete %} {% if can_delete %}
{% include "forms/modal-form.html" with modal_id="delete" form=d_form %} {% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
{% endif %} {% endif %}
@ -81,25 +110,45 @@
{% if can_delete %} {% if can_delete %}
<th class="has-text-centered">{% trans "Supprimer" %}</th> <th class="has-text-centered">{% trans "Supprimer" %}</th>
{% endif %} {% endif %}
<tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% if election.restricted %} {% if election.restricted %}
{% for v in election.registered_voters.all %} {% for v in election.registered_voters.all %}
<tr> <tr id="v_{{ forloop.counter }}">
<td>{{ v.full_name }} ({{ v.base_username }})</td> <td>{{ v.full_name }} ({{ v.base_username }})</td>
{% if v in voters %}
<td class="has-text-centered"> <td class="has-text-centered">
<span class="icon"> <span class="icon">
{% if v in voters %}
<i class="fas fa-check"></i> <i class="fas fa-check"></i>
{% else %}
<i class="fas fa-times"></i>
{% endif %}
</span> </span>
</td> </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> </tr>
{% endfor %} {% endfor %}
{% else %} {% else %}
{% for v in voters %} {% for v in voters %}
<tr id="v_{{ forloop.counter }}"> <tr id="v_{{ forloop.counter }}">
<td>{{ v.full_name }} ({{ v.base_username }})</td> <td>{{ v.full_name }} ({{ v.base_username }})</td>
@ -111,19 +160,67 @@
{% if can_delete %} {% if can_delete %}
<td class="has-text-centered"> <td class="has-text-centered">
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %} {% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
<a class="tag is-danger has-tooltip-primary modal-button delete-vote" data-target="modal-delete" data-tooltip="{{ v_delete }}" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}"> <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"> <span class="icon">
<i class="fas fa-user-minus"></i> <i class="fas fa-user-minus"></i>
</span> </span>
</a> </a>
</td>
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</tbody>
</table> </table>
</div> </div>
</div> </div>
{% else %}
<div class="notification is-danger is-light has-text-centered">
<b>{% trans "Pour voir la liste des votant·e·s vous devez être connecté·e." %}</b>
{% if election.restricted %}
<br>
<span class="icon">
<i class="fas fa-info-circle"></i>
</span>
<i>{% trans "La connexion doit s'effectuer via les identifiants reçus par mail." %}</i>
{% endif %}
</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 %}
{% endblock %} {% endblock %}

View file

@ -1,19 +0,0 @@
{% extends "base.html" %}
{% load i18n static string %}
{% block content %}
{% for error in form.non_field_errors %}
<div class="notification is-danger">
{{ error }}
</div>
{% endfor %}
<h1 class="title">{% trans "Modification d'une option" %}</h1>
<hr>
{% url 'election.admin' option.question.election.pk as r_url %}
{% include "forms/common-form.html" with anchor=o_|concatenate:option.pk %}
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends "base.html" %}
{% load i18n static string %}
{% block content %}
{% for error in form.non_field_errors %}
<div class="notification is-danger">
{{ error }}
</div>
{% endfor %}
<h1 class="title">{% trans "Modification d'une question" %}</h1>
<hr>
{% url 'election.admin' question.election.pk as r_url %}
{% include "forms/common-form.html" with errors=False r_anchor="q_"|concatenate:question.pk %}
{% endblock %}

View file

@ -1,7 +1,7 @@
{% load i18n %} {% load i18n %}
<div class="panel-block"> <div class="panel-block">
<div class="columns is-centered is-flex-grow-1"> <div class="columns is-centered is-flex-grow-1 is-mobile">
<div class="column is-narrow"> <div class="column is-narrow">
<table class="table is-bordered is-striped"> <table class="table is-bordered is-striped">
<thead> <thead>
@ -24,7 +24,6 @@
<tbody> <tbody>
{% for line, o in matrix %} {% for line, o in matrix %}
{% with loser=forloop.counter %}
<tr> <tr>
<th class="has-text-centered"> <th class="has-text-centered">
<span class="icon-text"> <span class="icon-text">
@ -36,10 +35,9 @@
</th> </th>
{% for cell, class in line %} {% for cell, class in line %}
<td class="has-text-centered has-tooltip-primary {{ class }}" {% if cell %}data-tooltip="{% blocktrans with winner=forloop.counter %}L'option {{ winner }} est préférée à l'option {{ loser }} par {{ cell }} voix.{% endblocktrans %}{% endif %}">{{ cell }}</td> <td class="has-text-centered has-tooltip-primary {{ class }}" {% if cell.value %}data-tooltip="{% blocktrans with winner=cell.winner loser=cell.loser value=cell.value %}L'option {{ winner }} est préférée à l'option {{ loser }} par {{ value }} voix.{% endblocktrans %}{% endif %}">{{ cell.value }}</td>
{% endfor %} {% endfor %}
</tr> </tr>
{% endwith %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -5,30 +5,30 @@
{% block extra_head %} {% block extra_head %}
<script> <script>
const nb_options = {{ nb_options }}; const nb_options = {{ nb_options }};
var ranks_used = nb_options; const rank_zones = new Array(nb_options + 1);
var rank_zones = new Array(nb_options + 1); let ranks_used = nb_options;
var $unranked; var $unranked;
function getLabelText($input) { function getLabelText(i) {
var label = $input.closest('.field').querySelector('.label label').innerHTML; const label = _$('.label label', i.closest('.field'), false).innerHTML;
return label.substring(0, label.length - 1).trim(); return label.substring(0, label.length - 1).trim();
} }
function collapseRanks() { function collapseRanks() {
// On décale pour éviter les rangs vides // On décale pour éviter les rangs vides
for (let i = 1; i < nb_options; i++) { for (let j = 1; j < nb_options; j++) {
// On a au moins le tag avec le numéro du rang // On a au moins le tag avec le numéro du rang
if (rank_zones[i].childElementCount == 1) { if (rank_zones[j].childElementCount == 1) {
// On cherche le prochain rang avec des options // On cherche le prochain rang avec des options
var next_rank = i + 1; let next_rank = j + 1;
for (; next_rank < nb_options && rank_zones[next_rank].childElementCount == 1; next_rank++) {} for (; next_rank < nb_options && rank_zones[next_rank].childElementCount == 1; next_rank++) {}
// On déplace les options // On déplace les options
while (rank_zones[next_rank].childElementCount > 1) { while (rank_zones[next_rank].childElementCount > 1) {
let $tile = rank_zones[next_rank].lastChild; const t = rank_zones[next_rank].lastChild;
let $input = document.getElementById($tile.dataset.input); const i = _id(t.dataset.input);
$input.value = i.toString(); i.value = j.toString();
rank_zones[i].append($tile); rank_zones[j].append(t);
} }
} }
} }
@ -36,14 +36,6 @@
// On recalcule ranks_used // On recalcule ranks_used
for (ranks_used = 0; ranks_used < nb_options && rank_zones[ranks_used + 1].childElementCount > 1; ranks_used++) {} for (ranks_used = 0; ranks_used < nb_options && rank_zones[ranks_used + 1].childElementCount > 1; ranks_used++) {}
// On affiche le bouton + si besoin
// let $add_rank = document.getElementById('rank-add');
// if (ranks_used < (nb_options - 1)) {
// $add_rank.parentElement.classList.remove('is-hidden')
// } else {
// $add_rank.parentElement.classList.add('is-hidden')
// }
// On cache les zones non utilisées, sauf une // On cache les zones non utilisées, sauf une
for (let i = 1; i <= nb_options; i++) { for (let i = 1; i <= nb_options; i++) {
if (i > (ranks_used + 1)) { if (i > (ranks_used + 1)) {
@ -55,19 +47,19 @@
} }
function moveOptions() { function moveOptions() {
(document.querySelectorAll('.control .input') || []).forEach($input => { _$('.control .input').forEach(i => {
// On rajoute la tuile dans le classement ou dans les non classées // On rajoute la tuile dans le classement ou dans les non classées
const rank = parseInt($input.value); const r = parseInt(i.value);
var $tile = document.getElementById(`tile-${$input.id}`); const t = _id(`tile-${i.id}`);
if (!(typeof rank === 'undefined') && rank > 0 && rank <= nb_options) { if (!(typeof r === 'undefined') && r > 0 && r <= nb_options) {
rank_zones[rank].appendChild($tile); rank_zones[r].appendChild(t);
rank_zones[rank].parentElement.classList.remove('is-hidden'); rank_zones[r].parentElement.classList.remove('is-hidden');
ranks_used = Math.max(rank, ranks_used); ranks_used = Math.max(r, ranks_used);
} else { } else {
$unranked.appendChild($tile); $unranked.appendChild(t);
// On enlève les valeurs non règlementaires // On enlève les valeurs non règlementaires
$input.value = ''; i.value = '';
} }
}); });
} }
@ -87,26 +79,19 @@
// On récupère l'id de la tuile à déplacer // On récupère l'id de la tuile à déplacer
const data = event.dataTransfer.getData('text/plain'); const data = event.dataTransfer.getData('text/plain');
var $target = event.target.closest('.drop-zone'); const d = event.target.closest('.drop-zone');
if ($target.id == 'rank-add') { const r = d.dataset.rank;
ranks_used += 1; const t = _id(data);
const i = _id(t.dataset.input);
$target = rank_zones[ranks_used];
$target.parentElement.classList.remove('is-hidden');
}
const rank = $target.dataset.rank;
var $tile = document.getElementById(data);
var $input = document.getElementById($tile.dataset.input);
// Si on ne change pas de rang, pas besoin de déplacer l'option // Si on ne change pas de rang, pas besoin de déplacer l'option
if ($input.value != rank) { if (i.value != r) {
// On déplace l'option // On déplace l'option
$target.appendChild($tile); d.appendChild(t);
// On enregistre le rang dans le formulaire // On enregistre le rang dans le formulaire
$input.value = rank; i.value = r;
} }
collapseRanks(); collapseRanks();
@ -114,37 +99,35 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Affiche le modal et remplit le récapitulatif // Affiche le modal et remplit le récapitulatif
document.getElementById('confirm-button').addEventListener('click', () => { _id('confirm-button').addEventListener('click', () => {
var $modal_body = document.getElementById('modal-body'); const ranks = new Array(nb_options + 1);
var ranks = new Array(nb_options + 1); _$('.control .input').forEach(i => {
const r = parseInt(i.value) || nb_options;
(document.querySelectorAll('.control .input') || []).forEach($input => { const o = getLabelText(i)
var rank = parseInt($input.value) || nb_options;
var option = getLabelText($input) if (r > 0 && r <= nb_options) {
ranks[r] = (ranks[r] || []).concat([o]);
if (rank > 0 && rank <= nb_options) {
ranks[rank] = (ranks[rank] || []).concat([option]);
} else { } else {
ranks[nb_options] = (ranks[nb_options] || []).concat([option]); ranks[nb_options] = (ranks[nb_options] || []).concat([o]);
} }
}); });
var table_rows = ''; let trs = '';
for (let i = 1; i <= nb_options; i++) { for (let j = 1; j <= nb_options; j++) {
var option_list = ''; let option_list = '';
if (!(typeof ranks[i] === 'undefined')) { if (!(typeof ranks[j] === 'undefined')) {
for (option of ranks[i]) { for (option of ranks[j]) {
option_list += `${option}<br>`; option_list += `${option}<br>`;
} }
} }
table_rows += `<tr><th>${i}</th><td><div>${option_list}</div></td></tr>\n` trs += `<tr><th>${j}</th><td><div>${option_list}</div></td></tr>\n`
} }
$modal_body.innerHTML = ` _id('modal-body').innerHTML = `
<table class="table is-fullwidth is-striped"> <table class="table is-fullwidth is-striped">
<thead> <thead>
<tr> <tr>
@ -153,62 +136,60 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${table_rows} ${trs}
</tbody> </tbody>
</table>`; </table>`;
}); });
// Change le mode de remplissge de formulaire (input vs drag & drop) // Change le mode de remplissge de formulaire (input vs drag & drop)
document.getElementById('change-method').addEventListener('click', () => { _id('change-method').addEventListener('click', () => {
var $hide = document.getElementById('hide-form'); const h = _id('hide-form');
var $drag_zone = document.getElementById('drag-zone'); const d = _id('drag-zone');
var $method_button = document.getElementById('change-method'); const b = _id('change-method');
// On échange ce qui est visible // On échange ce qui est visible
$hide.classList.toggle('is-hidden'); h.classList.toggle('is-hidden');
$drag_zone.classList.toggle('is-hidden'); d.classList.toggle('is-hidden');
if ($hide.classList.contains('is-hidden')) { if (h.classList.contains('is-hidden')) {
$method_button.innerHTML = "{% trans "Utiliser le formulaire classique" %}"; b.innerHTML = "{% trans "Utiliser le formulaire classique" %}";
moveOptions(); moveOptions();
collapseRanks(); collapseRanks();
} else { } else {
$method_button.innerHTML = "{% trans "Utiliser le cliquer-déposer" %}"; b.innerHTML = "{% trans "Utiliser le cliquer-déposer" %}";
} }
}); });
// Initialise les éléments pour le formulaire interactif // Initialise les éléments pour le formulaire interactif
$unranked = document.getElementById('unranked'); $unranked = _id('unranked');
for (let i = 1; i <= nb_options; i++) { for (let i = 1; i <= nb_options; i++) {
rank_zones[i] = document.getElementById(`rank-${i}`); rank_zones[i] = _id(`rank-${i}`);
} }
(document.querySelectorAll('.control .input') || []).forEach($input => { _$('.control .input').forEach(i => {
var option = getLabelText($input);
// On créé une tuile avec le nom de l'option // On créé une tuile avec le nom de l'option
var $tile = document.createElement('div'); const t = document.createElement('div');
$tile.classList.add('tile', 'is-parent', 'is-flex-grow-0'); t.classList.add('tile', 'is-parent', 'is-flex-grow-0');
$tile.id = `tile-${$input.id}`; t.id = `tile-${i.id}`;
$tile.dataset.input = $input.id; t.dataset.input = i.id;
$tile.innerHTML = `<p class="tile is-child notification is-primary">${option}</p>`; t.innerHTML = `<p class="tile is-child notification is-primary is-grabable">${getLabelText(i)}</p>`;
$tile.setAttribute('draggable', true); t.setAttribute('draggable', true);
$tile.addEventListener('dragstart', dragstart_handler); t.addEventListener('dragstart', dragstart_handler);
// Par défaut on ajoute la tuile dans undefined // Par défaut on ajoute la tuile dans undefined
$unranked.appendChild($tile); $unranked.appendChild(t);
}); });
moveOptions(); moveOptions();
collapseRanks(); collapseRanks();
document.querySelectorAll('.drop-zone').forEach($zone => { _$('.drop-zone').forEach(z => {
$zone.addEventListener('drop', drop_handler); z.addEventListener('drop', drop_handler);
$zone.addEventListener('dragover', dragover_handler); z.addEventListener('dragover', dragover_handler);
}); });
}); });
@ -236,17 +217,6 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<div class="tile is-parent is-flex-grow-0 is-hidden">
<div id="rank-add" class="tile is-child notification has-text-centered drop-zone">
<span class="icon-text subtitle has-text-primary">
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>{% trans "Ajouter un rang" %}</span>
</span>
</div>
</div>
</div> </div>
<div class="tile is-parent"> <div class="tile is-parent">

View file

@ -5,19 +5,16 @@
{% block extra_head %} {% block extra_head %}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('confirm-button').addEventListener('click', () => { _id('confirm-button').addEventListener('click', () => {
var $modal_body = document.getElementById('modal-body'); let selected_rows = '';
var selected_rows = ''; _$('.checkbox input').forEach(c => {
if (c.checked) {
(document.querySelectorAll('.checkbox input') || []).forEach($checkbox => { selected_rows += `<tr><td>${c.nextSibling.textContent.trim()}</td></tr>\n`;
if ($checkbox.checked) {
let option_text = $checkbox.nextSibling.textContent.trim();
selected_rows += '<tr><td>' + option_text + '</td></tr>\n';
} }
}); });
$modal_body.innerHTML = ` _id('modal-body').innerHTML = `
<table class="table is-fullwidth"> <table class="table is-fullwidth">
<thead> <thead>
<tr> <tr>

View file

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

View file

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

7
elections/typing.py Normal file
View file

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

View file

@ -49,33 +49,33 @@ urlpatterns = [
# Question views # Question views
path( path(
"add-question/<int:pk>", "add-question/<int:pk>",
views.AddQuestionView.as_view(), views.CreateQuestionView.as_view(),
name="election.add-question", name="election.add-question",
), ),
path( path(
"mod-question/<int:pk>", "mod-question/<int:pk>",
views.ModQuestionView.as_view(), views.UpdateQuestionView.as_view(),
name="election.mod-question", name="election.mod-question",
), ),
path( path(
"del-question/<int:pk>", "del-question/<int:pk>",
views.DelQuestionView.as_view(), views.DeleteQuestionView.as_view(),
name="election.del-question", name="election.del-question",
), ),
# Option views # Option views
path( path(
"add-option/<int:pk>", "add-option/<int:pk>",
views.AddOptionView.as_view(), views.CreateOptionView.as_view(),
name="election.add-option", name="election.add-option",
), ),
path( path(
"mod-option/<int:pk>", "mod-option/<int:pk>",
views.ModOptionView.as_view(), views.UpdateOptionView.as_view(),
name="election.mod-option", name="election.mod-option",
), ),
path( path(
"del-option/<int:pk>", "del-option/<int:pk>",
views.DelOptionView.as_view(), views.DeleteOptionView.as_view(),
name="election.del-option", name="election.del-option",
), ),
# Common views # Common views

View file

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

View file

@ -1,17 +1,15 @@
import csv import csv
from typing import TYPE_CHECKING
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.db import transaction from django.db import transaction
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from django.views.generic import ( from django.views.generic import (
CreateView, CreateView,
DetailView, DetailView,
@ -21,7 +19,9 @@ from django.views.generic import (
View, View,
) )
from shared.views import BackgroundUpdateView from elections.typing import AuthenticatedRequest
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
from shared.views import BackgroundUpdateView, TimeMixin
from .forms import ( from .forms import (
DeleteVoteForm, DeleteVoteForm,
@ -41,9 +41,14 @@ from .mixins import (
) )
from .models import Election, Option, Question, Vote from .models import Election, Option, Question, Vote
from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES
from .tasks import send_election_mail from .tasks import pseudonimize_election, send_election_mail
from .utils import create_users 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* # TODO: access control *everywhere*
@ -54,6 +59,8 @@ User = get_user_model()
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView): class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
object: Election
model = Election model = Election
form_class = ElectionForm form_class = ElectionForm
success_message = _("Élection créée avec succès !") success_message = _("Élection créée avec succès !")
@ -62,7 +69,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
def get_success_url(self): def get_success_url(self):
return reverse("election.admin", args=[self.object.pk]) return reverse("election.admin", args=[self.object.pk])
def form_valid(self, form): def form_valid(self, form: ElectionForm):
# We need to add the short name and the creator od the election # We need to add the short name and the creator od the election
form.instance.short_name = slugify( form.instance.short_name = slugify(
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
@ -72,7 +79,26 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
class ElectionAdminView(CreatorOnlyMixin, DetailView): class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
model = Election
pattern_name = "election.list"
def get_object(self):
obj: Election = super().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:
raise Http404
return obj
def get(self, request, *args, **kwargs):
self.get_object().delete()
return super().get(request, *args, **kwargs)
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
object: Election
model = Election model = Election
template_name = "elections/election_admin.html" template_name = "elections/election_admin.html"
@ -82,7 +108,6 @@ class ElectionAdminView(CreatorOnlyMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs.update( kwargs.update(
{ {
"current_time": timezone.now(),
"question_types": QUESTION_TYPES, "question_types": QUESTION_TYPES,
"o_form": OptionForm, "o_form": OptionForm,
"q_form": QuestionForm, "q_form": QuestionForm,
@ -100,7 +125,7 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
success_message = _("Élection visible !") success_message = _("Élection visible !")
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.election = self.get_object() self.election: Election = self.get_object()
self.election.visible = True self.election.visible = True
self.election.save() self.election.save()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -126,7 +151,7 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi
model = Election model = Election
form_class = UploadVotersForm form_class = UploadVotersForm
success_message = _("Liste de votant·e·s importée avec succès !") success_message = _("Liste de votant·e·s importée avec succès !")
template_name = "elections/upload_voters.html" template_name = "elections/election_upload_voters.html"
def get_queryset(self): def get_queryset(self):
# On ne peut ajouter une liste d'électeurs que sur une élection restreinte # On ne peut ajouter une liste d'électeurs que sur une élection restreinte
@ -160,7 +185,7 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView
model = Election model = Election
form_class = VoterMailForm form_class = VoterMailForm
success_message = _("Mail d'annonce en cours d'envoi !") success_message = _("Mail d'annonce en cours d'envoi !")
template_name = "elections/mail_voters.html" template_name = "elections/election_mail_voters.html"
def get_queryset(self): def get_queryset(self):
# On ne peut envoyer un mail que sur une élection restreinte qui n'a pas # On ne peut envoyer un mail que sur une élection restreinte qui n'a pas
@ -184,14 +209,11 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView
def form_valid(self, form): def form_valid(self, form):
self.object.sent_mail = None self.object.sent_mail = None
self.object.save() self.object.save()
send_election_mail.apply_async( send_election_mail(
countdown=5, election_pk=self.object.pk,
kwargs={ subject=form.cleaned_data["objet"],
"election_pk": self.object.pk, body=form.cleaned_data["message"],
"subject": form.cleaned_data["objet"], reply_to=self.request.user.email,
"body": form.cleaned_data["message"],
"reply_to": self.request.user.email,
},
) )
return super().form_valid(form) return super().form_valid(form)
@ -219,49 +241,28 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
return super().form_valid(form) return super().form_valid(form)
class DeleteVoteView(ClosedElectionMixin, FormView): class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
voter: User
model = Election model = Election
template_name = "elections/delete_vote.html"
form_class = DeleteVoteForm
def get_success_url(self): def get_message(self):
return reverse("election.voters", args=[self.object.pk]) + "#v_{anchor}".format( return {
**self.kwargs "content": _("Vote de {} supprimé !").format(self.voter.full_name),
) "class": "success",
}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["voter"] = self.voter
return kwargs
def get_queryset(self):
# On n'affiche la page que pour les élections ouvertes à toustes
return super().get_queryset().filter(restricted=False)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["anchor"] = self.kwargs["anchor"]
return context
def get(self, request, *args, **kwargs):
self.object = super().get_object()
self.voter = User.objects.get(pk=self.kwargs["user_pk"])
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = super().get_object()
self.voter = User.objects.get(pk=self.kwargs["user_pk"])
return super().post(request, *args, **kwargs)
@transaction.atomic @transaction.atomic
def form_valid(self, form): def get(self, request, *args, **kwargs):
if form.cleaned_data["delete"] == "oui": election = self.get_object()
self.voter = User.objects.get(pk=self.kwargs["user_pk"])
# On envoie un mail à la personne lui indiquant que le vote est supprimé # On envoie un mail à la personne lui indiquant que le vote est supprimé
EmailMessage( EmailMessage(
subject="Vote removed", subject="Vote removed",
body=MAIL_VOTE_DELETED.format( body=MAIL_VOTE_DELETED.format(
full_name=self.voter.full_name, full_name=self.voter.full_name,
election_name=self.object.name, election_name=election.name,
), ),
to=[self.voter.email], to=[self.voter.email],
).send() ).send()
@ -269,14 +270,13 @@ class DeleteVoteView(ClosedElectionMixin, FormView):
# On supprime les votes # On supprime les votes
Vote.objects.filter( Vote.objects.filter(
user=self.voter, user=self.voter,
option__question__election=self.object, option__question__election=election,
).delete() ).delete()
# On marque les questions comme non votées # On marque les questions comme non votées
self.voter.cast_elections.remove(self.object) self.voter.cast_elections.remove(election)
self.voter.cast_questions.remove(*list(self.object.questions.all())) self.voter.cast_questions.remove(*list(election.questions.all()))
return self.render_to_json(action="delete")
return super().form_valid(form)
class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView): class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
@ -298,7 +298,11 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
q.tally() q.tally()
election.tallied = True election.tallied = True
election.time_tallied = timezone.now()
election.save() election.save()
pseudonimize_election(election.pk)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -314,6 +318,10 @@ class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.election = self.get_object() self.election = self.get_object()
self.election.results_public = not self.election.results_public self.election.results_public = not self.election.results_public
self.election.time_published = (
timezone.now() if self.election.results_public else None
)
self.election.save() self.election.save()
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -350,46 +358,27 @@ class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
# ############################################################################# # #############################################################################
@method_decorator(require_POST, name="dispatch") class CreateQuestionView(CreatorOnlyEditMixin, TimeMixin, JsonCreateView):
class AddQuestionView(CreatorOnlyEditMixin, CreateView):
model = Election model = Election
form_class = QuestionForm form_class = QuestionForm
context_object_name = "q"
def get_success_url(self): template_name = "elections/admin/question.html"
return reverse("election.admin", args=[self.election.pk]) + "#q_add"
def form_valid(self, form): def form_valid(self, form):
self.election = self.get_object() form.instance.election = self.get_object()
# On ajoute l'élection voulue à la question créée
form.instance.election = self.election
return super().form_valid(form) return super().form_valid(form)
class ModQuestionView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): class UpdateQuestionView(CreatorOnlyEditMixin, TimeMixin, JsonUpdateView):
model = Question model = Question
form_class = QuestionForm form_class = QuestionForm
success_message = _("Question modifiée avec succès !") context_object_name = "q"
template_name = "elections/question_update.html" template_name = "elections/admin/question.html"
def get_success_url(self):
return (
reverse("election.admin", args=[self.object.election.pk])
+ f"#q_{self.object.pk}"
)
class DelQuestionView(CreatorOnlyEditMixin, BackgroundUpdateView): class DeleteQuestionView(CreatorOnlyEditMixin, JsonDeleteView):
model = Question model = Question
success_message = _("Question supprimée !") message = _("Question supprimée !")
def get_redirect_url(self, *args, **kwargs):
return reverse("election.admin", args=[self.election.pk]) + "#q_add"
def get(self, request, *args, **kwargs):
question = self.get_object()
self.election = question.election
question.delete()
return super().get(request, *args, **kwargs)
# ############################################################################# # #############################################################################
@ -397,49 +386,27 @@ class DelQuestionView(CreatorOnlyEditMixin, BackgroundUpdateView):
# ############################################################################# # #############################################################################
@method_decorator(require_POST, name="dispatch") class CreateOptionView(CreatorOnlyEditMixin, TimeMixin, JsonCreateView):
class AddOptionView(CreatorOnlyEditMixin, CreateView):
model = Question model = Question
form_class = OptionForm form_class = OptionForm
context_object_name = "o"
def get_success_url(self): template_name = "elections/admin/option.html"
return (
reverse("election.admin", args=[self.question.election.pk])
+ f"#q_{self.question.pk}"
)
def form_valid(self, form): def form_valid(self, form):
self.question = self.get_object() form.instance.question = self.get_object()
# On ajoute l'élection voulue à la question créée
form.instance.question = self.question
return super().form_valid(form) return super().form_valid(form)
class ModOptionView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView): class UpdateOptionView(CreatorOnlyEditMixin, TimeMixin, JsonUpdateView):
model = Option model = Option
form_class = OptionForm form_class = OptionForm
success_message = _("Option modifiée avec succès !") context_object_name = "o"
template_name = "elections/option_update.html" template_name = "elections/admin/option.html"
def get_success_url(self):
return (
reverse("election.admin", args=[self.object.question.election.pk])
+ f"#o_{self.object.pk}"
)
class DelOptionView(CreatorOnlyEditMixin, BackgroundUpdateView): class DeleteOptionView(CreatorOnlyEditMixin, JsonDeleteView):
model = Option model = Option
success_message = _("Option supprimée !") message = _("Option supprimée !")
def get_redirect_url(self, *args, **kwargs):
return reverse("election.admin", args=[self.election.pk]) + "#q_add"
def get(self, request, *args, **kwargs):
option = self.get_object()
self.election = option.question.election
option.delete()
return super().get(request, *args, **kwargs)
# ############################################################################# # #############################################################################
@ -464,7 +431,7 @@ class ElectionView(NotArchivedMixin, DetailView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["current_time"] = timezone.now() context["current_time"] = timezone.now()
if user.is_authenticated: if user.is_authenticated and isinstance(user, User):
context["can_vote"] = user.can_vote(self.request, context["election"]) context["can_vote"] = user.can_vote(self.request, context["election"])
context["cast_questions"] = user.cast_questions.all() context["cast_questions"] = user.cast_questions.all()
context["has_voted"] = user.cast_elections.filter( context["has_voted"] = user.cast_elections.filter(
@ -492,10 +459,11 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
election = context["election"] election = context["election"]
voters = list(election.voters.all()) voters = list(election.voters.all())
if user.is_authenticated: 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)
can_delete = ( can_delete = (
not election.restricted election.created_by == user
and election.created_by == user
and election.end_date < timezone.now() and election.end_date < timezone.now()
and not election.tallied and not election.tallied
) )
@ -503,6 +471,7 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
context["d_form"] = DeleteVoteForm() context["d_form"] = DeleteVoteForm()
context["can_delete"] = can_delete context["can_delete"] = can_delete
context["from_admin"] = self.request.GET.get("prev") == "admin"
context["voters"] = voters context["voters"] = voters
return context return context
@ -516,12 +485,14 @@ class ElectionBallotsView(NotArchivedMixin, DetailView):
return ( return (
super() super()
.get_queryset() .get_queryset()
.filter(tallied=True) .filter(results_public=True, tallied=True)
.prefetch_related("questions__options") .prefetch_related("questions__options")
) )
class VoteView(OpenElectionOnlyMixin, DetailView): class VoteView(OpenElectionOnlyMixin, DetailView):
request: AuthenticatedRequest
model = Question model = Question
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):

View file

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-07-12 17:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("faqs", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="faq",
options={"permissions": [("faq_admin", "Can create faqs")]},
),
]

View file

@ -4,7 +4,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
class AdminOnlyMixin(PermissionRequiredMixin): class AdminOnlyMixin(PermissionRequiredMixin):
"""Restreint l'accès aux admins""" """Restreint l'accès aux admins"""
permission_required = "faqs.is_author" permission_required = "faqs.faq_admin"
class CreatorOnlyMixin(AdminOnlyMixin): class CreatorOnlyMixin(AdminOnlyMixin):

View file

@ -25,7 +25,7 @@ class Faq(models.Model):
class Meta: class Meta:
permissions = [ permissions = [
("is_author", "Can create faqs"), ("faq_admin", "Can create faqs"),
] ]
constraints = [ constraints = [
models.UniqueConstraint(fields=["anchor"], name="unique_faq_anchor") models.UniqueConstraint(fields=["anchor"], name="unique_faq_anchor")

View file

@ -11,7 +11,7 @@
</div> </div>
</div> </div>
{% if perms.faqs.is_author %} {% if perms.faqs.faq_admin %}
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
<a class="button is-light is-outlined is-primary" href={% url 'faq.create' %}> <a class="button is-light is-outlined is-primary" href={% url 'faq.create' %}>

View file

@ -1,3 +0,0 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

View file

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

View file

@ -1,17 +0,0 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kadenios.settings.local")
app = Celery("kadenios")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
print(f"{'test'!r}")
return 3
# print(f"Request: {self.request!r}")

View file

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

View file

@ -1,158 +0,0 @@
"""
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",
"django_celery_results",
"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 <kadenios@vote.eleves.ens.fr>"
# #############################################################################
# Paramètres de Celery
# #############################################################################
CELERY_RESULT_BACKEND = "django-db"
CELERY_CACHE_BACKEND = "default"
# #############################################################################
# Paramètres d'authentification
# #############################################################################
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
AUTH_USER_MODEL = "elections.User"
AUTHENTICATION_BACKENDS = [
"shared.auth.backends.PwdBackend",
"shared.auth.backends.CASBackend",
"shared.auth.backends.ElectionBackend",
]
LOGIN_URL = reverse_lazy("authens:login")
LOGIN_REDIRECT_URL = "/"
AUTHENS_USE_OLDCAS = False
# #############################################################################
# Paramètres de langage
# #############################################################################
LANGUAGE_CODE = "fr-fr"
TIME_ZONE = "Europe/Paris"
USE_I18N = True
USE_L10N = True
USE_TZ = True
LANGUAGES = [
("fr", _("Français")),
("en", _("Anglais")),
]
LOCALE_PATHS = [os.path.join(BASE_DIR, "shared", "locale")]
# #############################################################################
# Paramètres des fichiers statiques
# #############################################################################
STATIC_URL = "/static/"

View file

@ -1,55 +0,0 @@
"""
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}

View file

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

View file

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

View file

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

80
npins/default.nix Normal file
View file

@ -0,0 +1,80 @@
# 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`"

22
npins/sources.json Normal file
View file

@ -0,0 +1,22 @@
{
"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
}

3
pyproject.toml Normal file
View file

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

View file

@ -1,9 +1,7 @@
django==3.2.* django==3.2.*
celery==5.1.* django-translated-fields==0.11.*
django-celery-results
django-translated-fields==0.11.1
authens>=0.1b2 authens>=0.1b2
markdown markdown
numpy numpy
networkx networkx
python-csv django-background-tasks

View file

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

13
shared/admin.py Normal file
View file

@ -0,0 +1,13 @@
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()
for model in models:
try:
admin.site.register(model)
except AlreadyRegistered:
pass

5
shared/auth/__init__.py Normal file
View file

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

View file

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

View file

@ -1,10 +1,15 @@
from typing import TYPE_CHECKING
from django import forms from django import forms
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth import forms as auth_forms from django.contrib.auth import forms as auth_forms
from django.contrib.auth import get_user_model
from django.core.validators import validate_email from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
if TYPE_CHECKING:
from elections.typing import User
else:
from django.contrib.auth import get_user_model
User = get_user_model() User = get_user_model()
@ -82,3 +87,38 @@ class PwdUserForm(forms.ModelForm):
class Meta: class Meta:
model = User model = User
fields = ["username", "full_name", "email"] fields = ["username", "full_name", "email"]
class UserAdminForm(forms.Form):
"""
Allows to select an user and give them some admin permissions
"""
username = forms.CharField(label=_("Nom d'utilisateur"), max_length=150)
full_admin = forms.BooleanField(
label=_("Passer administrateur de Kadenios"), required=False
)
faq_admin = forms.BooleanField(
label=_("Autoriser à créer des FAQs"), required=False
)
election_admin = forms.BooleanField(
label=_("Autoriser à créer des élections"), required=False
)
def clean(self):
cleaned_data = super().clean()
username = cleaned_data["username"]
if not username[:5] in ["cas__", "pwd__"]:
self.add_error(
"username",
_(
"Format de login invalide, seuls les comptes CAS ou avec "
"mot de passe sont modifiables"
),
)
elif not User.objects.filter(username=username).exists():
self.add_error("username", _("Pas d'utilisateur·rice avec ce login"))
return cleaned_data

View file

@ -0,0 +1,6 @@
from django.utils.translation import gettext_lazy as _
CONNECTION_METHODS = {
"pwd": _("mot de passe"),
"cas": _("CAS"),
}

View file

@ -9,4 +9,10 @@ urlpatterns = [
name="auth.election", name="auth.election",
), ),
path("pwd-create", views.CreatePwdAccount.as_view(), name="auth.create-account"), path("pwd-create", views.CreatePwdAccount.as_view(), name="auth.create-account"),
path("admin", views.AdminPanelView.as_view(), name="auth.admin"),
path(
"permissions", views.PermissionManagementView.as_view(), name="auth.permissions"
),
path("accounts", views.AccountListView.as_view(), name="auth.accounts"),
path("admins", views.AdminAccountsView.as_view(), name="auth.admins"),
] ]

View file

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

View file

@ -1,16 +1,44 @@
from django.contrib.admin.views.decorators import staff_member_required from typing import TYPE_CHECKING
from django.contrib.auth import get_user_model
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.urls import reverse_lazy from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.utils.decorators import method_decorator from django.contrib.auth.models import Permission
from django.views.generic.edit import CreateView 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 .forms import ElectionAuthForm, PwdUserForm from elections.typing import AuthenticatedRequest
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
from .utils import generate_password 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()
# #############################################################################
# Mixin to restrict access to staff members
# #############################################################################
class StaffMemberMixin(UserPassesTestMixin):
"""
Mixin permettant de restreindre l'accès aux membres `staff`, si la personne
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
# ############################################################################# # #############################################################################
# Election Specific Login # Election Specific Login
# ############################################################################# # #############################################################################
@ -28,21 +56,136 @@ class ElectionLoginView(auth_views.LoginView):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
# #############################################################################
# Admin Panel
# #############################################################################
class AdminPanelView(StaffMemberMixin, TemplateView):
template_name = "auth/admin-panel.html"
# ############################################################################# # #############################################################################
# Creation of Password Accounts # Creation of Password Accounts
# ############################################################################# # #############################################################################
@method_decorator(staff_member_required, name="dispatch") class CreatePwdAccount(StaffMemberMixin, SuccessMessageMixin, CreateView):
class CreatePwdAccount(CreateView):
model = User model = User
form_class = PwdUserForm form_class = PwdUserForm
template_name = "auth/create-user.html" template_name = "auth/create-user.html"
success_url = reverse_lazy("auth.create-account") success_url = reverse_lazy("auth.admin")
success_message = _("Compte créé avec succès")
def form_valid(self, form): def form_valid(self, form):
# On enregistre un mot de passe aléatoire # On enregistre un mot de passe aléatoire
form.instance.password = make_password(generate_password(32)) form.instance.password = make_password(generate_password(32))
# On envoie un mail pour réinitialiser le mot de passe
return super().form_valid(form) return super().form_valid(form)
# #############################################################################
# List of password and CAS users
# #############################################################################
class AccountListView(StaffMemberMixin, ListView):
model = User
template_name = "auth/account-list.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
qs: QuerySet = self.get_queryset() # pyright: ignore
ctx["cas_users"] = qs.filter(username__startswith="cas__")
ctx["pwd_users"] = qs.filter(username__startswith="pwd__")
ctx["e_manager"] = User.objects.with_perm(
Permission.objects.get(codename="election_admin"),
backend="shared.auth.backends.PwdBackend",
)
ctx["f_manager"] = User.objects.with_perm(
Permission.objects.get(codename="faq_admin"),
backend="shared.auth.backends.PwdBackend",
)
return ctx
# #############################################################################
# Permission management
# #############################################################################
class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
form_class = UserAdminForm
template_name = "auth/permission-management.html"
success_message = _("Permissions modifiées avec succès !")
def get_context_data(self, **kwargs):
kwargs.update({"username": self.request.GET.get("user", None)})
return super().get_context_data(**kwargs)
def get_initial(self):
username = self.request.GET.get("user", None)
if username is not None:
user = User.objects.filter(username=username).first()
if user is not None:
return {
"username": username,
"full_admin": user.is_staff,
"election_admin": user.has_perm("elections.election_admin"),
"faq_admin": user.has_perm("faqs.faq_admin"),
}
return {}
def get_success_url(self):
return reverse("auth.permissions") + f"?user={self.user}"
def form_valid(self, form):
user = User.objects.get(username=form.cleaned_data["username"])
self.user = user.username
# Kadenios admin
user.is_staff = form.cleaned_data["full_admin"]
# Election admin
election_perm = Permission.objects.get(codename="election_admin")
if form.cleaned_data["election_admin"]:
election_perm.user_set.add(user) # pyright: ignore
else:
election_perm.user_set.remove(user) # pyright: ignore
# FAQ admin
faq_perm = Permission.objects.get(codename="faq_admin")
if form.cleaned_data["faq_admin"]:
faq_perm.user_set.add(user) # pyright: ignore
else:
faq_perm.user_set.remove(user) # pyright: ignore
user.save()
return super().form_valid(form)
# #############################################################################
# List of special accounts
# #############################################################################
class AdminAccountsView(LoginRequiredMixin, TemplateView):
template_name = "auth/admin-accounts.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["admin"] = User.objects.filter(is_staff=True)
ctx["e_manager"] = User.objects.with_perm(
Permission.objects.get(codename="election_admin"),
backend="shared.auth.backends.PwdBackend",
)
ctx["f_manager"] = User.objects.with_perm(
Permission.objects.get(codename="faq_admin"),
backend="shared.auth.backends.PwdBackend",
)
return ctx

10
shared/json/__init__.py Normal file
View file

@ -0,0 +1,10 @@
from .mixins import Serializer # noqa
from .views import JsonCreateView, JsonDeleteView, JsonDetailView, JsonUpdateView
__all__ = [
"Serializer",
"JsonCreateView",
"JsonDeleteView",
"JsonDetailView",
"JsonUpdateView",
]

21
shared/json/mixins.py Normal file
View file

@ -0,0 +1,21 @@
import json
class Serializer:
serializable_fields = []
def get_serializable_fields(self):
return self.serializable_fields
def to_json(self):
data = {}
for field in self.get_serializable_fields():
if hasattr(self, field):
data.update({field: getattr(self, field)})
else:
raise AttributeError(
"This object does not have a field named '{}'".format(field)
)
return json.dumps(data)

105
shared/json/views.py Normal file
View file

@ -0,0 +1,105 @@
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
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormMixin, ModelFormMixin, ProcessFormView
# #############################################################################
# Views for use with AJAX
# #############################################################################
class JsonMixin:
success = True
errors = {}
def get_data(self, **kwargs):
data = {"success": self.success, "errors": self.errors}
data.update(kwargs)
return data
def render_to_json(self, **kwargs):
return JsonResponse(self.get_data(**kwargs))
class JsonFormMixin(JsonMixin, FormMixin):
def form_valid(self, form):
"""If the form is valid, return success"""
return self.render_to_json()
def form_invalid(self, form):
"""If the form is invalid, return the errors and no success"""
return self.render_to_json(success=False, errors=form.errors)
class JsonModelFormMixin(JsonFormMixin, ModelFormMixin):
def form_valid(self, form):
"""Override form_valid to return a JSON response"""
self.object = form.save()
return super().form_valid(form)
class JsonMessageMixin:
message = ""
def get_message(self):
return {"content": self.message, "class": "success"}
def get_data(self, **kwargs):
kwargs.update(message=self.get_message())
return super().get_data(**kwargs) # pyright: ignore
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):
def get(self, request, *args, **kwargs):
self.object = self.get_object()
context = self.get_context_data(object=self.object)
return self.render_to_json(
html=self.render_to_response(context).rendered_content
)
class JsonDeleteView(JsonMessageMixin, JsonDetailView):
def get(self, request, *args, **kwargs):
obj = self.get_object()
obj.delete()
return self.render_to_json(action="delete")
@method_decorator(require_POST, name="dispatch")
class JsonCreateView(
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
):
def render_to_json(self, **kwargs):
context = self.get_context_data(object=self.object)
kwargs.update(
html=self.render_to_response(context).rendered_content, action="create"
)
return super().render_to_json(**kwargs)
@method_decorator(require_POST, name="dispatch")
class JsonUpdateView(
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)
def render_to_json(self, **kwargs):
context = self.get_context_data(object=self.object)
kwargs.update(
html=self.render_to_response(context).rendered_content, action="update"
)
return super().render_to_json(**kwargs)

File diff suppressed because it is too large Load diff

View file

View file

View file

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

View file

@ -10577,6 +10577,7 @@ body {
height: auto; height: auto;
min-height: 2em; min-height: 2em;
white-space: unset; white-space: unset;
text-align: center;
} }
.message.is-primary .message-body hr { .message.is-primary .message-body hr {
@ -10586,4 +10587,16 @@ body {
background-color: #ffdd57; background-color: #ffdd57;
} }
.is-grabable {
cursor: move;
}
#scroll-button {
position: fixed;
bottom: 1em;
right: 1em;
z-index: 30;
padding: 1.5rem;
}
/*# sourceMappingURL=main.css.map */ /*# sourceMappingURL=main.css.map */

File diff suppressed because one or more lines are too long

167
shared/static/js/main.js Normal file
View file

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

View file

@ -36,6 +36,7 @@ body
height: auto height: auto
min-height: 2em min-height: 2em
white-space: unset white-space: unset
text-align: center
.message .message
&.is-primary &.is-primary
@ -46,3 +47,13 @@ body
.message-body .message-body
hr hr
background-color: $warning background-color: $warning
.is-grabable
cursor: move
#scroll-button
position: fixed
bottom: 1em
right: 1em
z-index: 30
padding: 1.5rem

View file

@ -43,10 +43,11 @@ class DateTimePicker {
throw TypeError('Selector required to construct a DateTimePicker'); throw TypeError('Selector required to construct a DateTimePicker');
} }
this.target = document.querySelector(selector); this.target = _$(selector, document, false);
if (!this.target) { if (!this.target) {
throw Error(`The selector '{selector}' doesn't give any results`); throw Error(`The selector '{selector}' doesn't give any results`);
} }
this.target.addEventListener('click', () => { this.target.addEventListener('click', () => {
document.documentElement.classList.add('is-clipped'); document.documentElement.classList.add('is-clipped');
this.modal.classList.add('is-active'); this.modal.classList.add('is-active');
@ -67,9 +68,10 @@ class DateTimePicker {
// Création du modal // Création du modal
this.modal = document.createElement('div'); this.modal = document.createElement('div');
this.modal.id = `modal-${this.target.id}`;
this.modal.classList.add('modal'); this.modal.classList.add('modal');
this.modal.innerHTML = `<div class="modal-background"></div><div class="modal-card"><header class="modal-card-head"><div class="field is-grouped has-addons is-flex-grow-1"><div class="control"><a class="button"><span class="icon"><i class="fas fa-chevron-left"></i></span></a></div><div class="control is-expanded"><a class="button is-fullwidth"></a></div><div class="control"><a class="button"><span class="icon"><i class="fas fa-chevron-right"></i></span></a></div></div></header><section class="modal-card-body"><div class="columns is-centered is-mobile"><div class="column is-narrow"></div></div></section><footer class="modal-card-foot is-block"><div class="field is-horizontal is-flex-grow-1"><div class="field-label is-normal"><label class="label">${_horaire[this.config.lang]}</label></div><div class="field-body"><div class="field has-addons"><div class="control"><div class="select is-left"><select><option>00</option><option>01</option><option>02</option><option>03</option><option>04</option><option>05</option><option>06</option><option>07</option><option>08</option><option>09</option><option>10</option><option>11</option><option>12</option><option>13</option><option>14</option><option>15</option><option>16</option><option>17</option><option>18</option><option>19</option><option>20</option><option>21</option><option>22</option><option>23</option></select></div></div><div class="control"><button class="button is-static has-text-primary"><b>h</b></button></div></div><div class="field has-addons"><div class="control"><div class="select"><select><option>00</option><option>05</option><option>10</option><option>15</option><option>20</option><option>25</option><option>30</option><option>35</option><option>40</option><option>45</option><option>50</option><option>55</option></select></div></div><div class="control"><button class="button is-static has-text-primary"><b>min</b></button></div></div></div></div><br><button class="button is-primary is-fullwidth button-close">${_valider[this.config.lang]}</button></footer></div><button class="modal-close is-large" aria-label="close"></button>`; this.modal.innerHTML = `<div class="modal-background" data-closes="${this.modal.id}"></div><div class="modal-card"><header class="modal-card-head"><div class="field is-grouped has-addons is-flex-grow-1"><div class="control"><a class="button"><span class="icon"><i class="fas fa-chevron-left"></i></span></a></div><div class="control is-expanded"><a class="button is-fullwidth"></a></div><div class="control"><a class="button"><span class="icon"><i class="fas fa-chevron-right"></i></span></a></div></div></header><section class="modal-card-body"><div class="columns is-centered is-mobile"><div class="column is-narrow"></div></div></section><footer class="modal-card-foot is-block"><div class="field is-horizontal is-flex-grow-1"><div class="field-label is-normal"><label class="label">${_horaire[this.config.lang]}</label></div><div class="field-body"><div class="field has-addons"><div class="control"><div class="select is-left"><select><option>00</option><option>01</option><option>02</option><option>03</option><option>04</option><option>05</option><option>06</option><option>07</option><option>08</option><option>09</option><option>10</option><option>11</option><option>12</option><option>13</option><option>14</option><option>15</option><option>16</option><option>17</option><option>18</option><option>19</option><option>20</option><option>21</option><option>22</option><option>23</option></select></div></div><div class="control"><button class="button is-static has-text-primary"><b>h</b></button></div></div><div class="field has-addons"><div class="control"><div class="select"><select><option>00</option><option>05</option><option>10</option><option>15</option><option>20</option><option>25</option><option>30</option><option>35</option><option>40</option><option>45</option><option>50</option><option>55</option></select></div></div><div class="control"><button class="button is-static has-text-primary"><b>min</b></button></div></div></div></div><br><button class="button is-primary is-fullwidth button-close" data-closes="${this.modal.id}">${_valider[this.config.lang]}</button></footer></div><button class="modal-close is-large" data-closes="${this.modal.id}" aria-label="close"></button>`;
let _controls = this.modal.querySelectorAll('header a.button'); let _controls = _$('header a.button', this.modal);
this._leftArrow = _controls[0]; this._leftArrow = _controls[0];
this._menu = _controls[1]; this._menu = _controls[1];
this._rightArrow = _controls[2]; this._rightArrow = _controls[2];
@ -113,7 +115,7 @@ class DateTimePicker {
} }
}); });
let _selects = this.modal.querySelectorAll('footer select'); let _selects = _$('footer select', this.modal);
this._hour = _selects[0]; this._hour = _selects[0];
this._minutes = _selects[1]; this._minutes = _selects[1];
@ -154,7 +156,7 @@ class DateTimePicker {
this._menu.innerHTML = `<b>${months[_beginning.getMonth()]} ${_beginning.getFullYear()}</b>`; this._menu.innerHTML = `<b>${months[_beginning.getMonth()]} ${_beginning.getFullYear()}</b>`;
const _body = this.modal.querySelector('.modal-card section .column'); const _body = _$('.modal-card section .column', this.modal, false);
_body.innerHTML = `<table class="table has-text-centered is-narrow"><thead><tr><th>${days[0]}</th><th>${days[1]}</th><th>${days[2]}</th><th>${days[3]}</th><th>${days[4]}</th><th>${days[5]}</th><th>${days[6]}</th></tr></thead><tbody></tbody></table>`; _body.innerHTML = `<table class="table has-text-centered is-narrow"><thead><tr><th>${days[0]}</th><th>${days[1]}</th><th>${days[2]}</th><th>${days[3]}</th><th>${days[4]}</th><th>${days[5]}</th><th>${days[6]}</th></tr></thead><tbody></tbody></table>`;
// Création du tableau // Création du tableau

View file

@ -3,10 +3,11 @@
{% block content %} {% block content %}
<p class="title">{% trans "Erreur 403" %}</p>
<h1 class="title">{% trans "Erreur 403" %}</h1>
<hr> <hr>
<div class="hero is-danger"> <div class="hero is-dark">
<div class="hero-body"> <div class="hero-body">
<div class="subtitle"> <div class="subtitle">
<span class="icon-text"> <span class="icon-text">
@ -18,4 +19,5 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -3,7 +3,8 @@
{% block content %} {% block content %}
<p class="title">{% trans "Erreur 404" %}</p>
<h1 class="title">{% trans "Erreur 404" %}</h1>
<hr> <hr>
<div class="hero is-warning"> <div class="hero is-warning">
@ -16,7 +17,7 @@
<span>{% blocktrans with host=request.get_host %}La page à l'adresse « {{ host }}{{ request_path }} » n'a pas pu être trouvée.{% endblocktrans %}</span> <span>{% blocktrans with host=request.get_host %}La page à l'adresse « {{ host }}{{ request_path }} » n'a pas pu être trouvée.{% endblocktrans %}</span>
</span> </span>
</div> </div>
{% if exception != "Resolver404" %}<p>{{ exception }}</p>{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -3,7 +3,8 @@
{% block content %} {% block content %}
<p class="title">{% trans "Erreur 500" %}</p>
<h1 class="title">{% trans "Erreur 500" %}</h1>
<hr> <hr>
<div class="hero is-danger"> <div class="hero is-danger">
@ -18,4 +19,5 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,128 @@
{% extends "base.html" %}
{% load i18n %}
{% block custom_js %}
<script>
function initSearch(input) {
const s = _id(input);
const us = _$('a.panel-block', s.closest('div.panel'));
s.addEventListener('input', () => {
const username = s.value.toLowerCase();
us.forEach(u => {
if (u.id.includes(username)) {
u.classList.remove('is-hidden');
} else {
u.classList.add('is-hidden');
}
});
});
}
initSearch('pwd_search');
initSearch('cas_search');
</script>
{% endblock %}
{% block content %}
<h1 class="title">{% trans "Liste des comptes" %}</h1>
<hr>
<div class="columns">
{# Password Accounts #}
<div class="column is-half">
<div class="panel">
<p class="panel-heading is-radiusless">{% trans "Comptes avec mot de passe" %}</p>
{# Search bar #}
<div class="panel-block">
<p class="control has-icons-left">
<input class="input" type="text" id="pwd_search" placeholder="{% trans "Search" %}">
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
{# List of users #}
{% for u in pwd_users %}
<a class="panel-block" href="{% url 'auth.permissions' %}?user={{ u.username }}" id={{ u.base_username|lower }}>
<div class="level is-mobile is-flex-grow-1">
<div class="level-left is-flex-shrink-1 pr-3">
<span class="panel-icon">
<i class="fas fa-user-cog"></i>
</span>
<span class="ml-2" style="overlay=clip">{{ u.full_name }} ({{ u.base_username }})</span>
</div>
<div class="level-right">
<span class="panel-icon has-text-{% if u in e_manager %}primary{% else %}grey-lighter{% endif %}">
<i class="fas fa-vote-yea"></i>
</span>
<span class="panel-icon has-text-{% if u in f_manager %}primary{% else %}grey-lighter{% endif %}">
<i class="fas fa-question"></i>
</span>
<span class="panel-icon has-text-{% if u.is_staff %}danger{% else %}grey-lighter{% endif %}">
<i class="fas fa-user-shield"></i>
</span>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{# CAS Accounts #}
<div class="column is-half">
<div class="panel">
<p class="panel-heading is-radiusless">{% trans "Comptes CAS" %}</p>
{# Search bar #}
<div class="panel-block">
<p class="control has-icons-left">
<input class="input" type="text" id="cas_search" placeholder="{% trans "Search" %}">
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
{# List of users #}
{% for u in cas_users %}
<a class="panel-block" href="{% url 'auth.permissions' %}?user={{ u.username }}" id={{ u.base_username|lower }}>
<div class="level is-mobile is-flex-grow-1">
<div class="level-left is-flex-shrink-1 pr-3">
<span class="panel-icon">
<i class="fas fa-user-cog"></i>
</span>
<span class="ml-2">{{ u.full_name }} ({{ u.base_username }})</span>
</div>
<div class="level-right">
<span class="panel-icon has-text-{% if u in e_manager %}primary{% else %}grey-lighter{% endif %}">
<i class="fas fa-vote-yea"></i>
</span>
<span class="panel-icon has-text-{% if u in f_manager %}primary{% else %}grey-lighter{% endif %}">
<i class="fas fa-question"></i>
</span>
<span class="panel-icon has-text-{% if u.is_staff %}danger{% else %}grey-lighter{% endif %}">
<i class="fas fa-user-shield"></i>
</span>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% trans "Liste des comptes spéciaux" %}</h1>
<hr>
<div class="notification">
<h3 class="subtitle has-text-weight-semibold">{% trans "Gestionnaires de Kadenios" %}</h3>
<div class="field is-grouped">
{% for a in admin %}
<div class="control">
<span class="tag is-primary">{{ a.full_name }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="notification">
<h3 class="subtitle has-text-weight-semibold">{% trans "Gestionnaires d'élections" %}</h3>
<div class="field is-grouped">
{% for m in e_manager %}
<div class="control">
<span class="tag is-primary">{{ m.full_name }}</span>
</div>
{% endfor %}
</div>
</div>
{% comment %}
<div class="notification">
<h3 class="subtitle has-text-weight-semibold">{% trans "Gestionnaires de FAQs" %}</h3>
<div class="field is-grouped">
{% for m in f_manager %}
<div class="control">
<span class="tag is-primary">{{ m.full_name }}</span>
</div>
{% endfor %}
</div>
</div>
{% endcomment %}
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% trans "Gestion de Kadenios" %}</h1>
<hr>
<div class="tile is-ancestor">
<div class="tile is-parent">
<a class="tile is-child notification is-light px-0" href="{% url 'auth.create-account' %}">
<div class="subtitle has-text-centered">
<span class="icon-text">
<span class="icon">
<i class="fas fa-user-plus"></i>
</span>
<span class="ml-3">{% trans "Créer un nouveau compte" %}</span>
</span>
</div>
</a>
</div>
<div class="tile is-parent">
<a class="tile is-child notification is-light px-0" href="{% url 'auth.accounts' %}">
<div class="subtitle has-text-centered">
<span class="icon-text">
<span class="icon">
<i class="fas fa-stream"></i>
</span>
<span class="ml-3">{% trans "Liste des comptes" %}</span>
</span>
</div>
</a>
</div>
</div>
{% endblock %}

View file

@ -23,6 +23,15 @@
<span>{% trans "Enregistrer" %}</span> <span>{% trans "Enregistrer" %}</span>
</button> </button>
</div> </div>
<div class="control">
<a class="button is-primary" href="{% url 'auth.admin' %}">
<span class="icon">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div> </div>
</form> </form>
</div> </div>

View file

@ -11,6 +11,20 @@
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-two-thirds"> <div class="column is-two-thirds">
<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>
</div>
</div>
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
@ -27,7 +41,7 @@
</div> </div>
<div class="control"> <div class="control">
<a class="button is-primary" href="{% url 'election.view' election_id %}"> <a class="button is-primary" href="{{ request.GET.next }}">
<span class="icon"> <span class="icon">
<i class="fas fa-undo-alt"></i> <i class="fas fa-undo-alt"></i>
</span> </span>

View file

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% trans "Gestion des permissions" %}</h1>
<hr>
<div class="message is-primary">
<p class="message-body">
{% trans "Pour modifier un compte CAS, le nom d'utilisateur doit commencer par <code>cas__</code>, pour un compte avec mot de passe, <code>pwd__</code>." %}
</p>
</div>
<div class="columns is-centered">
<div class="column is-two-thirds">
<form action="" method="post">
{% csrf_token %}
{% include "forms/form.html" with errors=True %}
<div class="field is-grouped is-centered">
<div class="control is-expanded">
<button class="button is-fullwidth is-outlined is-primary is-light" 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 'auth.accounts' %}">
<span class="icon">
<i class="fas fa-undo-alt"></i>
</span>
<span>{% trans "Retour" %}</span>
</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -37,5 +37,17 @@
</div> </div>
</div> </div>
<div class="tile is-ancestor pt-6">
<div class="tile is-parent">
<div class="tile is-child notification is-warning">
<div class="has-text-centered">
<span class="icon">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{% trans "La connexion par identifiants reçus par mail pour un vote particulier se fera directement sur la page du vote lorsqu'il sera ouvert." %}</span>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show more