Compare commits
72 commits
thubrecht/
...
main
Author | SHA1 | Date | |
---|---|---|---|
4fd9e3a211 | |||
fb0e5a8a37 | |||
3a0d499ba1 | |||
1ca85957d2 | |||
fd65aa36ad | |||
8f88eef5c7 | |||
c6aa72e843 | |||
7ae43d4d7e | |||
1d7bace777 | |||
3c81dea1c9 | |||
1383dc30eb | |||
|
78c385110f | ||
b612664ae4 | |||
|
b38aadb0d8 | ||
4fa3f96184 | |||
9d4902365f | |||
85c7e1b750 | |||
de1cea0400 | |||
6b34c052ea | |||
d98d7da399 | |||
e4e4cff9e6 | |||
9f3f2a4664 | |||
9c5f3b3a04 | |||
932a20fd17 | |||
f9271f5690 | |||
b2c55b824a | |||
0e0f29cba6 | |||
ca1fdff3cb | |||
361ef6d80b | |||
2c74a7866a | |||
5dfb23bde5 | |||
408aa51e5b | |||
870b16cea5 | |||
c2931aa81f | |||
a621bb8197 | |||
8a91648bac | |||
f65c3a991a | |||
77e085458c | |||
3c91771e84 | |||
04c14b37ec | |||
cd95f2c1e4 | |||
064c5578c8 | |||
c0b9025fcf | |||
0e9b7e82cc | |||
bca8d03400 | |||
6a797d3357 | |||
9474fcd1d7 | |||
7bb98f9cd8 | |||
db2d69bc5f | |||
f2b4e9bcfe | |||
e14ceca91a | |||
c7bc1fbe16 | |||
ed15d137cc | |||
d8b0885772 | |||
78f2b66594 | |||
9ab42a3a9d | |||
2e1051bb2e | |||
d210fc88c4 | |||
b4ffd2ee36 | |||
779bf7bf44 | |||
b55d398bf8 | |||
2396c163bd | |||
3e683cd87e | |||
f56cd87358 | |||
287716276d | |||
684fe20d24 | |||
b79a7f6b0f | |||
ba863c757f | |||
4b8ed181d8 | |||
3601508ab2 | |||
6ba5a5c620 | |||
18383802d6 |
109 changed files with 3393 additions and 1717 deletions
1
.credentials/EMAIL_HOST
Normal file
1
.credentials/EMAIL_HOST
Normal file
|
@ -0,0 +1 @@
|
||||||
|
localhost
|
1
.credentials/FROM_EMAIL
Normal file
1
.credentials/FROM_EMAIL
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Kadenios <kadenios@localhost>
|
1
.credentials/SECRET_KEY
Normal file
1
.credentials/SECRET_KEY
Normal file
|
@ -0,0 +1 @@
|
||||||
|
insecure-secret-key
|
1
.credentials/SERVER_EMAIL
Normal file
1
.credentials/SERVER_EMAIL
Normal file
|
@ -0,0 +1 @@
|
||||||
|
kadenios@localhost
|
1
.envrc
Normal file
1
.envrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
use nix
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -11,7 +11,9 @@
|
||||||
|
|
||||||
venv/
|
venv/
|
||||||
.python-version
|
.python-version
|
||||||
|
pyrightconfig.json
|
||||||
|
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
|
.direnv
|
||||||
|
|
11
01-authens.patch
Normal file
11
01-authens.patch
Normal 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
21
LICENSE
Normal 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.
|
21
README.md
21
README.md
|
@ -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
210
app/settings.py
Normal 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>"}
|
|
@ -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
|
||||||
|
|
|
@ -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
67
default.nix
Normal 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
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
|
@ -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
|
|
|
@ -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é")
|
||||||
|
|
27
elections/migrations/0030_timestamps.py
Normal file
27
elections/migrations/0030_timestamps.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
20
elections/migrations/0031_alter_election_options.py
Normal file
20
elections/migrations/0031_alter_election_options.py
Normal 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")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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 = [
|
21
elections/migrations/0033_inactive_users.py
Normal file
21
elections/migrations/0033_inactive_users.py
Normal 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),
|
||||||
|
]
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
44
elections/migrations/0035_auto_20240711_1424.py
Normal file
44
elections/migrations/0035_auto_20240711_1424.py
Normal 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)]
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
||||||
|
|
|
@ -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")),
|
||||||
|
|
|
@ -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()
|
||||||
|
|
40
elections/templates/elections/admin/option.html
Normal file
40
elections/templates/elections/admin/option.html
Normal 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>
|
67
elections/templates/elections/admin/question.html
Normal file
67
elections/templates/elections/admin/question.html
Normal 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>
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 #}
|
||||||
{% for q in election.questions.all %}
|
<div id="questions" class="block">
|
||||||
<div class="panel" id="q_{{ q.pk }}">
|
{% for q in election.questions.all %}
|
||||||
<div class="panel-heading is-size-6">
|
{% include 'elections/admin/question.html' %}
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -149,6 +176,7 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -2,38 +2,66 @@
|
||||||
{% load i18n markdown %}
|
{% load i18n markdown %}
|
||||||
|
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block custom_js %}
|
||||||
<script>
|
{% if can_delete %}
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
<script>
|
||||||
const $del_modal = document.getElementById('modal-delete');
|
_$('.modal-button').forEach(b => {
|
||||||
const $del_title = $del_modal.querySelector('.modal-card-title');
|
b.addEventListener('click', () => {
|
||||||
const $del_form = $del_modal.querySelector('form');
|
const f = _$('form', _id(b.dataset.target), false);
|
||||||
|
f.dataset.target = b.dataset.origin;
|
||||||
$del_buttons = document.querySelectorAll('.modal-button.delete-vote')
|
_$('[name="delete"]', f, false).value = 'non';
|
||||||
|
|
||||||
$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>
|
||||||
|
@ -41,23 +69,23 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="level">
|
<div class="level">
|
||||||
<div class="level-left">
|
<div class="level-left">
|
||||||
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
|
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
{# Précisions sur les modalités de vote #}
|
{# Précisions sur les modalités de vote #}
|
||||||
{% if election.vote_restrictions %}
|
{% if election.vote_restrictions %}
|
||||||
<div class="message is-warning">
|
<div class="message is-warning">
|
||||||
<div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
|
<div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="message is-warning">
|
<div class="message is-warning">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
{% if election.restricted %}
|
{% if election.restricted %}
|
||||||
{% trans "Seules les personnes présentes sur cette liste peuvent voter, vous avez dû recevoir un mail avec vos identifiants de connexion." %}
|
{% trans "Seules les personnes présentes sur cette liste peuvent voter, vous avez dû recevoir un mail avec vos identifiants de connexion." %}
|
||||||
|
@ -65,10 +93,11 @@
|
||||||
{% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève, d'autres restrictions peuvent s'appliquer et votre vote pourra être supprimé si vous n'avez pas le droit de vote." %}
|
{% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève, d'autres restrictions peuvent s'appliquer et votre vote pourra être supprimé si vous n'avez pas le droit de vote." %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-centered">
|
{% if can_vote or is_admin %}
|
||||||
<div class="column is-two-thirds">
|
<div class="columns is-centered">
|
||||||
|
<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 %}
|
||||||
|
|
|
@ -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 %}
|
|
|
@ -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 %}
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,7 +7,10 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .test_utils import create_election
|
from .test_utils import create_election
|
||||||
|
|
||||||
User = get_user_model()
|
if TYPE_CHECKING:
|
||||||
|
from elections.typing import User
|
||||||
|
else:
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class UserTests(TestCase):
|
class UserTests(TestCase):
|
||||||
|
@ -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))
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
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
|
||||||
|
|
||||||
User = get_user_model()
|
if TYPE_CHECKING:
|
||||||
|
from elections.typing import User
|
||||||
|
else:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class AdminViewsTest(TestCase):
|
class AdminViewsTest(TestCase):
|
||||||
|
|
7
elections/typing.py
Normal file
7
elections/typing.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
from django.http.request import HttpRequest
|
||||||
|
|
||||||
|
from elections.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatedRequest(HttpRequest):
|
||||||
|
user: User
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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,10 +41,15 @@ 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
|
||||||
|
|
||||||
User = get_user_model()
|
if TYPE_CHECKING:
|
||||||
|
from elections.typing import User
|
||||||
|
else:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
# TODO: access control *everywhere*
|
# TODO: access control *everywhere*
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
|
17
faqs/migrations/0002_alter_faq_options.py
Normal file
17
faqs/migrations/0002_alter_faq_options.py
Normal 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")]},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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' %}>
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
from .celery import app as celery_app
|
|
||||||
|
|
||||||
__all__ = ("celery_app",)
|
|
|
@ -1,5 +0,0 @@
|
||||||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
|
||||||
|
|
||||||
|
|
||||||
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
|
|
||||||
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]
|
|
|
@ -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}")
|
|
1
kadenios/settings/.gitignore
vendored
1
kadenios/settings/.gitignore
vendored
|
@ -1 +0,0 @@
|
||||||
secret.py
|
|
|
@ -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/"
|
|
|
@ -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}
|
|
|
@ -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
|
|
|
@ -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"
|
|
|
@ -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
80
npins/default.nix
Normal 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
22
npins/sources.json
Normal 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
3
pyproject.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[tool.pyright]
|
||||||
|
reportIncompatibleMethodOverride = false
|
||||||
|
reportIncompatibleVariableOverride = false
|
|
@ -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
|
||||||
|
|
|
@ -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
13
shared/admin.py
Normal 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
5
shared/auth/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from .staticdefs import CONNECTION_METHODS
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CONNECTION_METHODS",
|
||||||
|
]
|
|
@ -1,9 +1,15 @@
|
||||||
|
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
|
||||||
|
|
||||||
User = get_user_model()
|
if TYPE_CHECKING:
|
||||||
|
from elections.typing import User
|
||||||
|
else:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class CASBackend(ENSCASBackend):
|
class CASBackend(ENSCASBackend):
|
||||||
|
@ -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
|
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
|
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 _
|
||||||
|
|
||||||
User = get_user_model()
|
if TYPE_CHECKING:
|
||||||
|
from elections.typing import User
|
||||||
|
else:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class ElectionAuthForm(forms.Form):
|
class ElectionAuthForm(forms.Form):
|
||||||
|
@ -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
|
||||||
|
|
6
shared/auth/staticdefs.py
Normal file
6
shared/auth/staticdefs.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
CONNECTION_METHODS = {
|
||||||
|
"pwd": _("mot de passe"),
|
||||||
|
"cas": _("CAS"),
|
||||||
|
}
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -1,15 +1,43 @@
|
||||||
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
|
||||||
|
|
||||||
User = get_user_model()
|
if TYPE_CHECKING:
|
||||||
|
from elections.typing import User
|
||||||
|
else:
|
||||||
|
from django.contrib.auth import 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
10
shared/json/__init__.py
Normal 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
21
shared/json/mixins.py
Normal 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
105
shared/json/views.py
Normal 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)
|
Binary file not shown.
File diff suppressed because it is too large
Load diff
0
shared/management/__init__.py
Normal file
0
shared/management/__init__.py
Normal file
0
shared/management/commands/__init__.py
Normal file
0
shared/management/commands/__init__.py
Normal file
53
shared/management/commands/createadmin.py
Normal file
53
shared/management/commands/createadmin.py
Normal 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
|
||||||
|
)
|
|
@ -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
167
shared/static/js/main.js
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -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
|
||||||
|
|
12
shared/static/vendor/datetimepicker/picker.js
vendored
12
shared/static/vendor/datetimepicker/picker.js
vendored
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
128
shared/templates/auth/account-list.html
Normal file
128
shared/templates/auth/account-list.html
Normal 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 %}
|
47
shared/templates/auth/admin-accounts.html
Normal file
47
shared/templates/auth/admin-accounts.html
Normal 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 %}
|
38
shared/templates/auth/admin-panel.html
Normal file
38
shared/templates/auth/admin-panel.html
Normal 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 %}
|
|
@ -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>
|
||||||
|
|
|
@ -6,11 +6,25 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
|
<h1 class="title">{% trans "Connexion par mot de passe" %}</h1>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-two-thirds">
|
<div class="column is-two-thirds">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
@ -37,6 +51,6 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
46
shared/templates/auth/permission-management.html
Normal file
46
shared/templates/auth/permission-management.html
Normal 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 %}
|
|
@ -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
Loading…
Reference in a new issue