Compare commits
1 commit
main
...
thubrecht/
Author | SHA1 | Date | |
---|---|---|---|
7068c6dd18 |
147 changed files with 3867 additions and 4613 deletions
|
@ -1 +0,0 @@
|
|||
localhost
|
|
@ -1 +0,0 @@
|
|||
Kadenios <kadenios@localhost>
|
|
@ -1 +0,0 @@
|
|||
insecure-secret-key
|
|
@ -1 +0,0 @@
|
|||
kadenios@localhost
|
1
.envrc
1
.envrc
|
@ -1 +0,0 @@
|
|||
use nix
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -11,9 +11,7 @@
|
|||
|
||||
venv/
|
||||
.python-version
|
||||
pyrightconfig.json
|
||||
|
||||
*.sqlite3
|
||||
|
||||
.vscode
|
||||
.direnv
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
diff --git a/src/authens/utils.py b/src/authens/utils.py
|
||||
index 7306506..36063b6 100644
|
||||
--- a/src/authens/utils.py
|
||||
+++ b/src/authens/utils.py
|
||||
@@ -16,7 +16,7 @@ def get_cas_client(request):
|
||||
service_url=urlunparse(
|
||||
(request.scheme, request.get_host(), request.path, "", "", "")
|
||||
),
|
||||
- server_url="https://cas.eleves.ens.fr/",
|
||||
+ server_url="https://cas-eleves.dgnum.eu/",
|
||||
)
|
19
README.md
19
README.md
|
@ -15,7 +15,7 @@ Debian et dérivées (Ubuntu, ...) :
|
|||
sudo apt-get install python3-pip python3-dev python3-venv sqlite3
|
||||
|
||||
Si vous décidez d'utiliser un environnement virtuel Python (virtualenv;
|
||||
fortement conseillé), déplacez-vous dans le dossier où est installé kadenios
|
||||
fortement conseillé), déplacez-vous dans le dossier où est installé GestioCOF
|
||||
(le dossier où se trouve ce README), et créez-le maintenant :
|
||||
|
||||
python3 -m venv venv
|
||||
|
@ -26,18 +26,11 @@ Pour l'activer, il faut taper
|
|||
|
||||
depuis le même dossier.
|
||||
|
||||
Une autre solution est d'utiliser [`pyenv`](https://github.com/pyenv/pyenv) et
|
||||
[`pyenv-virtualenv`](https://github.com/pyenv/pyenv-virtualenv).
|
||||
|
||||
pyenv install 3.7.3
|
||||
pyenv virtualenv 3.7.3 kadenios
|
||||
pyenv local kadenios
|
||||
|
||||
Vous pouvez maintenant installer les dépendances Python depuis le fichier
|
||||
`requirements-dev.txt` :
|
||||
`requirements-devel.txt` :
|
||||
|
||||
pip install -U pip
|
||||
pip install -r requirements-dev.txt
|
||||
pip install -U pip # parfois nécessaire la première fois
|
||||
pip install -r requirements-devel.txt
|
||||
|
||||
Nous avons un git hook de pre-commit pour formatter et vérifier que votre code
|
||||
vérifie nos conventions. Pour bénéficier des mises à jour du hook, préférez
|
||||
|
@ -53,11 +46,11 @@ Il ne vous reste plus qu'à initialiser les modèles de Django :
|
|||
|
||||
Il vous faut ensuite créer un superutilisateur :
|
||||
|
||||
./manage.py createadmin {username} {password} --superuser
|
||||
./manage.py createsuperuser
|
||||
|
||||
Vous êtes prêts à développer ! Lancer Kadenios en faisant
|
||||
|
||||
./manage.py runserver
|
||||
python manage.py runserver
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
|
|
210
app/settings.py
210
app/settings.py
|
@ -1,210 +0,0 @@
|
|||
"""
|
||||
Django settings for the kadenios project
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from loadcredential import Credentials
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
credentials = Credentials(env_prefix="KADENIOS_")
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = credentials["SECRET_KEY"]
|
||||
|
||||
# WARNING: don't run with debug turned on in production!
|
||||
DEBUG = credentials.get_json("DEBUG", False)
|
||||
|
||||
ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", [])
|
||||
|
||||
ADMINS = credentials.get_json("ADMINS", [])
|
||||
|
||||
|
||||
###
|
||||
# List the installed applications
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"shared.IgnoreSrcStaticFilesConfig",
|
||||
"background_task",
|
||||
"shared",
|
||||
"elections",
|
||||
"faqs",
|
||||
"authens",
|
||||
]
|
||||
|
||||
|
||||
###
|
||||
# List the installed middlewares
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
|
||||
###
|
||||
# The main url configuration
|
||||
|
||||
ROOT_URLCONF = "app.urls"
|
||||
|
||||
|
||||
###
|
||||
# Template configuration:
|
||||
# - Django Templating Language is used
|
||||
# - Application directories can be used
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
###
|
||||
# WSGI application configuration
|
||||
|
||||
WSGI_APPLICATION = "app.wsgi.application"
|
||||
|
||||
|
||||
###
|
||||
# E-Mail configuration
|
||||
|
||||
DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"]
|
||||
EMAIL_HOST = credentials.get("EMAIL_HOST", "localhost")
|
||||
EMAIL_HOST_PASSWORD = credentials.get("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_HOST_USER = credentials.get("EMAIL_HOST_USER", "")
|
||||
EMAIL_USE_SSL = credentials.get("EMAIL_USE_SSL", False)
|
||||
SERVER_EMAIL = credentials["SERVER_EMAIL"]
|
||||
|
||||
|
||||
###
|
||||
# Default primary key field type
|
||||
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
|
||||
###
|
||||
# Database configuration
|
||||
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||
|
||||
DATABASES = credentials.get_json(
|
||||
"DATABASES",
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
###
|
||||
# Authentication configuration
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "elections.User"
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"shared.auth.backends.PwdBackend",
|
||||
"shared.auth.backends.CASBackend",
|
||||
"shared.auth.backends.ElectionBackend",
|
||||
]
|
||||
|
||||
LOGIN_URL = reverse_lazy("authens:login")
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
AUTHENS_USE_OLDCAS = False
|
||||
|
||||
|
||||
###
|
||||
# Internationalization configuration
|
||||
# -> https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "fr-fr"
|
||||
TIME_ZONE = "Europe/Paris"
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
LANGUAGES = [
|
||||
("fr", _("Français")),
|
||||
("en", _("Anglais")),
|
||||
]
|
||||
|
||||
LOCALE_PATHS = [BASE_DIR / "shared" / "locale"]
|
||||
|
||||
|
||||
###
|
||||
# Static files (CSS, JavaScript, Images) configuration
|
||||
# -> https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = credentials["STATIC_ROOT"]
|
||||
|
||||
|
||||
###
|
||||
# Background tasks configuration
|
||||
# -> https://django4-background-tasks.readthedocs.io/en/latest/#settings
|
||||
|
||||
BACKGROUND_TASK_RUN_ASYNC = True
|
||||
BACKGROUND_TASK_ASYNC_THREADS = 4
|
||||
|
||||
|
||||
if DEBUG:
|
||||
# Print the e-mails in the console
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
INSTALLED_APPS += [
|
||||
"debug_toolbar",
|
||||
"django_browser_reload",
|
||||
]
|
||||
|
||||
MIDDLEWARE += [
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
]
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {"INSERT_BEFORE": "</footer>"}
|
67
default.nix
67
default.nix
|
@ -1,67 +0,0 @@
|
|||
{
|
||||
sources ? import ./npins,
|
||||
pkgs ? import sources.nixpkgs { },
|
||||
}:
|
||||
|
||||
let
|
||||
nix-pkgs = import sources.nix-pkgs { inherit pkgs; };
|
||||
|
||||
python3 = pkgs.python3.override {
|
||||
packageOverrides = _: _: {
|
||||
inherit (nix-pkgs)
|
||||
django-background-tasks
|
||||
django-browser-reload
|
||||
django-bulma-forms
|
||||
django-translated-fields
|
||||
loadcredential
|
||||
pyrage
|
||||
;
|
||||
|
||||
authens = nix-pkgs.authens.overridePythonAttrs (old: {
|
||||
patches = [ ./01-authens.patch ];
|
||||
});
|
||||
};
|
||||
};
|
||||
in
|
||||
|
||||
{
|
||||
devShell = pkgs.mkShell {
|
||||
name = "cas-eleves.dev";
|
||||
|
||||
packages = [
|
||||
(python3.withPackages (ps: [
|
||||
ps.django
|
||||
ps.ipython
|
||||
ps.django-stubs
|
||||
|
||||
ps.markdown
|
||||
ps.numpy
|
||||
ps.networkx
|
||||
|
||||
ps.authens
|
||||
ps.django-background-tasks
|
||||
ps.django-browser-reload
|
||||
ps.django-bulma-forms
|
||||
ps.django-debug-toolbar
|
||||
ps.django-translated-fields
|
||||
ps.loadcredential
|
||||
ps.pyrage
|
||||
]))
|
||||
|
||||
pkgs.gettext
|
||||
pkgs.gtranslator
|
||||
];
|
||||
|
||||
env = {
|
||||
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
|
||||
KADENIOS_DEBUG = "true";
|
||||
KADENIOS_STATIC_ROOT = builtins.toString ./.static;
|
||||
};
|
||||
|
||||
shellHook = ''
|
||||
if [ ! -d .static ]; then
|
||||
mkdir .static
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
12
elections/admin.py
Normal file
12
elections/admin.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
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,9 +14,6 @@ class ElectionForm(forms.ModelForm):
|
|||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
assert cleaned_data is not None
|
||||
|
||||
if cleaned_data["start_date"] < timezone.now():
|
||||
self.add_error(
|
||||
"start_date", _("Impossible de faire débuter l'élection dans le passé")
|
||||
|
@ -47,18 +44,10 @@ class ElectionForm(forms.ModelForm):
|
|||
*Election.vote_restrictions.fields,
|
||||
]
|
||||
widgets = {
|
||||
"description_en": forms.Textarea(
|
||||
attrs={"rows": 4, "class": "is-family-monospace"}
|
||||
),
|
||||
"description_fr": forms.Textarea(
|
||||
attrs={"rows": 4, "class": "is-family-monospace"}
|
||||
),
|
||||
"vote_restrictions_en": forms.Textarea(
|
||||
attrs={"rows": 4, "class": "is-family-monospace"}
|
||||
),
|
||||
"vote_restrictions_fr": forms.Textarea(
|
||||
attrs={"rows": 4, "class": "is-family-monospace"}
|
||||
),
|
||||
"description_en": forms.Textarea(attrs={"rows": 4}),
|
||||
"description_fr": forms.Textarea(attrs={"rows": 4}),
|
||||
"vote_restrictions_en": forms.Textarea(attrs={"rows": 4}),
|
||||
"vote_restrictions_fr": forms.Textarea(attrs={"rows": 4}),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
# Generated by Django 3.2.4 on 2021-06-14 09:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("elections", "0026_auto_20210529_2246"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="question",
|
||||
name="text_en",
|
||||
field=models.TextField(blank=True, default="", verbose_name="question"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="question",
|
||||
name="text_fr",
|
||||
field=models.TextField(blank=True, default="", verbose_name="question"),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 3.2.4 on 2021-06-17 08:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("elections", "0027_auto_20210614_1123"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="election",
|
||||
name="visible",
|
||||
field=models.BooleanField(default=True, verbose_name="visible au public"),
|
||||
),
|
||||
]
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 3.2.4 on 2021-06-17 09:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("elections", "0028_election_visible"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="election",
|
||||
name="visible",
|
||||
field=models.BooleanField(default=False, verbose_name="visible au public"),
|
||||
),
|
||||
]
|
|
@ -1,27 +0,0 @@
|
|||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,20 +0,0 @@
|
|||
# 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,29 +0,0 @@
|
|||
# Generated by Django 3.2.6 on 2021-08-19 22:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("elections", "0031_alter_election_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="has_valid_email",
|
||||
field=models.BooleanField(
|
||||
default=None, null=True, verbose_name="email valide"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="election",
|
||||
name="sent_mail",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
null=True,
|
||||
verbose_name="mail avec les identifiants envoyé",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,21 +0,0 @@
|
|||
# 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),
|
||||
]
|
|
@ -1,25 +0,0 @@
|
|||
# Generated by Django 4.2.12 on 2024-07-11 12:01
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('elections', '0033_inactive_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vote',
|
||||
name='pseudonymous_user',
|
||||
field=models.CharField(blank=True, max_length=16),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vote',
|
||||
name='user',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
|
@ -1,44 +0,0 @@
|
|||
# Generated by Django 4.2.12 on 2024-07-11 12:24
|
||||
|
||||
import random
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
|
||||
def generate_password(size):
|
||||
random.seed()
|
||||
|
||||
return "".join(random.choice(alphabet) for _ in range(size))
|
||||
|
||||
|
||||
def pseudonymize_users(apps, _):
|
||||
Question = apps.get_model("elections", "Question")
|
||||
Vote = apps.get_model("elections", "Vote")
|
||||
|
||||
votes = set()
|
||||
|
||||
for q in Question.objects.filter(election__tallied=True).prefetch_related(
|
||||
"options__vote_set"
|
||||
):
|
||||
|
||||
for v in q.voters.all():
|
||||
pseudonym = generate_password(16)
|
||||
|
||||
for opt in q.options.all():
|
||||
for vote in opt.vote_set.filter(user=v):
|
||||
vote.pseudonymous_user = pseudonym
|
||||
vote.user = None
|
||||
votes.add(vote)
|
||||
|
||||
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("elections", "0034_vote_pseudonymous_user_alter_vote_user"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(pseudonymize_users)]
|
|
@ -1,32 +1,23 @@
|
|||
from typing import Any
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.http.request import HttpRequest
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from elections.typing import AuthenticatedRequest
|
||||
|
||||
from .models import Election, Option, Question
|
||||
|
||||
|
||||
class AdminOnlyMixin(PermissionRequiredMixin):
|
||||
"""Restreint l'accès aux admins"""
|
||||
|
||||
request: AuthenticatedRequest
|
||||
|
||||
permission_required = "elections.election_admin"
|
||||
permission_required = "elections.is_admin"
|
||||
|
||||
|
||||
class SelectElectionMixin:
|
||||
"""Sélectionne automatiquement les foreignkeys voulues"""
|
||||
|
||||
model: type
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
qs = super().get_queryset() # pyright: ignore
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.model is Question:
|
||||
return qs.select_related("election")
|
||||
elif self.model is Option:
|
||||
|
@ -37,19 +28,15 @@ class SelectElectionMixin:
|
|||
class RestrictAccessMixin(SelectElectionMixin):
|
||||
"""Permet de restreindre l'accès à des élections/questions/options"""
|
||||
|
||||
f_prefixes = {
|
||||
Election: "",
|
||||
Question: "election__",
|
||||
Option: "question__election__",
|
||||
}
|
||||
f_prefixes = {Election: "", Question: "election__", Option: "question__election__"}
|
||||
|
||||
def get_f_prefix(self) -> str:
|
||||
return self.f_prefixes.get(self.model, "")
|
||||
def get_f_prefix(self):
|
||||
return self.f_prefixes.get(self.model, None)
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
return {}
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if self.model in self.f_prefixes:
|
||||
return qs.filter(**self.get_filters())
|
||||
|
@ -60,7 +47,7 @@ class RestrictAccessMixin(SelectElectionMixin):
|
|||
class OpenElectionOnlyMixin(RestrictAccessMixin):
|
||||
"""N'autorise la vue que lorsque l'élection est ouverte"""
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
f_prefix = self.get_f_prefix()
|
||||
# On ne peut modifier que les élections qui n'ont pas commencé, et
|
||||
# accessoirement qui ne sont pas dépouillées ou archivées
|
||||
|
@ -68,39 +55,38 @@ class OpenElectionOnlyMixin(RestrictAccessMixin):
|
|||
filters = super().get_filters()
|
||||
filters[f_prefix + "start_date__lt"] = timezone.now()
|
||||
filters[f_prefix + "end_date__gt"] = timezone.now()
|
||||
filters[f_prefix + "visible"] = True
|
||||
filters[f_prefix + "tallied"] = False
|
||||
filters[f_prefix + "archived"] = False
|
||||
return filters
|
||||
|
||||
|
||||
class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
|
||||
class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin):
|
||||
"""Restreint l'accès au créateurice de l'élection"""
|
||||
|
||||
def get_next_url(self):
|
||||
return reverse("kadenios")
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
filters = super().get_filters()
|
||||
# TODO: change the way we collect the user according to the model used
|
||||
filters[self.get_f_prefix() + "created_by"] = self.request.user
|
||||
return filters
|
||||
|
||||
|
||||
class CreatorOnlyEditMixin(CreatorOnlyMixin):
|
||||
class CreatorOnlyEditMixin(CreatorOnlyMixin, SingleObjectMixin):
|
||||
"""Permet au créateurice de modifier l'élection implicitement"""
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
# On ne peut modifier que les élections qui n'ont pas commencé
|
||||
filters = super().get_filters()
|
||||
filters[self.get_f_prefix() + "start_date__gt"] = timezone.now()
|
||||
return filters
|
||||
|
||||
|
||||
class ClosedElectionMixin(CreatorOnlyMixin):
|
||||
class ClosedElectionMixin(CreatorOnlyMixin, SingleObjectMixin):
|
||||
"""Permet d'agir sur une élection terminée"""
|
||||
|
||||
def get_filters(self) -> dict[str, Any]:
|
||||
def get_filters(self):
|
||||
f_prefix = self.get_f_prefix()
|
||||
# L'élection doit être terminée et non archivée
|
||||
filters = super().get_filters()
|
||||
|
@ -110,17 +96,12 @@ class ClosedElectionMixin(CreatorOnlyMixin):
|
|||
|
||||
|
||||
class NotArchivedMixin:
|
||||
"""
|
||||
Permet de ne garder que les élections non archivées, et visibles
|
||||
ou dont on est l'admin
|
||||
"""
|
||||
"""Permet de ne garder que les élections non archivées ou dont on est l'admin"""
|
||||
|
||||
request: HttpRequest
|
||||
|
||||
def get_queryset(self) -> QuerySet:
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = super().get_queryset() # pyright: ignore
|
||||
qs = super().get_queryset()
|
||||
if user.is_authenticated:
|
||||
return qs.filter(Q(archived=False, visible=True) | Q(created_by=user))
|
||||
return qs.filter(Q(archived=False) | Q(created_by=user))
|
||||
|
||||
return qs.filter(archived=False, visible=True)
|
||||
return qs.filter(archived=False)
|
||||
|
|
|
@ -1,22 +1,16 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from translated_fields import TranslatedFieldWithFallback
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models, transaction
|
||||
from django.http.request import HttpRequest
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from shared.auth import CONNECTION_METHODS
|
||||
from shared.auth.utils import generate_password
|
||||
from shared.json import Serializer
|
||||
from shared.utils import choices_length
|
||||
|
||||
from .staticdefs import (
|
||||
BALLOT_TYPE,
|
||||
CAST_FUNCTIONS,
|
||||
CONNECTION_METHODS,
|
||||
QUESTION_TYPES,
|
||||
TALLY_FUNCTIONS,
|
||||
VALIDATE_FUNCTIONS,
|
||||
|
@ -29,20 +23,12 @@ from .utils import (
|
|||
ValidateFunctions,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models.fields.related_descriptors import ManyRelatedManager
|
||||
from django.utils.functional import _StrPromise
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Models regarding an election
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class Election(models.Model):
|
||||
registered_voters: models.Manager["User"]
|
||||
questions: models.Manager["Question"]
|
||||
|
||||
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
|
||||
short_name = models.SlugField(_("nom bref"), unique=True)
|
||||
description = TranslatedFieldWithFallback(
|
||||
|
@ -52,8 +38,6 @@ class Election(models.Model):
|
|||
start_date = models.DateTimeField(_("date et heure de début"))
|
||||
end_date = models.DateTimeField(_("date et heure de fin"))
|
||||
|
||||
visible = models.BooleanField(_("visible au public"), default=False)
|
||||
|
||||
vote_restrictions = TranslatedFieldWithFallback(
|
||||
models.TextField(_("conditions de vote"), blank=True)
|
||||
)
|
||||
|
@ -63,7 +47,7 @@ class Election(models.Model):
|
|||
)
|
||||
|
||||
sent_mail = models.BooleanField(
|
||||
_("mail avec les identifiants envoyé"), null=True, default=False
|
||||
_("mail avec les identifiants envoyé"), default=False
|
||||
)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
|
@ -85,30 +69,18 @@ class Election(models.Model):
|
|||
|
||||
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:
|
||||
permissions = [
|
||||
("election_admin", _("Peut administrer des élections")),
|
||||
("is_admin", _("Peut administrer des élections")),
|
||||
]
|
||||
ordering = ["-start_date", "-end_date"]
|
||||
|
||||
|
||||
class Question(Serializer, models.Model):
|
||||
options: models.Manager["Option"]
|
||||
duels: models.Manager["Duel"]
|
||||
|
||||
class Question(models.Model):
|
||||
election = models.ForeignKey(
|
||||
Election, related_name="questions", on_delete=models.CASCADE
|
||||
)
|
||||
text = TranslatedFieldWithFallback(
|
||||
models.TextField(_("question"), blank=True, default="")
|
||||
)
|
||||
text = TranslatedFieldWithFallback(models.TextField(_("question"), blank=False))
|
||||
type = models.CharField(
|
||||
_("type de question"),
|
||||
choices=QUESTION_TYPES,
|
||||
|
@ -126,48 +98,20 @@ class Question(Serializer, models.Model):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
serializable_fields = ["text_en", "text_fr", "type"]
|
||||
|
||||
def is_form_valid(self, vote_form) -> bool:
|
||||
def is_form_valid(self, vote_form):
|
||||
validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type])
|
||||
return vote_form.is_valid() and validate_function(vote_form)
|
||||
|
||||
@transaction.atomic
|
||||
def cast_ballot(self, user: "User", vote_form) -> None:
|
||||
def cast_ballot(self, user, vote_form):
|
||||
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
|
||||
cast_function(user, vote_form)
|
||||
|
||||
@transaction.atomic
|
||||
def tally(self) -> None:
|
||||
def tally(self):
|
||||
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
|
||||
tally_function(self)
|
||||
|
||||
@transaction.atomic
|
||||
def pseudonymize(self):
|
||||
"""
|
||||
Generates a random id for each voter
|
||||
"""
|
||||
|
||||
options = list(self.options.prefetch_related("vote_set"))
|
||||
votes: set[Vote] = set()
|
||||
|
||||
for v in self.voters.all():
|
||||
pseudonym = generate_password(16)
|
||||
|
||||
for opt in options:
|
||||
for vote in opt.vote_set.filter(user=v):
|
||||
vote.pseudonymous_user = pseudonym
|
||||
vote.user = None
|
||||
votes.add(vote)
|
||||
|
||||
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
|
||||
|
||||
@property
|
||||
def results(self) -> str:
|
||||
return render_to_string(
|
||||
f"elections/results/{self.vote_type}_export.txt", {"question": self}
|
||||
)
|
||||
|
||||
def get_formset(self):
|
||||
from .forms import BallotFormset # Avoid circular imports
|
||||
|
||||
|
@ -185,16 +129,14 @@ class Question(Serializer, models.Model):
|
|||
def vote_type(self):
|
||||
return BALLOT_TYPE[self.type]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.text)
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
||||
|
||||
class Option(Serializer, models.Model):
|
||||
vote_set: models.Manager["Vote"]
|
||||
|
||||
class Option(models.Model):
|
||||
question = models.ForeignKey(
|
||||
Question, related_name="options", on_delete=models.CASCADE
|
||||
)
|
||||
|
@ -205,27 +147,22 @@ class Option(Serializer, models.Model):
|
|||
voters = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
related_name="votes",
|
||||
through="elections.Vote",
|
||||
through="Vote",
|
||||
blank=True,
|
||||
)
|
||||
# For now, we store the amount of votes received after the election is tallied
|
||||
nb_votes = models.PositiveSmallIntegerField(_("nombre de votes reçus"), default=0)
|
||||
|
||||
serializable_fields = ["text_fr", "text_en", "abbreviation"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# On enlève les espaces et on passe tout en majuscules
|
||||
self.abbreviation = "".join(self.abbreviation.upper().split())
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_abbr(self, default: str) -> str:
|
||||
return self.abbreviation or default
|
||||
|
||||
def __str__(self) -> str:
|
||||
def __str__(self):
|
||||
if self.abbreviation:
|
||||
return f"{self.abbreviation} - {self.text}"
|
||||
return str(self.text)
|
||||
return self.abbreviation + " - " + self.text
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
ordering = ["id"]
|
||||
|
@ -233,22 +170,12 @@ class Option(Serializer, models.Model):
|
|||
|
||||
class Vote(models.Model):
|
||||
option = models.ForeignKey(Option, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True
|
||||
)
|
||||
pseudonymous_user = models.CharField(max_length=16, blank=True)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ["option"]
|
||||
|
||||
|
||||
class RankedVote(Vote):
|
||||
rank: "Rank"
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class Rank(models.Model):
|
||||
vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
|
||||
rank = models.PositiveSmallIntegerField(_("rang de l'option"))
|
||||
|
@ -276,10 +203,6 @@ class Duel(models.Model):
|
|||
|
||||
|
||||
class User(AbstractUser):
|
||||
cast_elections: "ManyRelatedManager[Election]"
|
||||
cast_questions: "ManyRelatedManager[Question]"
|
||||
votes: "ManyRelatedManager[Vote]"
|
||||
|
||||
election = models.ForeignKey(
|
||||
Election,
|
||||
related_name="registered_voters",
|
||||
|
@ -288,33 +211,27 @@ class User(AbstractUser):
|
|||
on_delete=models.CASCADE,
|
||||
)
|
||||
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
|
||||
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
|
||||
|
||||
@property
|
||||
def base_username(self) -> str:
|
||||
def base_username(self):
|
||||
return "__".join(self.username.split("__")[1:])
|
||||
|
||||
def can_vote(self, request: HttpRequest, election: Election) -> bool:
|
||||
def can_vote(self, request, election):
|
||||
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
|
||||
# ouvertes à tou·te·s
|
||||
if self.election is None:
|
||||
# If the user is connected via CAS, request.session["CASCONNECTED"] is set
|
||||
# to True by authens
|
||||
return not election.restricted and request.session.get(
|
||||
"CASCONNECTED", False
|
||||
)
|
||||
return not election.restricted and request.session.get("CASCONNECTED")
|
||||
|
||||
# Pour les élections restreintes, il faut y être associé
|
||||
return election.restricted and (self.election == election)
|
||||
|
||||
def is_admin(self, election: Election) -> bool:
|
||||
return election.created_by == self or self.is_staff
|
||||
|
||||
def get_prefix(self) -> str:
|
||||
def get_prefix(self):
|
||||
return self.username.split("__")[0]
|
||||
|
||||
@property
|
||||
def connection_method(self) -> "_StrPromise":
|
||||
def connection_method(self):
|
||||
method = self.username.split("__")[0]
|
||||
return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))
|
||||
|
||||
|
|
|
@ -1,24 +1,31 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
MAIL_VOTERS = """Dear {full_name},
|
||||
MAIL_VOTERS = (
|
||||
"Dear {full_name},\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"Election URL: {election_url}\n"
|
||||
"\n"
|
||||
"Your voter ID: {username}\n"
|
||||
"Your password: {password}\n"
|
||||
"\n"
|
||||
"-- \n"
|
||||
"Kadenios"
|
||||
)
|
||||
|
||||
Election URL: {election_url}
|
||||
The election will take place from {start} to {end}.
|
||||
MAIL_VOTE_DELETED = (
|
||||
"Dear {full_name},\n"
|
||||
"\n"
|
||||
"Your vote for {election_name} has been removed."
|
||||
"\n"
|
||||
"-- \n"
|
||||
"Kadenios"
|
||||
)
|
||||
|
||||
Your voter ID: {username}
|
||||
Your password: {password}
|
||||
|
||||
--
|
||||
Kadenios
|
||||
"""
|
||||
|
||||
MAIL_VOTE_DELETED = """Dear {full_name},
|
||||
|
||||
Your vote for {election_name} has been removed.
|
||||
|
||||
--
|
||||
Kadenios
|
||||
"""
|
||||
CONNECTION_METHODS = {
|
||||
"pwd": _("mot de passe"),
|
||||
"cas": _("CAS"),
|
||||
}
|
||||
|
||||
QUESTION_TYPES = [
|
||||
("assentiment", _("Assentiment")),
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
from background_task import background
|
||||
|
||||
from .models import Election
|
||||
from .utils import send_mail
|
||||
|
||||
|
||||
@background
|
||||
def send_election_mail(election_pk: int, subject: str, body: str, reply_to: str):
|
||||
election = Election.objects.get(pk=election_pk)
|
||||
send_mail(election, subject, body, reply_to)
|
||||
election.sent_mail = True
|
||||
election.save(update_fields=["sent_mail"])
|
||||
|
||||
|
||||
@background
|
||||
def pseudonimize_election(election_pk: int):
|
||||
election = Election.objects.get(pk=election_pk)
|
||||
|
||||
for q in election.questions.all():
|
||||
q.pseudonymize()
|
|
@ -1,40 +0,0 @@
|
|||
{% 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>
|
|
@ -1,67 +0,0 @@
|
|||
{% 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>
|
||||
<tr>
|
||||
{% for o in options %}
|
||||
<th class="has-text-centered">{{ o }}</th>
|
||||
<th>{{ o }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for ballot in ballots %}
|
||||
{% for ballot in ballots.values %}
|
||||
<tr>
|
||||
{% for r in ballot %}
|
||||
<td class="has-text-centered">{{ r }}</td>
|
||||
|
|
|
@ -3,13 +3,13 @@
|
|||
<thead>
|
||||
<tr>
|
||||
{% for o in options %}
|
||||
<th class="has-text-centered">{{ o }}</th>
|
||||
<th>{{ o }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for ballot in ballots %}
|
||||
{% for ballot in ballots.values %}
|
||||
<tr>
|
||||
{% for v in ballot %}
|
||||
<td class="has-text-centered">
|
||||
|
|
|
@ -1,19 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n markdown %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level mb-2 is-mobile">
|
||||
<div class="level">
|
||||
{# Titre de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1 pr-3">
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<h1 class="title">{{ election.name }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level-right is-flex is-flex-shrink-1">
|
||||
{# Statut de l'élection #}
|
||||
<div class="level-right">
|
||||
{# 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 %}
|
||||
<div class="level-item is-flex-shrink-1">
|
||||
{# Statut de l'élection #}
|
||||
<div class="level-item">
|
||||
<span class="tag is-medium is-outlined is-light is-primary">
|
||||
{% if election.end_date < current_time %}
|
||||
{% trans "Élection terminée" %}
|
||||
|
@ -24,138 +46,67 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Lien vers la page d'administration #}
|
||||
{% if election.created_by == user %}
|
||||
<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 #}
|
||||
{% if election.created_by == user %}
|
||||
<a class="dropdown-item" href="{% url 'election.admin' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
<span>{% trans "Administrer" %}</span>
|
||||
</a>
|
||||
|
||||
<hr class="dropdown-divider">
|
||||
{% 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>
|
||||
<a class="tag is-medium is-primary is-light is-outlined has-tooltip-primary" href="{% url 'election.admin' election.pk %}" data-tooltip="{% trans "Administrer" %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
{# Dates d'ouverture de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1 pr-3">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-item">
|
||||
<span class="tag is-medium is-primary">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<span class="tag is-medium is-primary">
|
||||
<span class="icon-text">
|
||||
<span>{{ election.start_date|date:"d/m/Y H:i" }}</span>
|
||||
<span class="icon">
|
||||
<i class="fas fa-long-arrow-alt-right"></i>
|
||||
</span>
|
||||
<span>{{ election.end_date|date:"d/m/Y H:i" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Créateurice de l'élection #}
|
||||
<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>
|
||||
</div>
|
||||
{# Créateurice de l'élection #}
|
||||
<div class="level-item">
|
||||
<span class="tag is-primary is-light is-outlined">{% blocktrans with creator=election.created_by.full_name %}Créé par {{ creator }}{% endblocktrans %}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Confirmation de vote #}
|
||||
{% if has_voted %}
|
||||
<div class="level-right is-flex-shrink-1">
|
||||
<div class="level-item is-flex-shrink-1">
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<div class="tag is-medium is-outlined is-success is-light">
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<span>{% trans "Votre vote a bien été enregistré." %}</span>
|
||||
</span>
|
||||
<span>{% trans "Votre vote a bien été enregistré." %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
{# Précisions sur les modalités de vote #}
|
||||
{% if election.vote_restrictions %}
|
||||
<div class="message is-warning">
|
||||
<div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
|
||||
<div class="message-body">{{ election.vote_restrictions|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Indications de connexion #}
|
||||
{% 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 election.start_date < current_time and election.end_date > current_time %}
|
||||
{% if can_vote %}
|
||||
<div class="columns is-centered tile is-ancestor">
|
||||
<div class="column is-one-third tile is-parent">
|
||||
|
@ -199,7 +150,7 @@
|
|||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}?next={% url 'election.view' election.pk %}">
|
||||
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}">
|
||||
<div class="subtitle has-text-centered mb-2">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-white">
|
||||
|
@ -220,7 +171,7 @@
|
|||
{# Description de l'élection #}
|
||||
{% if election.description %}
|
||||
<div class="message is-primary">
|
||||
<div class="message-body content">{{ election.description|markdown|safe }}</div>
|
||||
<div class="message-body">{{ election.description|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -228,14 +179,9 @@
|
|||
{% for q in election.questions.all %}
|
||||
<div class="panel" id="q_{{ q.pk }}">
|
||||
<div class="panel-heading is-size-6">
|
||||
<div class="level is-mobile">
|
||||
<div class="level">
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<span class="mr-3">
|
||||
<span class="icon">
|
||||
<i class="fas fa-poll-h"></i>
|
||||
</span>
|
||||
<span>{{ q }}</span>
|
||||
</span>
|
||||
<span>{{ q }}</span>
|
||||
</div>
|
||||
|
||||
{% if q in cast_questions %}
|
||||
|
|
|
@ -1,65 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n markdown %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block custom_js %}
|
||||
{% block extra_head %}
|
||||
<script>
|
||||
const _fm = b => {
|
||||
b.addEventListener('click', () => {
|
||||
const f = _$('form', _id(b.dataset.target), false);
|
||||
f.dataset.next = b.dataset.next;
|
||||
f.dataset.origin = b.dataset.parent
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
var $modalButtons = document.querySelectorAll('.modal-button') || [];
|
||||
|
||||
const d = JSON.parse(b.dataset.json);
|
||||
$modalButtons.forEach($el => {
|
||||
$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;
|
||||
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
_$(`[name='${k}']`, f, false).value = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_$('.modal-button').forEach(_fm);
|
||||
|
||||
const _del = d => {
|
||||
d.addEventListener('click', () => {
|
||||
_get(d.dataset.url, r => {
|
||||
if (r.success && r.action == 'delete') {
|
||||
_id(d.dataset.target).remove()
|
||||
}
|
||||
|
||||
if (r.message) {
|
||||
_notif(r.message.content, r.message.class);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_$('.del').forEach(_del);
|
||||
|
||||
_$('form').forEach(f => {
|
||||
f.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
|
||||
_post(f.action, f, r => {
|
||||
if (r.success) {
|
||||
const e = document.createElement('div');
|
||||
e.innerHTML = r.html;
|
||||
// On initialise les boutons
|
||||
_$('.modal-button', e).forEach(b => {
|
||||
_om(b);
|
||||
_fm(b);
|
||||
});
|
||||
_$('.del', e).forEach(_del);
|
||||
|
||||
if (r.action == 'create') {
|
||||
_id(f.dataset.next).appendChild(e.firstElementChild);
|
||||
} else if (r.action == 'update') {
|
||||
const n = _id(f.dataset.origin);
|
||||
n.parentNode.replaceChild(e.firstElementChild, n);
|
||||
}
|
||||
// On ferme le modal
|
||||
document.documentElement.classList.remove('is-clipped');
|
||||
_id(f.dataset.modal).classList.remove('is-active');
|
||||
if ($el.classList.contains('question')) {
|
||||
$target_form.querySelector('#id_text_fr').value = $el.dataset.q_fr || '';
|
||||
$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 || '';
|
||||
$target_form.querySelector('#id_abbreviation').value = $el.dataset.abbr || '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -78,144 +41,96 @@
|
|||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
{# Visibilité de l'élection #}
|
||||
<div class="level-item">
|
||||
<div class="level is-mobile">
|
||||
<div class="level-item">
|
||||
{% if not election.visible %}
|
||||
<span class="tag is-medium is-outlined is-warning is-light">
|
||||
<span class="icon">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
<div class="dropdown is-right">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{% trans "Actions" %}</span>
|
||||
</span>
|
||||
<span>{% trans "Élection invisible" %}</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="tag is-medium is-outlined is-primary is-light">
|
||||
<span class="icon">
|
||||
<i class="fas fa-eye"></i>
|
||||
</span>
|
||||
<span>{% trans "Élection visible" %}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Menu d'actions #}
|
||||
<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-cog" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
{# Téléchargement de la liste des votant·e·s #}
|
||||
<a class="dropdown-item" href="{% url 'election.export-voters' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-download"></i>
|
||||
</span>
|
||||
<span>{% trans "Exporter les votant·e·s" %}
|
||||
</a>
|
||||
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
{# Vue classique #}
|
||||
<a class="dropdown-item" href="{% url 'election.view' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</span>
|
||||
<span>{% trans "Vue classique" %}
|
||||
</a>
|
||||
{% if election.start_date > current_time %}
|
||||
{# Modification de l'élection #}
|
||||
<a class="dropdown-item" href="{% url 'election.update' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
<span>{% trans "Modifier" %}</span>
|
||||
</a>
|
||||
|
||||
<hr class="dropdown-divider">
|
||||
{# Gestion des votant·e·s #}
|
||||
{% if election.restricted %}
|
||||
<a class="dropdown-item" href="{% url 'election.upload-voters' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-import"></i>
|
||||
</span>
|
||||
<span>{% trans "Gestion de la liste de votant·e·s" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if not election.visible %}
|
||||
{# Rend l'élection visible par tout le monde #}
|
||||
<a class="dropdown-item" href="{% url 'election.set-visible' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-eye"></i>
|
||||
</span>
|
||||
<span>{% trans "Rendre l'élection visible" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif election.end_date < current_time %}
|
||||
|
||||
{# Téléchargement de la liste des votant·e·s #}
|
||||
<a class="dropdown-item" href="{% url 'election.export-voters' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-download"></i>
|
||||
</span>
|
||||
<span>{% trans "Exporter les votant·e·s" %}
|
||||
</a>
|
||||
{% if not election.tallied %}
|
||||
{# Liste des votants #}
|
||||
<a class="dropdown-item" href="{% url 'election.voters' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-list"></i>
|
||||
</span>
|
||||
<span>{% trans "Liste des votant·e·s" %}</span>
|
||||
</a>
|
||||
|
||||
{% if election.start_date > current_time %}
|
||||
{# Modification de l'élection #}
|
||||
<a class="dropdown-item" href="{% url 'election.update' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
<span>{% trans "Modifier" %}</span>
|
||||
</a>
|
||||
{# Dépouillement #}
|
||||
<a class="dropdown-item" href="{% url 'election.tally' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-poll-h"></i>
|
||||
</span>
|
||||
<span>{% trans "Dépouiller" %}</span>
|
||||
</a>
|
||||
|
||||
{# Gestion des votant·e·s #}
|
||||
{% if election.restricted %}
|
||||
<a class="dropdown-item" href="{% url 'election.upload-voters' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-import"></i>
|
||||
</span>
|
||||
<span>{% trans "Gestion de la liste de votant·e·s" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
||||
{% elif election.end_date < current_time %}
|
||||
{# Publication des résultats #}
|
||||
{% if not election.archived %}
|
||||
<a class="dropdown-item" href="{% url 'election.publish' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
{% if not election.results_public %}
|
||||
<span>{% trans "Publier" %}</span>
|
||||
{% else %}
|
||||
<span>{% trans "Dépublier" %}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if not election.tallied %}
|
||||
{# Liste des votants #}
|
||||
<a class="dropdown-item" href="{% url 'election.voters' election.pk %}?prev=admin">
|
||||
<span class="icon">
|
||||
<i class="fas fa-list"></i>
|
||||
</span>
|
||||
<span>{% trans "Liste des votant·e·s" %}</span>
|
||||
</a>
|
||||
{# Archivage #}
|
||||
{% if not election.archived %}
|
||||
<a class="dropdown-item" href="{% url 'election.archive' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-archive"></i>
|
||||
</span>
|
||||
<span>{% trans "Archiver" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Dépouillement #}
|
||||
<a class="dropdown-item" href="{% url 'election.tally' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-poll-h"></i>
|
||||
</span>
|
||||
<span>{% trans "Dépouiller" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{# Publication des résultats #}
|
||||
{% if not election.archived %}
|
||||
<a class="dropdown-item" href="{% url 'election.publish' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
{% if not election.results_public %}
|
||||
<span>{% trans "Publier" %}</span>
|
||||
{% else %}
|
||||
<span>{% trans "Dépublier" %}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Export des résultats #}
|
||||
<a class="dropdown-item" href="{% url 'election.download-results' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-save"></i>
|
||||
</span>
|
||||
<span>{% trans "Télécharger les résultats" %}</span>
|
||||
</a>
|
||||
|
||||
{# Archivage #}
|
||||
{% if not election.archived %}
|
||||
<a class="dropdown-item" href="{% url 'election.archive' election.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-archive"></i>
|
||||
</span>
|
||||
<span>{% trans "Archiver" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -244,23 +159,116 @@
|
|||
{# Description de l'élection #}
|
||||
{% if election.description %}
|
||||
<div class="message is-primary">
|
||||
<div class="message-body content">{{ election.description|markdown|safe }}</div>
|
||||
<div class="message-body">{{ election.description|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Précisions sur les modalités de vote #}
|
||||
{% if election.vote_restrictions %}
|
||||
<div class="message is-warning">
|
||||
<div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
|
||||
<div class="message-body">{{ election.vote_restrictions|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Liste des questions #}
|
||||
<div id="questions" class="block">
|
||||
{% for q in election.questions.all %}
|
||||
{% include 'elections/admin/question.html' %}
|
||||
{% for q in election.questions.all %}
|
||||
<div class="panel" id="q_{{ q.pk }}">
|
||||
<div class="panel-heading is-size-6">
|
||||
<div class="level">
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<div class="level-item is-flex-shrink-1">
|
||||
<span>{{ q }}</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 %}
|
||||
|
||||
{# 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>
|
||||
{% endfor %}
|
||||
|
||||
{# Rajout d'une question #}
|
||||
{% if election.start_date > current_time %}
|
||||
|
@ -275,7 +283,7 @@
|
|||
|
||||
<div class="columns is-centered" id="q_add">
|
||||
<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" %}" data-next="questions" data-json='{"text_fr": "", "text_en": "", "type": "assentiment"}'>
|
||||
<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" %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-question"></i>
|
||||
</span>
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
|
||||
{% block content %}
|
||||
<div class="level is-mobile">
|
||||
<div class="level">
|
||||
{# Titre de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1 pr-3">
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<h1 class="title">{{ election.name }}</h1>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -4,18 +4,17 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{# DateTimePicker #}
|
||||
<script src="{% static 'vendor/datetimepicker/picker.js' %}"></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/datetimepicker/picker.css' %}">
|
||||
{% endblock %}
|
||||
<script src={% static 'vendor/datepicker/datetimepicker.js' %}></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/datepicker/datetimepicker.css' %}">
|
||||
|
||||
{% block custom_js %}
|
||||
<script>
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
new DateTimePicker('input[name=start_date]', {
|
||||
lang: '{{ LANGUAGE_CODE }}',
|
||||
});
|
||||
new DateTimePicker('input[name=end_date]', {
|
||||
lang: '{{ LANGUAGE_CODE }}',
|
||||
$(document).ready(function($) {
|
||||
$('#id_start_date').datetimepicker({
|
||||
format: 'Y-m-d H:i'
|
||||
});
|
||||
$('#id_end_date').datetimepicker({
|
||||
format: 'Y-m-d H:i'
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n markdown %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left is-flex-shrink-1 pr-3">
|
||||
<div class="level-item is-flex-shrink-1">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<h1 class="title">{% trans "Liste des élections" %}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if perms.elections.election_admin %}
|
||||
{% if perms.elections.is_admin %}
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<a class="button is-light is-outlined is-primary" href={% url 'election.create' %}>
|
||||
|
@ -27,15 +27,15 @@
|
|||
<hr>
|
||||
|
||||
{% for e in election_list %}
|
||||
<div class="panel is-primary is-radiusless">
|
||||
<div class="panel-heading is-size-6 is-radiusless">
|
||||
<div class="level is-mobile mb-0">
|
||||
<div class="panel is-primary">
|
||||
<div class="panel-heading is-size-6 is-radiusles">
|
||||
<div class="level">
|
||||
<div class="level-left 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>
|
||||
</div>
|
||||
|
||||
<div class="level-item is-hidden-touch">
|
||||
<div class="level-item">
|
||||
<span class="tag is-primary is-light">
|
||||
<span class="icon-text">
|
||||
<span>{{ e.start_date|date:"d/m/Y H:i" }}</span>
|
||||
|
@ -49,84 +49,41 @@
|
|||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
{% if e.tallied %}
|
||||
<div class="level-item">
|
||||
{% if not e.visible %}
|
||||
<span class="tag is-warning is-light">
|
||||
<span class="icon">
|
||||
<i class="fas fa-eye-slash"></i>
|
||||
</span>
|
||||
<span>{% trans "Élection invisible" %}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
<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.created_by == user %}
|
||||
{% 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 %}
|
||||
<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" %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="panel-block">
|
||||
<div class="content is-flex-grow-1">
|
||||
{{ e.description|markdown|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="panel-block">
|
||||
{{ e.description|linebreaksbr }}
|
||||
</p>
|
||||
</div>
|
||||
{% if not forloop.last %}
|
||||
<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -4,18 +4,17 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{# DateTimePicker #}
|
||||
<script src="{% static 'vendor/datetimepicker/picker.js' %}"></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/datetimepicker/picker.css' %}">
|
||||
{% endblock %}
|
||||
<script src={% static 'vendor/datepicker/datetimepicker.js' %}></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/datepicker/datetimepicker.css' %}">
|
||||
|
||||
{% block custom_js %}
|
||||
<script>
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
new DateTimePicker('input[name=start_date]', {
|
||||
lang: '{{ LANGUAGE_CODE }}',
|
||||
});
|
||||
new DateTimePicker('input[name=end_date]', {
|
||||
lang: '{{ LANGUAGE_CODE }}',
|
||||
$(document).ready(function($) {
|
||||
$('#id_start_date').datetimepicker({
|
||||
format: 'Y-m-d H:i'
|
||||
});
|
||||
$('#id_end_date').datetimepicker({
|
||||
format: 'Y-m-d H:i'
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
|
|
@ -1,226 +1,129 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n markdown %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block custom_js %}
|
||||
{% if can_delete %}
|
||||
<script>
|
||||
_$('.modal-button').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
const f = _$('form', _id(b.dataset.target), false);
|
||||
f.dataset.target = b.dataset.origin;
|
||||
_$('[name="delete"]', f, false).value = 'non';
|
||||
});
|
||||
{% block extra_head %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const $del_modal = document.getElementById('modal-delete');
|
||||
const $del_title = $del_modal.querySelector('.modal-card-title');
|
||||
const $del_form = $del_modal.querySelector('form');
|
||||
|
||||
$del_buttons = document.querySelectorAll('.modal-button.delete-vote')
|
||||
|
||||
$del_buttons.forEach($del => {
|
||||
$del.addEventListener('click', () => {
|
||||
$del_form.action = $del.dataset.post_url;
|
||||
$del_title.innerHTML = $del.dataset.tooltip;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
_$('form').forEach(f => {
|
||||
f.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
</script>
|
||||
|
||||
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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level is-mobile">
|
||||
{# Titre de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1 mr-3">
|
||||
<h1 class="title">{{ election.name }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<a class="button is-primary" href="{% if from_admin %}{% url 'election.admin' election.pk %}{% else %}{% url 'election.view' election.pk %}{% endif %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-undo-alt"></i>
|
||||
</span>
|
||||
<span>{% trans "Retour" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level">
|
||||
{# Titre de l'élection #}
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<h1 class="title">{{ election.name }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{# Précisions sur les modalités de vote #}
|
||||
{% if election.vote_restrictions %}
|
||||
<div class="message is-warning">
|
||||
<div class="message-body content">{{ election.vote_restrictions|markdown|safe }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="message is-warning">
|
||||
<div class="message-body">
|
||||
{% if election.restricted %}
|
||||
{% trans "Seules les personnes présentes sur cette liste peuvent voter, vous avez dû recevoir un mail avec vos identifiants de connexion." %}
|
||||
{% else %}
|
||||
{% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève, d'autres restrictions peuvent s'appliquer et votre vote pourra être supprimé si vous n'avez pas le droit de vote." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_vote or is_admin %}
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-narrow">
|
||||
{% if can_delete %}
|
||||
{% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
|
||||
{% endif %}
|
||||
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Nom" %}</th>
|
||||
<th class="has-text-centered">{% trans "Vote enregistré" %}</th>
|
||||
{% if can_delete %}
|
||||
<th class="has-text-centered">{% trans "Supprimer" %}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% if election.restricted %}
|
||||
{% for v in election.registered_voters.all %}
|
||||
<tr id="v_{{ forloop.counter }}">
|
||||
<td>{{ v.full_name }} ({{ v.base_username }})</td>
|
||||
{% if v in voters %}
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
</td>
|
||||
{% if can_delete %}
|
||||
<td class="has-text-centered">
|
||||
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
|
||||
<a class="tag is-danger modal-button delete-vote" data-target="modal-delete" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}" data-title="{{ v_delete }}" data-origin="v_{{ forloop.counter }}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
</td>
|
||||
{% if can_delete %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{% for v in voters %}
|
||||
<tr id="v_{{ forloop.counter }}">
|
||||
<td>{{ v.full_name }} ({{ v.base_username }})</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
</td>
|
||||
{% if can_delete %}
|
||||
<td class="has-text-centered">
|
||||
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
|
||||
<a class="tag is-danger modal-button delete-vote" data-target="modal-delete" data-post_url="{% url 'election.delete-vote' election.pk v.pk forloop.counter %}" data-title="{{ v_delete }}" data-origin="v_{{ forloop.counter }}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="notification is-danger is-light has-text-centered">
|
||||
<b>{% trans "Pour voir la liste des votant·e·s vous devez être connecté·e." %}</b>
|
||||
{% if election.restricted %}
|
||||
<br>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<a class="button is-primary" href="{% if can_delete %}{% url 'election.admin' election.pk %}{% else %}{% url 'election.view' election.pk %}{% endif %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<i class="fas fa-undo-alt"></i>
|
||||
</span>
|
||||
<i>{% trans "La connexion doit s'effectuer via les identifiants reçus par mail." %}</i>
|
||||
{% endif %}
|
||||
<span>{% trans "Retour" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-half">
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-parent">
|
||||
{% if election.restricted %}
|
||||
<a class="tile is-child notification is-primary" href="{% url 'auth.election' election.pk %}?next={% url 'election.voters' election.pk %}">
|
||||
<div class="subtitle has-text-centered mb-2">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-white">
|
||||
<i class="fas fa-unlock"></i>
|
||||
</span>
|
||||
<span class="ml-3">{% trans "Connexion par identifiants" %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="tile is-child notification is-primary" href="{% url 'authens:login.cas' %}?next={% url 'election.voters' election.pk %}">
|
||||
<div class="subtitle has-text-centered mb-2">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-white">
|
||||
<i class="fas fa-school"></i>
|
||||
</span>
|
||||
<span class="ml-3">{% trans "Connexion via CAS" %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<h3 class="subtitle">{% trans "Liste des votant·e·s" %} ({{ voters|length }})</h3>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{# Précisions sur les modalités de vote #}
|
||||
{% if election.vote_restrictions %}
|
||||
<div class="message is-warning">
|
||||
<div class="message-body">{{ election.vote_restrictions|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="message is-warning">
|
||||
<div class="message-body">
|
||||
{% if election.restricted %}
|
||||
{% trans "Seules les personnes présentes sur cette liste peuvent voter, vous avez dû recevoir un mail avec vos identifiants de connexion." %}
|
||||
{% else %}
|
||||
{% trans "Pour voter lors de cette élection, vous devez vous connecter à l'aide du CAS élève, d'autres restrictions peuvent s'appliquer et votre vote pourra être supprimé si vous n'avez pas le droit de vote." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-two-thirds">
|
||||
{% if can_delete %}
|
||||
{% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
|
||||
{% endif %}
|
||||
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Nom" %}</th>
|
||||
<th class="has-text-centered">{% trans "Vote enregistré" %}</th>
|
||||
{% if can_delete %}
|
||||
<th class="has-text-centered">{% trans "Supprimer" %}</th>
|
||||
{% endif %}
|
||||
<tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if election.restricted %}
|
||||
{% for v in election.registered_voters.all %}
|
||||
<tr>
|
||||
<td>{{ v.full_name }} ({{ v.base_username }})</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
{% if v in voters %}
|
||||
<i class="fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-times"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for v in voters %}
|
||||
<tr id="v_{{ forloop.counter }}">
|
||||
<td>{{ v.full_name }} ({{ v.base_username }})</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
</td>
|
||||
{% if can_delete %}
|
||||
<td class="has-text-centered">
|
||||
{% blocktrans with v_name=v.full_name asvar v_delete %}Supprimer le vote de {{ v_name }}{% endblocktrans %}
|
||||
<a class="tag is-danger 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 %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
19
elections/templates/elections/option_update.html
Normal file
19
elections/templates/elections/option_update.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% 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 %}
|
19
elections/templates/elections/question_update.html
Normal file
19
elections/templates/elections/question_update.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% 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,8 +1,8 @@
|
|||
{% load i18n %}
|
||||
|
||||
<div class="panel-block">
|
||||
<div class="columns is-centered is-flex-grow-1 is-mobile">
|
||||
<div class="column is-narrow">
|
||||
<div class="columns is-centered is-fullwidth">
|
||||
<div class="column">
|
||||
<table class="table is-bordered is-striped">
|
||||
<thead>
|
||||
<th class="has-text-centered">
|
||||
|
@ -24,6 +24,7 @@
|
|||
|
||||
<tbody>
|
||||
{% for line, o in matrix %}
|
||||
{% with loser=forloop.counter %}
|
||||
<tr>
|
||||
<th class="has-text-centered">
|
||||
<span class="icon-text">
|
||||
|
@ -35,9 +36,10 @@
|
|||
</th>
|
||||
|
||||
{% for cell, class in line %}
|
||||
<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>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
{{ question.text }} :
|
||||
{% for o in question.options.all %}- ({% if o.abbreviation %}{{ o.abbreviation }}{% else %}{{ forloop.counter }}{% endif %}) {{ o.text }}{% if not forloop.last %}
|
||||
{% endif %}{% endfor %}
|
|
@ -1,3 +0,0 @@
|
|||
{{ question.text }} :
|
||||
{% for o in question.options.all %}- {{ o.nb_votes }} {{ o.text }}{% if not forloop.last %}
|
||||
{% endif %}{% endfor %}
|
|
@ -3,11 +3,6 @@
|
|||
|
||||
|
||||
{% block extra_head %}
|
||||
{# Pendant l'envoi on rafraîchit automatiquement #}
|
||||
{% if election.sent_mail is None %}
|
||||
<meta http-equiv="refresh" content="20">
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
{% if not election.sent_mail %}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
@ -26,38 +21,24 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<div class="item-level is-flex-shrink-1 pr-3">
|
||||
<div class="level is-flex-widescreen">
|
||||
<div class="level-left">
|
||||
<div class="item-level">
|
||||
<h1 class="title">{% trans "Gestion de la liste de votant·e·s" %}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<div class="level-item is-hidden-touch">
|
||||
{% if election.sent_mail is False %}
|
||||
{% if not election.sent_mail %}
|
||||
<div class="level-item">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div class="level-item">
|
||||
<a class="button is-primary" href="{% url 'election.admin' election.pk %}">
|
||||
|
@ -69,31 +50,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-item is-hidden-desktop">
|
||||
{% 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>
|
||||
|
||||
{# Si on a déjà envoyé le mail avec les identifiants, on ne peut plus changer la liste #}
|
||||
|
@ -143,39 +99,26 @@
|
|||
<hr>
|
||||
|
||||
<div class="columns is-centered">
|
||||
<div class="column is-12">
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-bordered is-striped has-text-centered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Login" %}</th>
|
||||
<th>{% trans "Nom" %}</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<div class="column is-two-thirds">
|
||||
<table class="table is-fullwidth is-bordered is-striped has-text-centered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Login" %}</th>
|
||||
<th>{% trans "Nom" %}</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for v in voters %}
|
||||
<tr>
|
||||
<td>{{ v.base_username }}</td>
|
||||
<td>{{ v.full_name }}</td>
|
||||
<td>
|
||||
{{ v.email }}
|
||||
{% if v.has_valid_email %}
|
||||
<span class="icon has-text-success is-pulled-right">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
{% elif v.has_valid_email is False %}
|
||||
<span class="icon has-text-danger is-pulled-right">
|
||||
<i class="fas fa-times"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<tbody>
|
||||
{% for v in voters %}
|
||||
<tr>
|
||||
<td>{{ v.base_username }}</td>
|
||||
<td>{{ v.full_name }}</td>
|
||||
<td>{{ v.email }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -29,7 +29,7 @@
|
|||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% block vote_form %}{% endblock %}
|
||||
{% block vote_form %}{% endblock %}
|
||||
|
||||
<div class="field is-grouped is-centered">
|
||||
<div class="control is-expanded">
|
||||
|
|
|
@ -5,40 +5,46 @@
|
|||
{% block extra_head %}
|
||||
<script>
|
||||
const nb_options = {{ nb_options }};
|
||||
const rank_zones = new Array(nb_options + 1);
|
||||
let ranks_used = nb_options;
|
||||
var ranks_used = nb_options;
|
||||
var rank_zones = new Array(nb_options + 1);
|
||||
var $unranked;
|
||||
|
||||
function getLabelText(i) {
|
||||
const label = _$('.label label', i.closest('.field'), false).innerHTML;
|
||||
function getLabelText($input) {
|
||||
var label = $input.closest('.field').querySelector('.label label').innerHTML;
|
||||
return label.substring(0, label.length - 1).trim();
|
||||
}
|
||||
|
||||
function collapseRanks() {
|
||||
// On décale pour éviter les rangs vides
|
||||
for (let j = 1; j < nb_options; j++) {
|
||||
for (let i = 1; i < nb_options; i++) {
|
||||
// On a au moins le tag avec le numéro du rang
|
||||
if (rank_zones[j].childElementCount == 1) {
|
||||
if (rank_zones[i].childElementCount == 1) {
|
||||
// On cherche le prochain rang avec des options
|
||||
let next_rank = j + 1;
|
||||
var next_rank = i + 1;
|
||||
for (; next_rank < nb_options && rank_zones[next_rank].childElementCount == 1; next_rank++) {}
|
||||
|
||||
// On déplace les options
|
||||
while (rank_zones[next_rank].childElementCount > 1) {
|
||||
const t = rank_zones[next_rank].lastChild;
|
||||
const i = _id(t.dataset.input);
|
||||
i.value = j.toString();
|
||||
rank_zones[j].append(t);
|
||||
let $tile = rank_zones[next_rank].lastChild;
|
||||
let $input = document.getElementById($tile.dataset.input);
|
||||
$input.value = i.toString();
|
||||
rank_zones[i].append($tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On recalcule ranks_used
|
||||
for (ranks_used = 0; ranks_used < nb_options && rank_zones[ranks_used + 1].childElementCount > 1; ranks_used++) {}
|
||||
for (ranks_used = 1; ranks_used < nb_options && rank_zones[ranks_used + 1].childElementCount > 1; ranks_used++) {}
|
||||
|
||||
// On cache les zones non utilisées, sauf une
|
||||
// On affiche le bouton + si besoin
|
||||
if (ranks_used < nb_options) {
|
||||
let $add_rank = document.getElementById('rank-add');
|
||||
$add_rank.parentElement.classList.remove('is-hidden')
|
||||
}
|
||||
|
||||
// On cache les zones non utilisées
|
||||
for (let i = 1; i <= nb_options; i++) {
|
||||
if (i > (ranks_used + 1)) {
|
||||
if (i > ranks_used) {
|
||||
rank_zones[i].parentElement.classList.add('is-hidden');
|
||||
} else {
|
||||
rank_zones[i].parentElement.classList.remove('is-hidden');
|
||||
|
@ -47,19 +53,19 @@
|
|||
}
|
||||
|
||||
function moveOptions() {
|
||||
_$('.control .input').forEach(i => {
|
||||
(document.querySelectorAll('.control .input') || []).forEach($input => {
|
||||
// On rajoute la tuile dans le classement ou dans les non classées
|
||||
const r = parseInt(i.value);
|
||||
const t = _id(`tile-${i.id}`);
|
||||
const rank = parseInt($input.value);
|
||||
var $tile = document.getElementById(`tile-${$input.id}`);
|
||||
|
||||
if (!(typeof r === 'undefined') && r > 0 && r <= nb_options) {
|
||||
rank_zones[r].appendChild(t);
|
||||
rank_zones[r].parentElement.classList.remove('is-hidden');
|
||||
ranks_used = Math.max(r, ranks_used);
|
||||
if (!(typeof rank === 'undefined') && rank > 0 && rank <= nb_options) {
|
||||
rank_zones[rank].appendChild($tile);
|
||||
rank_zones[rank].parentElement.classList.remove('is-hidden');
|
||||
ranks_used = Math.max(rank, ranks_used);
|
||||
} else {
|
||||
$unranked.appendChild(t);
|
||||
$unranked.appendChild($tile);
|
||||
// On enlève les valeurs non règlementaires
|
||||
i.value = '';
|
||||
$input.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -79,55 +85,65 @@
|
|||
// On récupère l'id de la tuile à déplacer
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
|
||||
const d = event.target.closest('.drop-zone');
|
||||
var $target = event.target.closest('.drop-zone');
|
||||
|
||||
const r = d.dataset.rank;
|
||||
const t = _id(data);
|
||||
const i = _id(t.dataset.input);
|
||||
if ($target.id == 'rank-add') {
|
||||
ranks_used += 1;
|
||||
|
||||
// Si on ne change pas de rang, pas besoin de déplacer l'option
|
||||
if (i.value != r) {
|
||||
// On déplace l'option
|
||||
d.appendChild(t);
|
||||
// Si on a autant de rangs que d'option, on cache le bouton +
|
||||
if (ranks_used == nb_options) {
|
||||
$target.parentElement.classList.add('is-hidden');
|
||||
}
|
||||
|
||||
// On enregistre le rang dans le formulaire
|
||||
i.value = r;
|
||||
$target = rank_zones[ranks_used];
|
||||
$target.parentElement.classList.remove('is-hidden');
|
||||
}
|
||||
|
||||
// On déplace l'option
|
||||
var $tile = document.getElementById(data);
|
||||
$target.appendChild($tile);
|
||||
|
||||
// On enregistre le rang dans le formulaire
|
||||
const rank = $target.dataset.rank;
|
||||
var $input = document.getElementById($tile.dataset.input);
|
||||
$input.value = rank;
|
||||
|
||||
collapseRanks();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Affiche le modal et remplit le récapitulatif
|
||||
_id('confirm-button').addEventListener('click', () => {
|
||||
const ranks = new Array(nb_options + 1);
|
||||
document.getElementById('confirm-button').addEventListener('click', () => {
|
||||
var $modal_body = document.getElementById('modal-body');
|
||||
|
||||
_$('.control .input').forEach(i => {
|
||||
const r = parseInt(i.value) || nb_options;
|
||||
var ranks = new Array(nb_options + 1);
|
||||
|
||||
const o = getLabelText(i)
|
||||
(document.querySelectorAll('.control .input') || []).forEach($input => {
|
||||
var rank = parseInt($input.value) || nb_options;
|
||||
|
||||
if (r > 0 && r <= nb_options) {
|
||||
ranks[r] = (ranks[r] || []).concat([o]);
|
||||
var option = getLabelText($input)
|
||||
|
||||
if (rank > 0 && rank <= nb_options) {
|
||||
ranks[rank] = (ranks[rank] || []).concat([option]);
|
||||
} else {
|
||||
ranks[nb_options] = (ranks[nb_options] || []).concat([o]);
|
||||
ranks[nb_options] = (ranks[nb_options] || []).concat([option]);
|
||||
}
|
||||
});
|
||||
|
||||
let trs = '';
|
||||
var table_rows = '';
|
||||
|
||||
for (let j = 1; j <= nb_options; j++) {
|
||||
let option_list = '';
|
||||
for (let i = 1; i <= nb_options; i++) {
|
||||
var option_list = '';
|
||||
|
||||
if (!(typeof ranks[j] === 'undefined')) {
|
||||
for (option of ranks[j]) {
|
||||
if (!(typeof ranks[i] === 'undefined')) {
|
||||
for (option of ranks[i]) {
|
||||
option_list += `${option}<br>`;
|
||||
}
|
||||
}
|
||||
trs += `<tr><th>${j}</th><td><div>${option_list}</div></td></tr>\n`
|
||||
table_rows += `<tr><th>${i}</th><td><div>${option_list}</div></td></tr>\n`
|
||||
}
|
||||
|
||||
_id('modal-body').innerHTML = `
|
||||
$modal_body.innerHTML = `
|
||||
<table class="table is-fullwidth is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -136,60 +152,62 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${trs}
|
||||
${table_rows}
|
||||
</tbody>
|
||||
</table>`;
|
||||
});
|
||||
|
||||
// Change le mode de remplissge de formulaire (input vs drag & drop)
|
||||
_id('change-method').addEventListener('click', () => {
|
||||
const h = _id('hide-form');
|
||||
const d = _id('drag-zone');
|
||||
const b = _id('change-method');
|
||||
document.getElementById('change-method').addEventListener('click', () => {
|
||||
var $hide = document.getElementById('hide-form');
|
||||
var $drag_zone = document.getElementById('drag-zone');
|
||||
var $method_button = document.getElementById('change-method');
|
||||
|
||||
// On échange ce qui est visible
|
||||
h.classList.toggle('is-hidden');
|
||||
d.classList.toggle('is-hidden');
|
||||
$hide.classList.toggle('is-hidden');
|
||||
$drag_zone.classList.toggle('is-hidden');
|
||||
|
||||
if (h.classList.contains('is-hidden')) {
|
||||
b.innerHTML = "{% trans "Utiliser le formulaire classique" %}";
|
||||
if ($hide.classList.contains('is-hidden')) {
|
||||
$method_button.innerHTML = "{% trans "Utiliser le formulaire classique" %}";
|
||||
|
||||
moveOptions();
|
||||
collapseRanks();
|
||||
} else {
|
||||
b.innerHTML = "{% trans "Utiliser le cliquer-déposer" %}";
|
||||
$method_button.innerHTML = "{% trans "Utiliser le cliquer-déposer" %}";
|
||||
}
|
||||
});
|
||||
|
||||
// Initialise les éléments pour le formulaire interactif
|
||||
$unranked = _id('unranked');
|
||||
$unranked = document.getElementById('unranked');
|
||||
|
||||
for (let i = 1; i <= nb_options; i++) {
|
||||
rank_zones[i] = _id(`rank-${i}`);
|
||||
rank_zones[i] = document.getElementById(`rank-${i}`);
|
||||
}
|
||||
|
||||
_$('.control .input').forEach(i => {
|
||||
(document.querySelectorAll('.control .input') || []).forEach($input => {
|
||||
var option = getLabelText($input);
|
||||
|
||||
// On créé une tuile avec le nom de l'option
|
||||
const t = document.createElement('div');
|
||||
var $tile = document.createElement('div');
|
||||
|
||||
t.classList.add('tile', 'is-parent', 'is-flex-grow-0');
|
||||
t.id = `tile-${i.id}`;
|
||||
t.dataset.input = i.id;
|
||||
t.innerHTML = `<p class="tile is-child notification is-primary is-grabable">${getLabelText(i)}</p>`;
|
||||
$tile.classList.add('tile', 'is-parent', 'is-flex-grow-0');
|
||||
$tile.id = `tile-${$input.id}`;
|
||||
$tile.dataset.input = $input.id;
|
||||
$tile.innerHTML = `<p class="tile is-child notification is-primary">${option}</p>`;
|
||||
|
||||
t.setAttribute('draggable', true);
|
||||
t.addEventListener('dragstart', dragstart_handler);
|
||||
$tile.setAttribute('draggable', true);
|
||||
$tile.addEventListener('dragstart', dragstart_handler);
|
||||
|
||||
// Par défaut on ajoute la tuile dans undefined
|
||||
$unranked.appendChild(t);
|
||||
$unranked.appendChild($tile);
|
||||
});
|
||||
|
||||
moveOptions();
|
||||
collapseRanks();
|
||||
|
||||
_$('.drop-zone').forEach(z => {
|
||||
z.addEventListener('drop', drop_handler);
|
||||
z.addEventListener('dragover', dragover_handler);
|
||||
document.querySelectorAll('.drop-zone').forEach($zone => {
|
||||
$zone.addEventListener('drop', drop_handler);
|
||||
$zone.addEventListener('dragover', dragover_handler);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -217,11 +235,22 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="tile is-parent is-flex-grow-0">
|
||||
<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 class="tile is-parent">
|
||||
<div id="unranked" class="tile is-vertical drop-zone notification" data-rank="">
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<div id="unranked" class="tile is-vertical drop-zone notification" data-rank="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -5,16 +5,19 @@
|
|||
{% block extra_head %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
_id('confirm-button').addEventListener('click', () => {
|
||||
let selected_rows = '';
|
||||
document.getElementById('confirm-button').addEventListener('click', () => {
|
||||
var $modal_body = document.getElementById('modal-body');
|
||||
|
||||
_$('.checkbox input').forEach(c => {
|
||||
if (c.checked) {
|
||||
selected_rows += `<tr><td>${c.nextSibling.textContent.trim()}</td></tr>\n`;
|
||||
var selected_rows = '';
|
||||
|
||||
(document.querySelectorAll('.checkbox input') || []).forEach($checkbox => {
|
||||
if ($checkbox.checked) {
|
||||
let option_text = $checkbox.nextSibling.textContent.trim();
|
||||
selected_rows += '<tr><td>' + option_text + '</td></tr>\n';
|
||||
}
|
||||
});
|
||||
|
||||
_id('modal-body').innerHTML = `
|
||||
$modal_body.innerHTML = `
|
||||
<table class="table is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
@ -7,10 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
|
||||
from .test_utils import create_election
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserTests(TestCase):
|
||||
|
@ -45,11 +40,8 @@ class UserTests(TestCase):
|
|||
session["CASCONNECTED"] = True
|
||||
session.save()
|
||||
|
||||
assert session.session_key is not None
|
||||
|
||||
# On sauvegarde le cookie de session
|
||||
session_cookie_name = settings.SESSION_COOKIE_NAME
|
||||
|
||||
self.client.cookies[session_cookie_name] = session.session_key
|
||||
|
||||
self.assertFalse(self.cas_user.can_vote(self.client, self.election_1))
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from .test_utils import create_election
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AdminViewsTest(TestCase):
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
from django.http.request import HttpRequest
|
||||
|
||||
from elections.models import User
|
||||
|
||||
|
||||
class AuthenticatedRequest(HttpRequest):
|
||||
user: User
|
|
@ -21,21 +21,11 @@ urlpatterns = [
|
|||
views.ExportVotersView.as_view(),
|
||||
name="election.export-voters",
|
||||
),
|
||||
path(
|
||||
"results/<int:pk>",
|
||||
views.DownloadResultsView.as_view(),
|
||||
name="election.download-results",
|
||||
),
|
||||
path(
|
||||
"delete-vote/<int:pk>/<int:user_pk>/<int:anchor>",
|
||||
views.DeleteVoteView.as_view(),
|
||||
name="election.delete-vote",
|
||||
),
|
||||
path(
|
||||
"visible/<int:pk>",
|
||||
views.ElectionSetVisibleView.as_view(),
|
||||
name="election.set-visible",
|
||||
),
|
||||
path("update/<int:pk>", views.ElectionUpdateView.as_view(), name="election.update"),
|
||||
path("tally/<int:pk>", views.ElectionTallyView.as_view(), name="election.tally"),
|
||||
path(
|
||||
|
@ -49,33 +39,33 @@ urlpatterns = [
|
|||
# Question views
|
||||
path(
|
||||
"add-question/<int:pk>",
|
||||
views.CreateQuestionView.as_view(),
|
||||
views.AddQuestionView.as_view(),
|
||||
name="election.add-question",
|
||||
),
|
||||
path(
|
||||
"mod-question/<int:pk>",
|
||||
views.UpdateQuestionView.as_view(),
|
||||
views.ModQuestionView.as_view(),
|
||||
name="election.mod-question",
|
||||
),
|
||||
path(
|
||||
"del-question/<int:pk>",
|
||||
views.DeleteQuestionView.as_view(),
|
||||
views.DelQuestionView.as_view(),
|
||||
name="election.del-question",
|
||||
),
|
||||
# Option views
|
||||
path(
|
||||
"add-option/<int:pk>",
|
||||
views.CreateOptionView.as_view(),
|
||||
views.AddOptionView.as_view(),
|
||||
name="election.add-option",
|
||||
),
|
||||
path(
|
||||
"mod-option/<int:pk>",
|
||||
views.UpdateOptionView.as_view(),
|
||||
views.ModOptionView.as_view(),
|
||||
name="election.mod-option",
|
||||
),
|
||||
path(
|
||||
"del-option/<int:pk>",
|
||||
views.DeleteOptionView.as_view(),
|
||||
views.DelOptionView.as_view(),
|
||||
name="election.del-option",
|
||||
),
|
||||
# Common views
|
||||
|
|
|
@ -1,46 +1,30 @@
|
|||
import csv
|
||||
import io
|
||||
import smtplib
|
||||
from typing import TYPE_CHECKING, TypeGuard
|
||||
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
from networkx.algorithms.dag import ancestors, descendants
|
||||
from numpy._typing import NDArray
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import File
|
||||
from django.core.mail import EmailMessage
|
||||
from django.core.mail import EmailMessage, get_connection
|
||||
from django.core.validators import validate_email
|
||||
from django.forms import BaseFormSet
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from shared.auth.utils import generate_password
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.forms import RankVoteForm, SelectVoteForm
|
||||
from elections.models import Election, Question, RankedVote, Vote
|
||||
from elections.typing import User
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Classes pour différencier les différents types de questions
|
||||
# #############################################################################
|
||||
|
||||
|
||||
def has_rank(v: "Vote") -> TypeGuard["RankedVote"]:
|
||||
return hasattr(v, "rank")
|
||||
|
||||
|
||||
class CastFunctions:
|
||||
"""Classe pour enregistrer les votes"""
|
||||
|
||||
@staticmethod
|
||||
def cast_select(user: "User", vote_form: "BaseFormSet[SelectVoteForm]"):
|
||||
def cast_select(user, vote_form):
|
||||
"""On enregistre un vote classique"""
|
||||
selected, n_selected = [], []
|
||||
for v in vote_form:
|
||||
|
@ -52,8 +36,7 @@ class CastFunctions:
|
|||
user.votes.add(*selected)
|
||||
user.votes.remove(*n_selected)
|
||||
|
||||
@staticmethod
|
||||
def cast_rank(user: "User", vote_form: "BaseFormSet[RankVoteForm]"):
|
||||
def cast_rank(user, vote_form):
|
||||
"""On enregistre un vote par classement"""
|
||||
from .models import Rank, Vote
|
||||
|
||||
|
@ -69,8 +52,7 @@ class CastFunctions:
|
|||
|
||||
for v in vote_form:
|
||||
vote = votes[v.instance]
|
||||
|
||||
if has_rank(vote):
|
||||
if hasattr(vote, "rank"):
|
||||
vote.rank.rank = v.cleaned_data["rank"]
|
||||
ranks_update.append(vote.rank)
|
||||
else:
|
||||
|
@ -83,8 +65,7 @@ class CastFunctions:
|
|||
class TallyFunctions:
|
||||
"""Classe pour gérer les dépouillements"""
|
||||
|
||||
@staticmethod
|
||||
def tally_select(question: "Question") -> None:
|
||||
def tally_select(question):
|
||||
"""On dépouille un vote classique"""
|
||||
from .models import Option
|
||||
|
||||
|
@ -104,8 +85,7 @@ class TallyFunctions:
|
|||
|
||||
Option.objects.bulk_update(options, ["nb_votes", "winner"])
|
||||
|
||||
@staticmethod
|
||||
def tally_schultze(question: "Question") -> None:
|
||||
def tally_schultze(question):
|
||||
"""On dépouille un vote par classement et on crée la matrice des duels"""
|
||||
from .models import Duel, Option, Rank
|
||||
|
||||
|
@ -121,12 +101,12 @@ class TallyFunctions:
|
|||
else:
|
||||
ranks_by_user[user] = [r]
|
||||
|
||||
ballots: list[NDArray[np.int_]] = []
|
||||
ballots = []
|
||||
|
||||
# Pour chaque votant·e, on regarde son classement
|
||||
for user in ranks_by_user:
|
||||
votes = ranks_by_user[user]
|
||||
ballot = np.zeros((nb_options, nb_options), dtype=int)
|
||||
ballot = np.zeros((nb_options, nb_options))
|
||||
|
||||
for i in range(nb_options):
|
||||
for j in range(i):
|
||||
|
@ -140,9 +120,6 @@ class TallyFunctions:
|
|||
# des duels
|
||||
duels = sum(ballots)
|
||||
|
||||
# As ballots is not empty, sum cannot be 0
|
||||
assert duels != 0
|
||||
|
||||
# Configuration du graphe
|
||||
graph = nx.DiGraph()
|
||||
|
||||
|
@ -185,11 +162,11 @@ class TallyFunctions:
|
|||
# le plus faible
|
||||
min_weight = min(nx.get_edge_attributes(graph, "weight").values())
|
||||
min_edges = []
|
||||
for u, v in graph.edges():
|
||||
for (u, v) in graph.edges():
|
||||
if graph[u][v]["weight"] == min_weight:
|
||||
min_edges.append((u, v))
|
||||
|
||||
for u, v in min_edges:
|
||||
for (u, v) in min_edges:
|
||||
graph.remove_edge(u, v)
|
||||
|
||||
# Les options gagnantes sont celles encore présentes dans le graphe
|
||||
|
@ -203,31 +180,29 @@ class TallyFunctions:
|
|||
class ValidateFunctions:
|
||||
"""Classe pour valider les formsets selon le type de question"""
|
||||
|
||||
@staticmethod
|
||||
def always_true(_) -> bool:
|
||||
"""Renvoie True pour les votes sans validation particulière"""
|
||||
def always_true(vote_form):
|
||||
"""Retourne True pour les votes sans validation particulière"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def unique_selected(vote_form: "BaseFormSet[SelectVoteForm]") -> bool:
|
||||
def unique_selected(vote_form):
|
||||
"""Vérifie qu'une seule option est choisie"""
|
||||
|
||||
nb_selected = sum(v.cleaned_data["selected"] for v in vote_form)
|
||||
nb_selected = 0
|
||||
for v in vote_form:
|
||||
nb_selected += v.cleaned_data["selected"]
|
||||
|
||||
if nb_selected == 0:
|
||||
vote_form._non_form_errors.append( # pyright: ignore
|
||||
vote_form._non_form_errors.append(
|
||||
ValidationError(_("Vous devez sélectionnner une option."))
|
||||
)
|
||||
return False
|
||||
elif nb_selected > 1:
|
||||
vote_form._non_form_errors.append( # pyright: ignore
|
||||
vote_form._non_form_errors.append(
|
||||
ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option."))
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def limit_ranks(vote_form: "BaseFormSet[RankVoteForm]"):
|
||||
def limit_ranks(vote_form):
|
||||
"""Limite le classement au nombre d'options"""
|
||||
nb_options = len(vote_form)
|
||||
valid = True
|
||||
|
@ -253,34 +228,28 @@ class ValidateFunctions:
|
|||
class ResultsData:
|
||||
"""Classe pour afficher des informations supplémentaires après la fin d'une élection"""
|
||||
|
||||
@staticmethod
|
||||
def select(_: "Question") -> str:
|
||||
def select(question):
|
||||
"""On renvoie l'explication des couleurs"""
|
||||
return render_to_string("elections/results/select.html")
|
||||
|
||||
@staticmethod
|
||||
def rank(question: "Question") -> str:
|
||||
def rank(question):
|
||||
"""On récupère la matrice des résultats et on l'affiche"""
|
||||
duels = question.duels.all()
|
||||
options = list(question.options.all())
|
||||
n = len(options)
|
||||
|
||||
_matrix = np.full((n, n), {"value": 0}, dtype=dict)
|
||||
matrix = np.empty((n, n), dtype=tuple)
|
||||
_matrix = np.zeros((n, n), dtype=int)
|
||||
matrix = np.zeros((n, n), dtype=tuple)
|
||||
|
||||
for d in duels:
|
||||
i, j = options.index(d.loser), options.index(d.winner)
|
||||
_matrix[i, j] = {
|
||||
"value": d.amount,
|
||||
"winner": d.winner.get_abbr(j + 1),
|
||||
"loser": d.loser.get_abbr(i + 1),
|
||||
}
|
||||
_matrix[i, j] = d.amount
|
||||
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if _matrix[i, j]["value"] > _matrix[j, i]["value"]:
|
||||
if _matrix[i, j] > _matrix[j, i]:
|
||||
matrix[i, j] = (_matrix[i, j], "is-success")
|
||||
elif _matrix[i, j]["value"] < _matrix[j, i]["value"]:
|
||||
elif _matrix[i, j] < _matrix[j, i]:
|
||||
matrix[i, j] = (_matrix[i, j], "is-danger")
|
||||
else:
|
||||
matrix[i, j] = (_matrix[i, j], "")
|
||||
|
@ -296,40 +265,37 @@ class ResultsData:
|
|||
class BallotsData:
|
||||
"""Classe pour afficher les bulletins d'une question"""
|
||||
|
||||
@staticmethod
|
||||
def select(question: "Question") -> str:
|
||||
def select(question):
|
||||
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
|
||||
from .models import Vote
|
||||
|
||||
votes = Vote.objects.filter(option__question=question)
|
||||
votes = Vote.objects.filter(option__question=question).select_related("user")
|
||||
|
||||
options = list(question.options.all())
|
||||
|
||||
ballots = {}
|
||||
for v in votes:
|
||||
ballot = ballots.get(v.pseudonymous_user, [False] * len(options))
|
||||
ballot = ballots.get(v.user, [False] * len(options))
|
||||
ballot[options.index(v.option)] = True
|
||||
|
||||
ballots[v.pseudonymous_user] = ballot
|
||||
ballots[v.user] = ballot
|
||||
|
||||
return render_to_string(
|
||||
"elections/ballots/select.html",
|
||||
{"options": options, "ballots": sorted(ballots.values(), reverse=True)},
|
||||
"elections/ballots/select.html", {"options": options, "ballots": ballots}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rank(question: "Question") -> str:
|
||||
def rank(question):
|
||||
"""Renvoie un tableau contenant les classements des options par bulletin"""
|
||||
from .models import Rank
|
||||
|
||||
options = list(question.options.all())
|
||||
ranks = Rank.objects.select_related("vote").filter(
|
||||
ranks = Rank.objects.select_related("vote__user").filter(
|
||||
vote__option__in=options
|
||||
)
|
||||
ranks_by_user = {}
|
||||
|
||||
for r in ranks:
|
||||
user = r.vote.pseudonymous_user
|
||||
user = r.vote.user
|
||||
if user in ranks_by_user:
|
||||
ranks_by_user[user].append(r.rank)
|
||||
else:
|
||||
|
@ -337,7 +303,7 @@ class BallotsData:
|
|||
|
||||
return render_to_string(
|
||||
"elections/ballots/rank.html",
|
||||
{"options": options, "ballots": sorted(ranks_by_user.values())},
|
||||
{"options": options, "ballots": ranks_by_user},
|
||||
)
|
||||
|
||||
|
||||
|
@ -346,30 +312,20 @@ class BallotsData:
|
|||
# #############################################################################
|
||||
|
||||
|
||||
def create_users(election: "Election", csv_file: File):
|
||||
def create_users(election, csv_file):
|
||||
"""Crée les votant·e·s pour l'élection donnée, en remplissant les champs
|
||||
`username`, `election` et `full_name`.
|
||||
"""
|
||||
User = get_user_model()
|
||||
|
||||
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
|
||||
csv_file.seek(0)
|
||||
reader = csv.reader(io.StringIO(csv_file.read().decode("utf-8")), dialect)
|
||||
|
||||
users = [
|
||||
User(
|
||||
election=election,
|
||||
username=f"{election.pk}__{username}",
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
for (username, full_name, email) in reader:
|
||||
election.registered_voters.create(
|
||||
username=f"{election.id}__{username}", email=email, full_name=full_name
|
||||
)
|
||||
for (username, full_name, email) in reader
|
||||
]
|
||||
|
||||
User.objects.bulk_create(users)
|
||||
|
||||
|
||||
def check_csv(csv_file: File):
|
||||
def check_csv(csv_file):
|
||||
"""Vérifie que le fichier donnant la liste de votant·e·s est bien formé"""
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
|
||||
|
@ -422,47 +378,30 @@ def check_csv(csv_file: File):
|
|||
return errors
|
||||
|
||||
|
||||
def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> None:
|
||||
def send_mail(election, mail_form):
|
||||
"""Envoie le mail d'annonce de l'élection avec identifiants et mot de passe
|
||||
aux votant·e·s, le mdp est généré en même temps que le mail est envoyé.
|
||||
"""
|
||||
User = get_user_model()
|
||||
|
||||
# On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un
|
||||
voters = list(election.registered_voters.exclude(has_valid_email=True))
|
||||
e_url = reverse("election.view", args=[election.pk])
|
||||
voters = list(election.registered_voters.all())
|
||||
e_url = reverse("election.view", args=[election.id])
|
||||
url = f"https://vote.eleves.ens.fr{e_url}"
|
||||
start = election.start_date.strftime("%d/%m/%Y %H:%M %Z")
|
||||
end = election.end_date.strftime("%d/%m/%Y %H:%M %Z")
|
||||
messages = []
|
||||
for v in voters:
|
||||
password = generate_password()
|
||||
v.password = make_password(password)
|
||||
messages.append(
|
||||
(
|
||||
EmailMessage(
|
||||
subject=subject,
|
||||
body=body.format(
|
||||
full_name=v.full_name,
|
||||
election_url=url,
|
||||
start=start,
|
||||
end=end,
|
||||
username=v.base_username,
|
||||
password=password,
|
||||
),
|
||||
to=[v.email],
|
||||
reply_to=[reply_to],
|
||||
# On modifie l'adresse de retour d'erreur
|
||||
headers={"From": "Kadenios <klub-dev@ens.fr>"},
|
||||
EmailMessage(
|
||||
subject=mail_form.cleaned_data["objet"],
|
||||
body=mail_form.cleaned_data["message"].format(
|
||||
full_name=v.full_name,
|
||||
election_url=url,
|
||||
username=v.base_username,
|
||||
password=password,
|
||||
),
|
||||
v,
|
||||
to=[v.email],
|
||||
)
|
||||
)
|
||||
|
||||
for m, v in messages:
|
||||
try:
|
||||
m.send()
|
||||
v.has_valid_email = True
|
||||
except smtplib.SMTPException:
|
||||
v.has_valid_email = False
|
||||
|
||||
v.save()
|
||||
get_connection(fail_silently=False).send_messages(messages)
|
||||
User.objects.bulk_update(voters, ["password"])
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import csv
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.mail import EmailMessage
|
||||
from django.db import transaction
|
||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import (
|
||||
CreateView,
|
||||
DetailView,
|
||||
|
@ -18,10 +20,9 @@ from django.views.generic import (
|
|||
UpdateView,
|
||||
View,
|
||||
)
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
|
||||
from elections.typing import AuthenticatedRequest
|
||||
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
|
||||
from shared.views import BackgroundUpdateView, TimeMixin
|
||||
from shared.views import BackgroundUpdateView
|
||||
|
||||
from .forms import (
|
||||
DeleteVoteForm,
|
||||
|
@ -41,15 +42,9 @@ from .mixins import (
|
|||
)
|
||||
from .models import Election, Option, Question, Vote
|
||||
from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES
|
||||
from .tasks import pseudonimize_election, send_election_mail
|
||||
from .utils import create_users
|
||||
from .utils import create_users, send_mail
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from elections.typing import User
|
||||
else:
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
User = get_user_model()
|
||||
|
||||
# TODO: access control *everywhere*
|
||||
|
||||
|
@ -59,8 +54,6 @@ else:
|
|||
|
||||
|
||||
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
||||
object: Election
|
||||
|
||||
model = Election
|
||||
form_class = ElectionForm
|
||||
success_message = _("Élection créée avec succès !")
|
||||
|
@ -69,7 +62,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
|||
def get_success_url(self):
|
||||
return reverse("election.admin", args=[self.object.pk])
|
||||
|
||||
def form_valid(self, form: ElectionForm):
|
||||
def form_valid(self, form):
|
||||
# We need to add the short name and the creator od the election
|
||||
form.instance.short_name = slugify(
|
||||
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
|
||||
|
@ -79,26 +72,7 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
|||
return super().form_valid(form)
|
||||
|
||||
|
||||
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
|
||||
|
||||
class ElectionAdminView(CreatorOnlyMixin, DetailView):
|
||||
model = Election
|
||||
template_name = "elections/election_admin.html"
|
||||
|
||||
|
@ -108,6 +82,7 @@ class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
|
|||
def get_context_data(self, **kwargs):
|
||||
kwargs.update(
|
||||
{
|
||||
"current_time": timezone.now(),
|
||||
"question_types": QUESTION_TYPES,
|
||||
"o_form": OptionForm,
|
||||
"q_form": QuestionForm,
|
||||
|
@ -119,19 +94,7 @@ class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
|
|||
return super().get_queryset().prefetch_related("questions__options")
|
||||
|
||||
|
||||
class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
|
||||
model = Election
|
||||
pattern_name = "election.admin"
|
||||
success_message = _("Élection visible !")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.election: Election = self.get_object()
|
||||
self.election.visible = True
|
||||
self.election.save()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ExportVotersView(CreatorOnlyMixin, View):
|
||||
class ExportVotersView(CreatorOnlyMixin, SingleObjectMixin, View):
|
||||
model = Election
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
@ -142,7 +105,7 @@ class ExportVotersView(CreatorOnlyMixin, View):
|
|||
writer.writerow(["Nom", "login"])
|
||||
|
||||
for v in self.get_object().voters.all():
|
||||
writer.writerow([v.full_name, v.base_username])
|
||||
writer.writerow([v.full_name, v.username])
|
||||
|
||||
return response
|
||||
|
||||
|
@ -151,7 +114,7 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi
|
|||
model = Election
|
||||
form_class = UploadVotersForm
|
||||
success_message = _("Liste de votant·e·s importée avec succès !")
|
||||
template_name = "elections/election_upload_voters.html"
|
||||
template_name = "elections/upload_voters.html"
|
||||
|
||||
def get_queryset(self):
|
||||
# On ne peut ajouter une liste d'électeurs que sur une élection restreinte
|
||||
|
@ -184,8 +147,8 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi
|
|||
class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView):
|
||||
model = Election
|
||||
form_class = VoterMailForm
|
||||
success_message = _("Mail d'annonce en cours d'envoi !")
|
||||
template_name = "elections/election_mail_voters.html"
|
||||
success_message = _("Mail d'annonce envoyé avec succès !")
|
||||
template_name = "elections/mail_voters.html"
|
||||
|
||||
def get_queryset(self):
|
||||
# On ne peut envoyer un mail que sur une élection restreinte qui n'a pas
|
||||
|
@ -207,14 +170,9 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView
|
|||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object.sent_mail = None
|
||||
self.object.sent_mail = True
|
||||
send_mail(self.object, form)
|
||||
self.object.save()
|
||||
send_election_mail(
|
||||
election_pk=self.object.pk,
|
||||
subject=form.cleaned_data["objet"],
|
||||
body=form.cleaned_data["message"],
|
||||
reply_to=self.request.user.email,
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
|
@ -241,42 +199,64 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
|
|||
return super().form_valid(form)
|
||||
|
||||
|
||||
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
|
||||
voter: User
|
||||
|
||||
class DeleteVoteView(ClosedElectionMixin, FormView):
|
||||
model = Election
|
||||
template_name = "elections/delete_vote.html"
|
||||
form_class = DeleteVoteForm
|
||||
|
||||
def get_message(self):
|
||||
return {
|
||||
"content": _("Vote de {} supprimé !").format(self.voter.full_name),
|
||||
"class": "success",
|
||||
}
|
||||
def get_success_url(self):
|
||||
return reverse("election.voters", args=[self.object.pk]) + "#v_{anchor}".format(
|
||||
**self.kwargs
|
||||
)
|
||||
|
||||
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
|
||||
def get(self, request, *args, **kwargs):
|
||||
election = self.get_object()
|
||||
self.voter = User.objects.get(pk=self.kwargs["user_pk"])
|
||||
def form_valid(self, form):
|
||||
if form.cleaned_data["delete"] == "oui":
|
||||
# On envoie un mail à la personne lui indiquant que le vote est supprimé
|
||||
EmailMessage(
|
||||
subject="Vote removed",
|
||||
body=MAIL_VOTE_DELETED.format(
|
||||
full_name=self.voter.full_name,
|
||||
election_name=self.object.name,
|
||||
),
|
||||
to=[self.voter.email],
|
||||
).send()
|
||||
|
||||
# On envoie un mail à la personne lui indiquant que le vote est supprimé
|
||||
EmailMessage(
|
||||
subject="Vote removed",
|
||||
body=MAIL_VOTE_DELETED.format(
|
||||
full_name=self.voter.full_name,
|
||||
election_name=election.name,
|
||||
),
|
||||
to=[self.voter.email],
|
||||
).send()
|
||||
# On supprime les votes
|
||||
Vote.objects.filter(
|
||||
user=self.voter,
|
||||
option__question__election=self.object,
|
||||
).delete()
|
||||
|
||||
# On supprime les votes
|
||||
Vote.objects.filter(
|
||||
user=self.voter,
|
||||
option__question__election=election,
|
||||
).delete()
|
||||
# On marque les questions comme non votées
|
||||
self.voter.cast_elections.remove(self.object)
|
||||
self.voter.cast_questions.remove(*list(self.object.questions.all()))
|
||||
|
||||
# On marque les questions comme non votées
|
||||
self.voter.cast_elections.remove(election)
|
||||
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):
|
||||
|
@ -298,11 +278,7 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
|
|||
q.tally()
|
||||
|
||||
election.tallied = True
|
||||
election.time_tallied = timezone.now()
|
||||
election.save()
|
||||
|
||||
pseudonimize_election(election.pk)
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
|
@ -318,29 +294,10 @@ class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView):
|
|||
def get(self, request, *args, **kwargs):
|
||||
self.election = self.get_object()
|
||||
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()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class DownloadResultsView(CreatorOnlyMixin, View):
|
||||
model = Election
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(tallied=True)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
content = "\n".join([q.results for q in self.get_object().questions.all()])
|
||||
|
||||
response = HttpResponse(content, content_type="text/plain")
|
||||
response["Content-Disposition"] = "attachment; filename=results.txt"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
|
||||
model = Election
|
||||
pattern_name = "election.admin"
|
||||
|
@ -358,27 +315,46 @@ class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
|
|||
# #############################################################################
|
||||
|
||||
|
||||
class CreateQuestionView(CreatorOnlyEditMixin, TimeMixin, JsonCreateView):
|
||||
@method_decorator(require_POST, name="dispatch")
|
||||
class AddQuestionView(CreatorOnlyEditMixin, CreateView):
|
||||
model = Election
|
||||
form_class = QuestionForm
|
||||
context_object_name = "q"
|
||||
template_name = "elections/admin/question.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("election.admin", args=[self.election.pk]) + "#q_add"
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.election = self.get_object()
|
||||
self.election = self.get_object()
|
||||
# On ajoute l'élection voulue à la question créée
|
||||
form.instance.election = self.election
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class UpdateQuestionView(CreatorOnlyEditMixin, TimeMixin, JsonUpdateView):
|
||||
class ModQuestionView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
|
||||
model = Question
|
||||
form_class = QuestionForm
|
||||
context_object_name = "q"
|
||||
template_name = "elections/admin/question.html"
|
||||
success_message = _("Question modifiée avec succès !")
|
||||
template_name = "elections/question_update.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return (
|
||||
reverse("election.admin", args=[self.object.election.pk])
|
||||
+ f"#q_{self.object.pk}"
|
||||
)
|
||||
|
||||
|
||||
class DeleteQuestionView(CreatorOnlyEditMixin, JsonDeleteView):
|
||||
class DelQuestionView(CreatorOnlyEditMixin, BackgroundUpdateView):
|
||||
model = Question
|
||||
message = _("Question supprimée !")
|
||||
success_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)
|
||||
|
||||
|
||||
# #############################################################################
|
||||
|
@ -386,27 +362,49 @@ class DeleteQuestionView(CreatorOnlyEditMixin, JsonDeleteView):
|
|||
# #############################################################################
|
||||
|
||||
|
||||
class CreateOptionView(CreatorOnlyEditMixin, TimeMixin, JsonCreateView):
|
||||
@method_decorator(require_POST, name="dispatch")
|
||||
class AddOptionView(CreatorOnlyEditMixin, CreateView):
|
||||
model = Question
|
||||
form_class = OptionForm
|
||||
context_object_name = "o"
|
||||
template_name = "elections/admin/option.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return (
|
||||
reverse("election.admin", args=[self.question.election.pk])
|
||||
+ f"#q_{self.question.pk}"
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.question = self.get_object()
|
||||
self.question = self.get_object()
|
||||
# On ajoute l'élection voulue à la question créée
|
||||
form.instance.question = self.question
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class UpdateOptionView(CreatorOnlyEditMixin, TimeMixin, JsonUpdateView):
|
||||
class ModOptionView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
|
||||
model = Option
|
||||
form_class = OptionForm
|
||||
context_object_name = "o"
|
||||
template_name = "elections/admin/option.html"
|
||||
success_message = _("Option modifiée avec succès !")
|
||||
template_name = "elections/option_update.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return (
|
||||
reverse("election.admin", args=[self.object.question.election.pk])
|
||||
+ f"#o_{self.object.pk}"
|
||||
)
|
||||
|
||||
|
||||
class DeleteOptionView(CreatorOnlyEditMixin, JsonDeleteView):
|
||||
class DelOptionView(CreatorOnlyEditMixin, BackgroundUpdateView):
|
||||
model = Option
|
||||
message = _("Option supprimée !")
|
||||
success_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)
|
||||
|
||||
|
||||
# #############################################################################
|
||||
|
@ -431,7 +429,7 @@ class ElectionView(NotArchivedMixin, DetailView):
|
|||
context = super().get_context_data(**kwargs)
|
||||
context["current_time"] = timezone.now()
|
||||
|
||||
if user.is_authenticated and isinstance(user, User):
|
||||
if user.is_authenticated:
|
||||
context["can_vote"] = user.can_vote(self.request, context["election"])
|
||||
context["cast_questions"] = user.cast_questions.all()
|
||||
context["has_voted"] = user.cast_elections.filter(
|
||||
|
@ -459,11 +457,10 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
|
|||
election = context["election"]
|
||||
voters = list(election.voters.all())
|
||||
|
||||
if user.is_authenticated and isinstance(user, User):
|
||||
context["can_vote"] = user.can_vote(self.request, context["election"])
|
||||
context["is_admin"] = user.is_admin(election)
|
||||
if user.is_authenticated:
|
||||
can_delete = (
|
||||
election.created_by == user
|
||||
not election.restricted
|
||||
and election.created_by == user
|
||||
and election.end_date < timezone.now()
|
||||
and not election.tallied
|
||||
)
|
||||
|
@ -471,7 +468,6 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
|
|||
context["d_form"] = DeleteVoteForm()
|
||||
|
||||
context["can_delete"] = can_delete
|
||||
context["from_admin"] = self.request.GET.get("prev") == "admin"
|
||||
context["voters"] = voters
|
||||
|
||||
return context
|
||||
|
@ -485,14 +481,12 @@ class ElectionBallotsView(NotArchivedMixin, DetailView):
|
|||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(results_public=True, tallied=True)
|
||||
.filter(tallied=True)
|
||||
.prefetch_related("questions__options")
|
||||
)
|
||||
|
||||
|
||||
class VoteView(OpenElectionOnlyMixin, DetailView):
|
||||
request: AuthenticatedRequest
|
||||
|
||||
model = Question
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
from translated_fields import language_code_formfield_callback
|
||||
|
||||
from django import forms
|
||||
|
||||
from .models import Faq
|
||||
|
||||
|
||||
class FaqForm(forms.ModelForm):
|
||||
formfield_callback = language_code_formfield_callback
|
||||
|
||||
class Meta:
|
||||
model = Faq
|
||||
fields = [
|
||||
*Faq.title.fields,
|
||||
"anchor",
|
||||
*Faq.description.fields,
|
||||
*Faq.content.fields,
|
||||
]
|
||||
widgets = {
|
||||
"description_en": forms.Textarea(
|
||||
attrs={"rows": 4, "class": "is-family-monospace"}
|
||||
),
|
||||
"description_fr": forms.Textarea(
|
||||
attrs={"rows": 4, "class": "is-family-monospace"}
|
||||
),
|
||||
"content_en": forms.Textarea(attrs={"class": "is-family-monospace"}),
|
||||
"content_fr": forms.Textarea(attrs={"class": "is-family-monospace"}),
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
# Generated by Django 3.2.4 on 2021-06-15 08:34
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Faq",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title_fr", models.CharField(max_length=255, verbose_name="titre")),
|
||||
(
|
||||
"title_en",
|
||||
models.CharField(blank=True, max_length=255, verbose_name="titre"),
|
||||
),
|
||||
("description_fr", models.TextField(verbose_name="description")),
|
||||
(
|
||||
"description_en",
|
||||
models.TextField(blank=True, verbose_name="description"),
|
||||
),
|
||||
("content_fr", models.TextField(blank=True, verbose_name="contenu")),
|
||||
("content_en", models.TextField(blank=True, verbose_name="contenu")),
|
||||
(
|
||||
"last_modified",
|
||||
models.DateField(auto_now=True, verbose_name="mise à jour"),
|
||||
),
|
||||
("anchor", models.CharField(max_length=20, verbose_name="ancre")),
|
||||
(
|
||||
"author",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="faqs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"permissions": [("is_author", "Can create faqs")],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="faq",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("anchor",), name="unique_faq_anchor"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# 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")]},
|
||||
),
|
||||
]
|
|
@ -1,14 +0,0 @@
|
|||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
|
||||
|
||||
class AdminOnlyMixin(PermissionRequiredMixin):
|
||||
"""Restreint l'accès aux admins"""
|
||||
|
||||
permission_required = "faqs.faq_admin"
|
||||
|
||||
|
||||
class CreatorOnlyMixin(AdminOnlyMixin):
|
||||
"""Restreint l'accès à l'auteur"""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(author=self.request.user)
|
|
@ -1,32 +0,0 @@
|
|||
from translated_fields import TranslatedFieldWithFallback
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Faq(models.Model):
|
||||
title = TranslatedFieldWithFallback(
|
||||
models.CharField(_("titre"), blank=False, max_length=255)
|
||||
)
|
||||
description = TranslatedFieldWithFallback(
|
||||
models.TextField(_("description"), blank=False)
|
||||
)
|
||||
content = TranslatedFieldWithFallback(models.TextField(_("contenu"), blank=True))
|
||||
|
||||
author = models.ForeignKey(
|
||||
User, related_name="faqs", null=True, on_delete=models.SET_NULL
|
||||
)
|
||||
last_modified = models.DateField(_("mise à jour"), auto_now=True)
|
||||
|
||||
anchor = models.CharField(_("ancre"), max_length=20)
|
||||
|
||||
class Meta:
|
||||
permissions = [
|
||||
("faq_admin", "Can create faqs"),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["anchor"], name="unique_faq_anchor")
|
||||
]
|
|
@ -1,43 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n markdown %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level">
|
||||
{# Titre de la FAQ #}
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<h1 class="title">{{ faq.title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
{# Date de dernière modification #}
|
||||
<div class="level-item">
|
||||
<span class="tag is-primary is-light is-outlined">{% blocktrans with maj=faq.last_modified|date:'d/m/Y' %}Mis à jour le {{ maj }}{% endblocktrans %}</span>
|
||||
</div>
|
||||
|
||||
{# Lien vers la page d'édition #}
|
||||
{% if faq.author == user %}
|
||||
<div class="level-item">
|
||||
<a class="button has-tooltip-primary" href="{% url 'faq.edit' faq.anchor %}" data-tooltip="{% trans "Modifier" %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{# Description #}
|
||||
<div class="message is-primary">
|
||||
<div class="message-body content">{{ faq.description|markdown|safe }}</div>
|
||||
</div>
|
||||
|
||||
{# Contenu #}
|
||||
<div class="content">
|
||||
{{ faq.content|markdown|safe }}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -1,20 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="notification is-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<h1 class="title">{% trans "Nouvelle FAQ" %}</h1>
|
||||
<hr>
|
||||
|
||||
{% url 'faq.list' as r_url %}
|
||||
{% include "forms/common-form.html" with c_size="is-9" errors=False %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="notification is-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<h1 class="title">{% trans "Modification de la FAQ" %}</h1>
|
||||
<hr>
|
||||
|
||||
{% url 'faq.view' faq.anchor as r_url %}
|
||||
{% include "forms/common-form.html" with c_size="is-9" errors=False %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n markdown %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<h1 class="title">{% trans "Liste des FAQ" %}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if perms.faqs.faq_admin %}
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<a class="button is-light is-outlined is-primary" href={% url 'faq.create' %}>
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<span>{% trans "Créer une FAQ" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{% for f in faq_list %}
|
||||
<div class="panel is-primary is-radiusless">
|
||||
<div class="panel-heading is-size-6 is-radiusless">
|
||||
<a class="has-text-primary-light" href="{% url 'faq.view' f.anchor %}"><u>{{ f.title }}</u></a>
|
||||
</div>
|
||||
|
||||
{% if f.description %}
|
||||
<div class="panel-block">
|
||||
<div class="content is-flex-grow-1">
|
||||
{{ f.description|markdown|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not forloop.last %}
|
||||
<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
12
faqs/urls.py
12
faqs/urls.py
|
@ -1,12 +0,0 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Admin views
|
||||
path("create", views.FaqCreateView.as_view(), name="faq.create"),
|
||||
path("edit/<slug:slug>", views.FaqEditView.as_view(), name="faq.edit"),
|
||||
# Public views
|
||||
path("", views.FaqListView.as_view(), name="faq.list"),
|
||||
path("view/<slug:slug>", views.FaqView.as_view(), name="faq.view"),
|
||||
]
|
|
@ -1,54 +0,0 @@
|
|||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView, ListView, UpdateView
|
||||
|
||||
from .forms import FaqForm
|
||||
from .mixins import AdminOnlyMixin, CreatorOnlyMixin
|
||||
from .models import Faq
|
||||
|
||||
# #############################################################################
|
||||
# Administration Views
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class FaqCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
||||
model = Faq
|
||||
form_class = FaqForm
|
||||
success_message = _("Faq créée avec succès !")
|
||||
template_name = "faqs/faq_create.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("faq.view", args=[self.object.anchor])
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.author = self.request.user
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class FaqEditView(CreatorOnlyMixin, SuccessMessageMixin, UpdateView):
|
||||
model = Faq
|
||||
form_class = FaqForm
|
||||
slug_field = "anchor"
|
||||
success_message = _("Faq modifiée avec succès !")
|
||||
template_name = "faqs/faq_edit.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("faq.view", args=[self.object.anchor])
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Public Views
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class FaqListView(ListView):
|
||||
model = Faq
|
||||
template_name = "faqs/faq_list.html"
|
||||
|
||||
|
||||
class FaqView(DetailView):
|
||||
model = Faq
|
||||
template_name = "faqs/faq.html"
|
||||
slug_field = "anchor"
|
5
kadenios/apps.py
Normal file
5
kadenios/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
||||
|
||||
|
||||
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
|
||||
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]
|
1
kadenios/settings/.gitignore
vendored
Normal file
1
kadenios/settings/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
secret.py
|
153
kadenios/settings/common.py
Normal file
153
kadenios/settings/common.py
Normal file
|
@ -0,0 +1,153 @@
|
|||
"""
|
||||
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",
|
||||
"shared",
|
||||
"elections",
|
||||
"petitions",
|
||||
"authens",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "kadenios.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "kadenios.wsgi.application"
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
DEFAULT_FROM_EMAIL = "Kadenios <klub-dev@ens.fr>"
|
||||
|
||||
SERVER_DOMAIN = "vote.eleves.ens.fr"
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres d'authentification
|
||||
# #############################################################################
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "elections.User"
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"shared.auth.backends.PwdBackend",
|
||||
"shared.auth.backends.CASBackend",
|
||||
"shared.auth.backends.ElectionBackend",
|
||||
]
|
||||
|
||||
LOGIN_URL = reverse_lazy("authens:login")
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
AUTHENS_USE_OLDCAS = False
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres de langage
|
||||
# #############################################################################
|
||||
|
||||
LANGUAGE_CODE = "fr-fr"
|
||||
TIME_ZONE = "Europe/Paris"
|
||||
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
USE_TZ = True
|
||||
|
||||
LANGUAGES = [
|
||||
("fr", _("Français")),
|
||||
("en", _("Anglais")),
|
||||
]
|
||||
|
||||
LOCALE_PATHS = [os.path.join(BASE_DIR, "shared", "locale")]
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres des fichiers statiques
|
||||
# #############################################################################
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [BASE_DIR + "/shared/static"]
|
55
kadenios/settings/local.py
Normal file
55
kadenios/settings/local.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
"""
|
||||
Paramètre pour le développement local
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from .common import * # noqa
|
||||
from .common import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres Django
|
||||
# #############################################################################
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
DEBUG = True
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
|
||||
}
|
||||
}
|
||||
|
||||
# Use the default cache backend for local development
|
||||
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
# Pas besoin de sécurité en local
|
||||
AUTH_PASSWORD_VALIDATORS = []
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres pour la Django Debug Toolbar
|
||||
# #############################################################################
|
||||
|
||||
|
||||
def show_toolbar(request):
|
||||
"""
|
||||
On active la debug-toolbar en mode développement local sauf :
|
||||
- dans l'admin où ça ne sert pas à grand chose;
|
||||
- si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver
|
||||
sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal
|
||||
qui lance `./manage.py runserver`.
|
||||
"""
|
||||
env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None))
|
||||
return DEBUG and not env_no_ddt and not request.path.startswith("/admin/")
|
||||
|
||||
|
||||
if not TESTING:
|
||||
INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"]
|
||||
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
|
||||
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar}
|
68
kadenios/settings/prod.py
Normal file
68
kadenios/settings/prod.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
"""
|
||||
Paramètres pour la mise en production
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from .common import * # noqa
|
||||
from .common import BASE_DIR, import_secret
|
||||
|
||||
# #############################################################################
|
||||
# Secrets de production
|
||||
# #############################################################################
|
||||
|
||||
REDIS_PASSWD = import_secret("REDIS_PASSWD")
|
||||
REDIS_DB = import_secret("REDIS_DB")
|
||||
REDIS_HOST = import_secret("REDIS_HOST")
|
||||
REDIS_PORT = import_secret("REDIS_PORT")
|
||||
|
||||
DBNAME = import_secret("DBNAME")
|
||||
DBUSER = import_secret("DBUSER")
|
||||
DBPASSWD = import_secret("DBPASSWD")
|
||||
|
||||
# #############################################################################
|
||||
# À modifier possiblement lors de la mise en production
|
||||
# #############################################################################
|
||||
|
||||
ALLOWED_HOSTS = ["vote.eleves.ens.fr"]
|
||||
|
||||
STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static")
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres du cache
|
||||
# #############################################################################
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "redis://:{passwd}@{host}:{port}/{db}".format(
|
||||
passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres de la base de données
|
||||
# #############################################################################
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"NAME": DBNAME,
|
||||
"USER": DBUSER,
|
||||
"PASSWORD": DBPASSWD,
|
||||
"HOST": os.environ.get("DBHOST", ""),
|
||||
}
|
||||
}
|
||||
|
||||
# #############################################################################
|
||||
# Paramètres Https
|
||||
# #############################################################################
|
||||
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
14
kadenios/settings/secret_example.py
Normal file
14
kadenios/settings/secret_example.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
SECRET_KEY = "f*!6tw8c74)&k_&4$toiw@e=8m00xv_(tmjf9_#wq30wg_7n^8"
|
||||
ADMINS = None
|
||||
SERVER_EMAIL = "root@localhost"
|
||||
EMAIL_HOST = None
|
||||
|
||||
|
||||
DBUSER = "kadenios"
|
||||
DBNAME = "kadenios"
|
||||
DBPASSWD = "O1LxCADDA6Px5SiKvifjvdp3DSjfbp"
|
||||
|
||||
REDIS_PASSWD = "dummy"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_DB = 0
|
||||
REDIS_HOST = "127.0.0.1"
|
|
@ -1,5 +1,4 @@
|
|||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
|
@ -7,19 +6,14 @@ from .views import HomeView
|
|||
|
||||
urlpatterns = [
|
||||
path("", HomeView.as_view(), name="kadenios"),
|
||||
path("admin/", admin.site.urls),
|
||||
path("elections/", include("elections.urls")),
|
||||
path("faqs/", include("faqs.urls")),
|
||||
path("petitions/", include("petitions.urls")),
|
||||
path("auth/", include("shared.auth.urls")),
|
||||
path("authens/", include("authens.urls")),
|
||||
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:
|
||||
from debug_toolbar import urls as djdt_urls
|
||||
|
|
@ -11,6 +11,6 @@ import os
|
|||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kadenios.settings')
|
||||
|
||||
application = get_wsgi_application()
|
|
@ -5,7 +5,7 @@ import sys
|
|||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kadenios.settings.local")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
# Generated by npins. Do not modify; will be overwritten regularly
|
||||
let
|
||||
data = builtins.fromJSON (builtins.readFile ./sources.json);
|
||||
version = data.version;
|
||||
|
||||
mkSource =
|
||||
spec:
|
||||
assert spec ? type;
|
||||
let
|
||||
path =
|
||||
if spec.type == "Git" then
|
||||
mkGitSource spec
|
||||
else if spec.type == "GitRelease" then
|
||||
mkGitSource spec
|
||||
else if spec.type == "PyPi" then
|
||||
mkPyPiSource spec
|
||||
else if spec.type == "Channel" then
|
||||
mkChannelSource spec
|
||||
else
|
||||
builtins.throw "Unknown source type ${spec.type}";
|
||||
in
|
||||
spec // { outPath = path; };
|
||||
|
||||
mkGitSource =
|
||||
{
|
||||
repository,
|
||||
revision,
|
||||
url ? null,
|
||||
hash,
|
||||
branch ? null,
|
||||
...
|
||||
}:
|
||||
assert repository ? type;
|
||||
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
|
||||
# In the latter case, there we will always be an url to the tarball
|
||||
if url != null then
|
||||
(builtins.fetchTarball {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
})
|
||||
else
|
||||
assert repository.type == "Git";
|
||||
let
|
||||
urlToName =
|
||||
url: rev:
|
||||
let
|
||||
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
|
||||
|
||||
short = builtins.substring 0 7 rev;
|
||||
|
||||
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
|
||||
in
|
||||
"${if matched == null then "source" else builtins.head matched}${appendShort}";
|
||||
name = urlToName repository.url revision;
|
||||
in
|
||||
builtins.fetchGit {
|
||||
url = repository.url;
|
||||
rev = revision;
|
||||
inherit name;
|
||||
narHash = hash;
|
||||
};
|
||||
|
||||
mkPyPiSource =
|
||||
{ url, hash, ... }:
|
||||
builtins.fetchurl {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
};
|
||||
|
||||
mkChannelSource =
|
||||
{ url, hash, ... }:
|
||||
builtins.fetchTarball {
|
||||
inherit url;
|
||||
sha256 = hash;
|
||||
};
|
||||
in
|
||||
if version == 4 then
|
||||
builtins.mapAttrs (_: mkSource) data.pins
|
||||
else
|
||||
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"pins": {
|
||||
"nix-pkgs": {
|
||||
"type": "Git",
|
||||
"repository": {
|
||||
"type": "Git",
|
||||
"url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs.git"
|
||||
},
|
||||
"branch": "main",
|
||||
"revision": "22e90684e355bdd1e257c661b6275c7490f8c50b",
|
||||
"url": null,
|
||||
"hash": "sha256-yEZAv3bK7+gxNM8/31ONwdPIXlyQ5QnNnPDnWl3bXZo="
|
||||
},
|
||||
"nixpkgs": {
|
||||
"type": "Channel",
|
||||
"name": "nixpkgs-unstable",
|
||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre646460.0aeab749216e/nixexprs.tar.xz",
|
||||
"hash": "0xa73bs0n28x731hf6ipqrlji0p3qf2a42vfm6g8snnhaab9mfwj"
|
||||
}
|
||||
},
|
||||
"version": 4
|
||||
}
|
6
petitions/apps.py
Normal file
6
petitions/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PetitionsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "petitions"
|
68
petitions/forms.py
Normal file
68
petitions/forms.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
from translated_fields import language_code_formfield_callback
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import Petition, Signature
|
||||
|
||||
|
||||
class PetitionForm(forms.ModelForm):
|
||||
formfield_callback = language_code_formfield_callback
|
||||
|
||||
class Meta:
|
||||
model = Petition
|
||||
fields = [
|
||||
*Petition.title.fields,
|
||||
*Petition.text.fields,
|
||||
*Petition.letter.fields,
|
||||
"launch_date",
|
||||
]
|
||||
widgets = {
|
||||
"text_en": forms.Textarea(attrs={"rows": 3}),
|
||||
"text_fr": forms.Textarea(attrs={"rows": 4}),
|
||||
"letter_en": forms.Textarea(attrs={"rows": 3}),
|
||||
"letter_fr": forms.Textarea(attrs={"rows": 4}),
|
||||
}
|
||||
|
||||
|
||||
class DeleteForm(forms.Form):
|
||||
def __init__(self, **kwargs):
|
||||
signature = kwargs.pop("signature", None)
|
||||
super().__init__(**kwargs)
|
||||
if signature is not None:
|
||||
self.fields["delete"].label = _("Supprimer la signature de {} ?").format(
|
||||
signature.full_name
|
||||
)
|
||||
|
||||
delete = forms.ChoiceField(
|
||||
label=_("Supprimer"), choices=(("non", _("Non")), ("oui", _("Oui")))
|
||||
)
|
||||
|
||||
|
||||
class ValidateForm(forms.Form):
|
||||
def __init__(self, **kwargs):
|
||||
signature = kwargs.pop("signature", None)
|
||||
super().__init__(**kwargs)
|
||||
if signature is not None:
|
||||
self.fields["validate"].label = _("Valider la signature de {} ?").format(
|
||||
signature.full_name
|
||||
)
|
||||
|
||||
validate = forms.ChoiceField(
|
||||
label=_("Valider"), choices=(("non", _("Non")), ("oui", _("Oui")))
|
||||
)
|
||||
|
||||
|
||||
class SignatureForm(forms.ModelForm):
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"]
|
||||
if self.instance.petition.signatures.filter(email__iexact=email):
|
||||
self.add_error(
|
||||
"email",
|
||||
_("Une personne a déjà signé la pétition avec cette adresse mail."),
|
||||
)
|
||||
return email
|
||||
|
||||
class Meta:
|
||||
model = Signature
|
||||
fields = ["full_name", "email", "status", "department", "elected"]
|
262
petitions/migrations/0001_initial.py
Normal file
262
petitions/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,262 @@
|
|||
# Generated by Django 3.2.3 on 2021-05-29 20:34
|
||||
|
||||
import datetime
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Petition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title_fr", models.CharField(max_length=255, verbose_name="titre")),
|
||||
(
|
||||
"title_en",
|
||||
models.CharField(blank=True, max_length=255, verbose_name="titre"),
|
||||
),
|
||||
("text_fr", models.TextField(blank=True, verbose_name="texte")),
|
||||
("text_en", models.TextField(blank=True, verbose_name="texte")),
|
||||
("letter_fr", models.TextField(blank=True, verbose_name="lettre")),
|
||||
("letter_en", models.TextField(blank=True, verbose_name="lettre")),
|
||||
(
|
||||
"launch_date",
|
||||
models.DateField(
|
||||
default=datetime.date.today, verbose_name="date d'ouverture"
|
||||
),
|
||||
),
|
||||
(
|
||||
"archived",
|
||||
models.BooleanField(default=False, verbose_name="archivée"),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="petitions_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-launch_date"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Signature",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"full_name",
|
||||
models.CharField(max_length=255, verbose_name="nom complet"),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(max_length=254, verbose_name="adresse mail"),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("normalien-license", "Normalien·ne en licence"),
|
||||
("normalien-master", "Normalien·ne en master"),
|
||||
("normalien-cesure", "Normalien·ne en césure"),
|
||||
("normalien-pre-these", "Normalien·ne en pré-thèse"),
|
||||
(
|
||||
"normalien-concours",
|
||||
"Normalien·ne préparant un concours (Agrégation, ENA...)",
|
||||
),
|
||||
(
|
||||
"normalien-stage",
|
||||
"Normalien·ne en stage ou en année de formation complémentaire",
|
||||
),
|
||||
(
|
||||
"normalien-administration",
|
||||
"Normalien·ne dans l'administration publique",
|
||||
),
|
||||
("normalien-entreprise", "Normalien·ne dans l'entreprise"),
|
||||
(
|
||||
"normalien-chercheur",
|
||||
"Normalien·ne et chercheur·se en Université",
|
||||
),
|
||||
("masterien", "Mastérien·ne"),
|
||||
("these", "Doctorant·e"),
|
||||
("postdoc", "Post-doctorant·e"),
|
||||
("archicube", "Ancien·ne élève ou étudiant·e"),
|
||||
("chercheur-ens", "Chercheur·se à l’ENS"),
|
||||
("enseignant-ens", "Enseignant·e à l’ENS"),
|
||||
(
|
||||
"enseignant-chercheur",
|
||||
"Enseignant·e et chercheur·se à l’ENS",
|
||||
),
|
||||
("enseignant-cpge", "Enseignant·e en classe préparatoire"),
|
||||
("charge-td", "Chargé·e de TD"),
|
||||
("direction-ens", "Membre de la direction de l'ENS"),
|
||||
(
|
||||
"direction-departement",
|
||||
"Membre de la direction d'un département",
|
||||
),
|
||||
(
|
||||
"directeur",
|
||||
"Directeur·rice de l'Ecole Normale Supérieure",
|
||||
),
|
||||
(
|
||||
"employe-cost",
|
||||
"Employé·e du Service des Concours, de la Scolarité et des Thèses",
|
||||
),
|
||||
(
|
||||
"employe-srh",
|
||||
"Employé·e du Service des Ressources Humaines",
|
||||
),
|
||||
(
|
||||
"employe-spr",
|
||||
"Employé·e du Service Partenariat de la Recherche",
|
||||
),
|
||||
(
|
||||
"employe-sfc",
|
||||
"Employé·e du Service Financier et Comptable",
|
||||
),
|
||||
(
|
||||
"employe-cri",
|
||||
"Employé·e du Centre de Ressources Informatiques",
|
||||
),
|
||||
("employe-sp", "Employé·e du Service Patrimoine"),
|
||||
(
|
||||
"employe-sps",
|
||||
"Employé·e du Service Prévention et Sécurité",
|
||||
),
|
||||
("employe-sl", "Employé·e du Service Logistique"),
|
||||
("employe-sr", "Employé·e du Service de la Restauration"),
|
||||
("employe-ps", "Employé·e du Pôle Santé"),
|
||||
(
|
||||
"employe-spi",
|
||||
"Employé·e du Service de Prestations Informatiques",
|
||||
),
|
||||
(
|
||||
"employe-bibliotheque",
|
||||
"Employé·e d'une des bibliothèques",
|
||||
),
|
||||
(
|
||||
"employe-exterieur",
|
||||
"Employé·e d'une société prestataire de service à l'ENS",
|
||||
),
|
||||
("pei", "Élève du PEI"),
|
||||
("autre", "Autre"),
|
||||
],
|
||||
max_length=24,
|
||||
verbose_name="statut",
|
||||
),
|
||||
),
|
||||
(
|
||||
"elected",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("", "Aucun"),
|
||||
("dg", "Membre de la Délégation Générale"),
|
||||
("cof", "Membre du bureau du COF"),
|
||||
("bda", "Membre du bureau du BdA"),
|
||||
("bds", "Membre du bureau du BDS"),
|
||||
("cs", "Membre du Conseil Scientifique"),
|
||||
("ca", "Membre du Conseil d'Administration"),
|
||||
("ce", "Membre de la Commission des Études"),
|
||||
("chsct", "Membre du CHSCT"),
|
||||
],
|
||||
default="",
|
||||
max_length=5,
|
||||
verbose_name="poste d'élu",
|
||||
),
|
||||
),
|
||||
(
|
||||
"department",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("", "Aucun département"),
|
||||
("arts", "Département Arts"),
|
||||
("litteratures", "Département Littératures et langage"),
|
||||
("histoire", "Département d’Histoire"),
|
||||
("economie", "Département d’Économie"),
|
||||
("philosophie", "Département de Philosophie"),
|
||||
("sciences-sociales", "Département de Sciences Sociales"),
|
||||
("antiquite", "Département des Sciences de l’Antiquité"),
|
||||
("ecla", "Espace des cultures et langues d’ailleurs"),
|
||||
("geographie", "Département Géographie et Territoires"),
|
||||
("di", "Département d’Informatique"),
|
||||
("cognition", "Département d'Études cognitives"),
|
||||
("biologie", "Département de Biologie"),
|
||||
("chimie", "Département de Chimie"),
|
||||
("geosciences", "Département de Géosciences"),
|
||||
("math", "Département de Mathématiques et applications"),
|
||||
("phys", "Département de Physique"),
|
||||
(
|
||||
"environnement",
|
||||
"Centre de formation sur l’Environnement et la Société",
|
||||
),
|
||||
],
|
||||
default="",
|
||||
max_length=17,
|
||||
verbose_name="département",
|
||||
),
|
||||
),
|
||||
(
|
||||
"verified",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="adresse mail vérifiée"
|
||||
),
|
||||
),
|
||||
(
|
||||
"valid",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="signature vérifiée"
|
||||
),
|
||||
),
|
||||
(
|
||||
"timestamp",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True, verbose_name="jour de signature"
|
||||
),
|
||||
),
|
||||
(
|
||||
"petition",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="signatures",
|
||||
to="petitions.petition",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="signature",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("petition", "email"), name="unique_signature"
|
||||
),
|
||||
),
|
||||
]
|
20
petitions/migrations/0002_alter_petition_options.py
Normal file
20
petitions/migrations/0002_alter_petition_options.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
# Generated by Django 3.2.3 on 2021-05-29 20:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("petitions", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="petition",
|
||||
options={
|
||||
"ordering": ["-launch_date"],
|
||||
"permissions": [("is_admin", "Peut administrer des pétitions")],
|
||||
},
|
||||
),
|
||||
]
|
143
petitions/migrations/0003_auto_20210530_1740.py
Normal file
143
petitions/migrations/0003_auto_20210530_1740.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
# Generated by Django 3.2.3 on 2021-05-30 15:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("petitions", "0002_alter_petition_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="signature",
|
||||
name="department",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Aucun département"),
|
||||
("arts", "Département Arts"),
|
||||
("litteratures", "Département Littératures et langage"),
|
||||
("histoire", "Département d’Histoire"),
|
||||
("economie", "Département d’Économie"),
|
||||
("philosophie", "Département de Philosophie"),
|
||||
("sciences-sociales", "Département de Sciences Sociales"),
|
||||
("antiquite", "Département des Sciences de l’Antiquité"),
|
||||
("ecla", "Espace des cultures et langues d’ailleurs"),
|
||||
("geographie", "Département Géographie et Territoires"),
|
||||
("di", "Département d’Informatique"),
|
||||
("cognition", "Département d'Études cognitives"),
|
||||
("biologie", "Département de Biologie"),
|
||||
("chimie", "Département de Chimie"),
|
||||
("geosciences", "Département de Géosciences"),
|
||||
("math", "Département de Mathématiques et applications"),
|
||||
("phys", "Département de Physique"),
|
||||
(
|
||||
"environnement",
|
||||
"Centre de formation sur l’Environnement et la Société",
|
||||
),
|
||||
],
|
||||
default="",
|
||||
max_length=17,
|
||||
verbose_name="département",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="signature",
|
||||
name="elected",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "Aucun"),
|
||||
("dg", "Membre de la Délégation Générale"),
|
||||
("cof", "Membre du bureau du COF"),
|
||||
("bda", "Membre du bureau du BdA"),
|
||||
("bds", "Membre du bureau du BDS"),
|
||||
("cs", "Membre du Conseil Scientifique"),
|
||||
("ca", "Membre du Conseil d'Administration"),
|
||||
("ce", "Membre de la Commission des Études"),
|
||||
("chsct", "Membre du CHSCT"),
|
||||
],
|
||||
default="",
|
||||
max_length=5,
|
||||
verbose_name="poste d'élu",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="signature",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("autre", "Autre"),
|
||||
("normalien-license", "Normalien·ne en licence"),
|
||||
("normalien-master", "Normalien·ne en master"),
|
||||
("normalien-cesure", "Normalien·ne en césure"),
|
||||
("normalien-pre-these", "Normalien·ne en pré-thèse"),
|
||||
(
|
||||
"normalien-concours",
|
||||
"Normalien·ne préparant un concours (Agrégation, ENA...)",
|
||||
),
|
||||
(
|
||||
"normalien-stage",
|
||||
"Normalien·ne en stage ou en année de formation complémentaire",
|
||||
),
|
||||
(
|
||||
"normalien-administration",
|
||||
"Normalien·ne dans l'administration publique",
|
||||
),
|
||||
("normalien-entreprise", "Normalien·ne dans l'entreprise"),
|
||||
(
|
||||
"normalien-chercheur",
|
||||
"Normalien·ne et chercheur·se en Université",
|
||||
),
|
||||
("masterien", "Mastérien·ne"),
|
||||
("these", "Doctorant·e"),
|
||||
("postdoc", "Post-doctorant·e"),
|
||||
("archicube", "Ancien·ne élève ou étudiant·e"),
|
||||
("chercheur-ens", "Chercheur·se à l’ENS"),
|
||||
("enseignant-ens", "Enseignant·e à l’ENS"),
|
||||
("enseignant-chercheur", "Enseignant·e et chercheur·se à l’ENS"),
|
||||
("enseignant-cpge", "Enseignant·e en classe préparatoire"),
|
||||
("charge-td", "Chargé·e de TD"),
|
||||
("direction-ens", "Membre de la direction de l'ENS"),
|
||||
(
|
||||
"direction-departement",
|
||||
"Membre de la direction d'un département",
|
||||
),
|
||||
("directeur", "Directeur·rice de l'Ecole Normale Supérieure"),
|
||||
(
|
||||
"employe-cost",
|
||||
"Employé·e du Service des Concours, de la Scolarité et des Thèses",
|
||||
),
|
||||
("employe-srh", "Employé·e du Service des Ressources Humaines"),
|
||||
("employe-spr", "Employé·e du Service Partenariat de la Recherche"),
|
||||
("employe-sfc", "Employé·e du Service Financier et Comptable"),
|
||||
("employe-cri", "Employé·e du Centre de Ressources Informatiques"),
|
||||
("employe-sp", "Employé·e du Service Patrimoine"),
|
||||
("employe-sps", "Employé·e du Service Prévention et Sécurité"),
|
||||
("employe-sl", "Employé·e du Service Logistique"),
|
||||
("employe-sr", "Employé·e du Service de la Restauration"),
|
||||
("employe-ps", "Employé·e du Pôle Santé"),
|
||||
(
|
||||
"employe-spi",
|
||||
"Employé·e du Service de Prestations Informatiques",
|
||||
),
|
||||
("employe-bibliotheque", "Employé·e d'une des bibliothèques"),
|
||||
(
|
||||
"employe-exterieur",
|
||||
"Employé·e d'une société prestataire de service à l'ENS",
|
||||
),
|
||||
("pei", "Élève du PEI"),
|
||||
],
|
||||
default="autre",
|
||||
max_length=24,
|
||||
verbose_name="statut",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="signature",
|
||||
name="timestamp",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="horodatage"),
|
||||
),
|
||||
]
|
24
petitions/migrations/0004_signature_token.py
Normal file
24
petitions/migrations/0004_signature_token.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 3.2.3 on 2021-05-30 18:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import shared.utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("petitions", "0003_auto_20210530_1740"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="signature",
|
||||
name="token",
|
||||
field=models.SlugField(
|
||||
default=shared.utils.token_generator,
|
||||
editable=False,
|
||||
verbose_name="token",
|
||||
),
|
||||
),
|
||||
]
|
18
petitions/mixins.py
Normal file
18
petitions/mixins.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class AdminOnlyMixin(PermissionRequiredMixin):
|
||||
"""Restreint l'accès aux admins"""
|
||||
|
||||
permission_required = "petitions.is_admin"
|
||||
|
||||
|
||||
class CreatorOnlyMixin(AdminOnlyMixin):
|
||||
"""Restreint l'accès au créateurice de l'élection"""
|
||||
|
||||
def get_next_url(self):
|
||||
return reverse("kadenios")
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(created_by=self.request.user)
|
85
petitions/models.py
Normal file
85
petitions/models.py
Normal file
|
@ -0,0 +1,85 @@
|
|||
from datetime import date
|
||||
|
||||
from translated_fields import TranslatedFieldWithFallback
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from shared.utils import choices_length, token_generator
|
||||
|
||||
from .staticdefs import DEPARTMENTS, ELECTED, STATUSES
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# #############################################################################
|
||||
# Petition related models
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class Petition(models.Model):
|
||||
title = TranslatedFieldWithFallback(models.CharField(_("titre"), max_length=255))
|
||||
text = TranslatedFieldWithFallback(models.TextField(_("texte"), blank=True))
|
||||
letter = TranslatedFieldWithFallback(models.TextField(_("lettre"), blank=True))
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
User,
|
||||
related_name="petitions_created",
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
|
||||
launch_date = models.DateField(_("date d'ouverture"), default=date.today)
|
||||
|
||||
archived = models.BooleanField(_("archivée"), default=False)
|
||||
|
||||
class Meta:
|
||||
permissions = [
|
||||
("is_admin", _("Peut administrer des pétitions")),
|
||||
]
|
||||
ordering = ["-launch_date"]
|
||||
|
||||
|
||||
class Signature(models.Model):
|
||||
petition = models.ForeignKey(
|
||||
Petition, related_name="signatures", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("nom complet"), max_length=255)
|
||||
email = models.EmailField(_("adresse mail"))
|
||||
|
||||
status = models.CharField(
|
||||
_("statut"),
|
||||
choices=STATUSES,
|
||||
max_length=choices_length(STATUSES),
|
||||
default="autre",
|
||||
)
|
||||
elected = models.CharField(
|
||||
_("poste d'élu"),
|
||||
choices=ELECTED,
|
||||
max_length=choices_length(ELECTED),
|
||||
default="",
|
||||
blank=True,
|
||||
)
|
||||
department = models.CharField(
|
||||
_("département"),
|
||||
choices=DEPARTMENTS,
|
||||
max_length=choices_length(DEPARTMENTS),
|
||||
default="",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
verified = models.BooleanField(_("adresse mail vérifiée"), default=False)
|
||||
valid = models.BooleanField(_("signature vérifiée"), default=False)
|
||||
|
||||
token = models.SlugField(_("token"), editable=False, default=token_generator)
|
||||
|
||||
timestamp = models.DateTimeField(_("horodatage"), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["petition", "email"], name="unique_signature"
|
||||
)
|
||||
]
|
118
petitions/staticdefs.py
Normal file
118
petitions/staticdefs.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
DEPARTMENTS = [
|
||||
("", _("Aucun département")),
|
||||
("arts", _("Département Arts")),
|
||||
("litteratures", _("Département Littératures et langage")),
|
||||
("histoire", _("Département d’Histoire")),
|
||||
("economie", _("Département d’Économie")),
|
||||
("philosophie", _("Département de Philosophie")),
|
||||
("sciences-sociales", _("Département de Sciences Sociales")),
|
||||
("antiquite", _("Département des Sciences de l’Antiquité")),
|
||||
("ecla", _("Espace des cultures et langues d’ailleurs")),
|
||||
("geographie", _("Département Géographie et Territoires")),
|
||||
("di", _("Département d’Informatique")),
|
||||
("cognition", "Département d'Études cognitives"),
|
||||
("biologie", _("Département de Biologie")),
|
||||
("chimie", _("Département de Chimie")),
|
||||
("geosciences", _("Département de Géosciences")),
|
||||
("math", _("Département de Mathématiques et applications")),
|
||||
("phys", _("Département de Physique")),
|
||||
("environnement", _("Centre de formation sur l’Environnement et la Société")),
|
||||
]
|
||||
|
||||
STATUSES = [
|
||||
("autre", _("Autre")),
|
||||
("normalien-license", _("Normalien·ne en licence")),
|
||||
("normalien-master", _("Normalien·ne en master")),
|
||||
("normalien-cesure", _("Normalien·ne en césure")),
|
||||
("normalien-pre-these", _("Normalien·ne en pré-thèse")),
|
||||
(
|
||||
"normalien-concours",
|
||||
_("Normalien·ne préparant un concours (Agrégation, ENA...)"),
|
||||
),
|
||||
(
|
||||
"normalien-stage",
|
||||
_("Normalien·ne en stage ou en année de formation complémentaire"),
|
||||
),
|
||||
("normalien-administration", _("Normalien·ne dans l'administration publique")),
|
||||
("normalien-entreprise", _("Normalien·ne dans l'entreprise")),
|
||||
("normalien-chercheur", _("Normalien·ne et chercheur·se en Université")),
|
||||
("masterien", _("Mastérien·ne")),
|
||||
("these", _("Doctorant·e")),
|
||||
("postdoc", _("Post-doctorant·e")),
|
||||
("archicube", _("Ancien·ne élève ou étudiant·e")),
|
||||
("chercheur-ens", _("Chercheur·se à l’ENS")),
|
||||
("enseignant-ens", _("Enseignant·e à l’ENS")),
|
||||
("enseignant-chercheur", _("Enseignant·e et chercheur·se à l’ENS")),
|
||||
("enseignant-cpge", _("Enseignant·e en classe préparatoire")),
|
||||
("charge-td", _("Chargé·e de TD")),
|
||||
("direction-ens", _("Membre de la direction de l'ENS")),
|
||||
("direction-departement", _("Membre de la direction d'un département")),
|
||||
("directeur", _("Directeur·rice de l'Ecole Normale Supérieure")),
|
||||
(
|
||||
"employe-cost",
|
||||
_("Employé·e du Service des Concours, de la Scolarité et des Thèses"),
|
||||
),
|
||||
("employe-srh", _("Employé·e du Service des Ressources Humaines")),
|
||||
("employe-spr", _("Employé·e du Service Partenariat de la Recherche")),
|
||||
("employe-sfc", _("Employé·e du Service Financier et Comptable")),
|
||||
("employe-cri", _("Employé·e du Centre de Ressources Informatiques")),
|
||||
("employe-sp", _("Employé·e du Service Patrimoine")),
|
||||
("employe-sps", _("Employé·e du Service Prévention et Sécurité")),
|
||||
("employe-sl", _("Employé·e du Service Logistique")),
|
||||
("employe-sr", _("Employé·e du Service de la Restauration")),
|
||||
("employe-ps", _("Employé·e du Pôle Santé")),
|
||||
("employe-spi", _("Employé·e du Service de Prestations Informatiques")),
|
||||
("employe-bibliotheque", _("Employé·e d'une des bibliothèques")),
|
||||
("employe-exterieur", _("Employé·e d'une société prestataire de service à l'ENS")),
|
||||
("pei", _("Élève du PEI")),
|
||||
]
|
||||
|
||||
ELECTED = [
|
||||
("", _("Aucun")),
|
||||
("dg", _("Membre de la Délégation Générale")),
|
||||
("cof", _("Membre du bureau du COF")),
|
||||
("bda", _("Membre du bureau du BdA")),
|
||||
("bds", _("Membre du bureau du BDS")),
|
||||
("cs", _("Membre du Conseil Scientifique")),
|
||||
("ca", _("Membre du Conseil d'Administration")),
|
||||
("ce", _("Membre de la Commission des Études")),
|
||||
("chsct", _("Membre du CHSCT")),
|
||||
]
|
||||
|
||||
MAIL_SIGNATURE_DELETED = (
|
||||
"Bonjour {full_name},\n"
|
||||
"\n"
|
||||
"Votre signature pour la pétition « {petition_name_fr} » a été supprimée.\n"
|
||||
"\n"
|
||||
"----------"
|
||||
"\n"
|
||||
"Dear {full_name},\n"
|
||||
"\n"
|
||||
"Your signature for the petition “{petition_name_en}” has been removed.\n"
|
||||
"\n"
|
||||
"-- \n"
|
||||
"Kadenios"
|
||||
)
|
||||
|
||||
MAIL_SIGNATURE_VERIFICATION = (
|
||||
"Bonjour {full_name},\n"
|
||||
"\n"
|
||||
"Merci d'avoir signé la pétition « {petition_name_fr} », pour confirmer votre "
|
||||
"signature, merci de cliquer sur le lien suivant :\n"
|
||||
"\n"
|
||||
"{confirm_url}\n"
|
||||
"\n"
|
||||
"----------"
|
||||
"\n"
|
||||
"Dear {full_name},\n"
|
||||
"\n"
|
||||
"Thank you for signing the petition “{petition_name_en}”, to confirm your "
|
||||
"signature, please click on the following link:\n"
|
||||
"\n"
|
||||
"{confirm_url}\n"
|
||||
"\n"
|
||||
"-- \n"
|
||||
"Kadenios"
|
||||
)
|
13
petitions/templates/petitions/delete_signature.html
Normal file
13
petitions/templates/petitions/delete_signature.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n string %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 class="title">{% trans "Supprimer une signature" %}</h1>
|
||||
<hr>
|
||||
|
||||
{% url 'election.voters' election.pk as r_url %}
|
||||
{% include "forms/common-form.html" with c_size="is-half" r_anchor="v_"|concatenate:anchor %}
|
||||
|
||||
{% endblock %}
|
132
petitions/templates/petitions/petition.html
Normal file
132
petitions/templates/petitions/petition.html
Normal file
|
@ -0,0 +1,132 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level">
|
||||
{# Titre de la pétition #}
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<h1 class="title">{{ petition.title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
{# Lien vers la page d'administration #}
|
||||
{% if petition.created_by == user %}
|
||||
<div class="level-item">
|
||||
<a class="button has-tooltip-primary" href="{% url 'petition.admin' petition.pk %}" data-tooltip="{% trans "Administrer" %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
{# Date d'ouverture de la pétition #}
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<span class="tag is-medium is-primary">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fas fa-calendar-week"></i>
|
||||
</span>
|
||||
<span>{{ petition.launch_date|date:"d/m/Y" }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Créateurice de la pétition #}
|
||||
<div class="level-item">
|
||||
<span class="tag is-primary is-light is-outlined">{% blocktrans with creator=petition.created_by.full_name %}Créé par {{ creator }}{% endblocktrans %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{# Signature #}
|
||||
{% if petition.launch_date <= today and not petition.archived %}
|
||||
<div class="columns is-centered tile is-ancestor">
|
||||
<div class="column is-6 tile is-parent">
|
||||
<a class="tile is-child notification is-primary" href="{% url 'petition.sign' petition.pk %}">
|
||||
<div class="subtitle has-text-centered">
|
||||
<span class="icon-text">
|
||||
<span class="icon has-text-white">
|
||||
<i class="fas fa-signature"></i>
|
||||
</span>
|
||||
<span class="ml-3">{% trans "Signer cette pétition" %}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Description de la pétition #}
|
||||
{% if petition.text %}
|
||||
<div class="message is-primary">
|
||||
<div class="message-header">{% trans "Texte de la pétition" %}</div>
|
||||
<div class="message-body">{{ petition.text|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Lettre aux signataires #}
|
||||
{% if petition.letter %}
|
||||
<div class="message is-primary">
|
||||
<div class="message-header">{% trans "Lettre de la pétition" %}</div>
|
||||
<div class="message-body">{{ petition.letter|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Liste des signataires #}
|
||||
<br>
|
||||
<h3 class="subtitle">{% trans "Liste des signataires" %}</h3>
|
||||
<hr>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Nom" %}</th>
|
||||
<th>{% trans "Statut" %}</th>
|
||||
<th>{% trans "Département" %}</th>
|
||||
<th>{% trans "Poste élu" %}</th>
|
||||
<th class="has-text-centered">{% trans "Vérifié" %}</th>
|
||||
<th class="has-text-centered">{% trans "Validé" %}</th>
|
||||
<tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for s in signatures %}
|
||||
<tr>
|
||||
<td>{{ s.full_name }}</td>
|
||||
<td>{{ s.get_status_display }}</td>
|
||||
<td>{{ s.get_department_display }}</td>
|
||||
<td>{{ s.get_elected_display }}</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
{% if s.verified %}
|
||||
<i class="has-text-success fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="has-text-danger fas fa-times"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
{% if s.valid %}
|
||||
<i class="has-text-success fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="has-text-danger fas fa-times"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
216
petitions/templates/petitions/petition_admin.html
Normal file
216
petitions/templates/petitions/petition_admin.html
Normal file
|
@ -0,0 +1,216 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block extra_head %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const $del_modal = document.getElementById('modal-delete');
|
||||
const $del_title = $del_modal.querySelector('.modal-card-title');
|
||||
const $del_form = $del_modal.querySelector('form');
|
||||
|
||||
const $val_modal = document.getElementById('modal-validate');
|
||||
const $val_title = $val_modal.querySelector('.modal-card-title');
|
||||
const $val_form = $val_modal.querySelector('form');
|
||||
|
||||
$del_buttons = document.querySelectorAll('.modal-button.delete-signature')
|
||||
$val_buttons = document.querySelectorAll('.modal-button.validate-signature')
|
||||
|
||||
$del_buttons.forEach($del => {
|
||||
$del.addEventListener('click', () => {
|
||||
$del_form.action = $del.dataset.post_url;
|
||||
$del_title.innerHTML = $del.dataset.tooltip;
|
||||
});
|
||||
});
|
||||
|
||||
$val_buttons.forEach($val => {
|
||||
$val.addEventListener('click', () => {
|
||||
$val_form.action = $val.dataset.post_url;
|
||||
$val_title.innerHTML = $val.dataset.tooltip;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level is-block-tablet is-block-desktop is-flex-fullhd">
|
||||
{# Titre de la pétition #}
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<h1 class="title">{{ petition.title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<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-text">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span>{% trans "Actions" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
{# Vue classique #}
|
||||
<a class="dropdown-item" href="{% url 'petition.view' petition.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</span>
|
||||
<span>{% trans "Vue classique" %}
|
||||
</a>
|
||||
|
||||
{# Téléchargement de la liste des signataires #}
|
||||
<a class="dropdown-item" href="{% url 'election.export-voters' petition.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-download"></i>
|
||||
</span>
|
||||
<span>{% trans "Exporter les signataires" %}
|
||||
</a>
|
||||
|
||||
{# Modification de la pétition #}
|
||||
{% if petition.launch_date > today %}
|
||||
<a class="dropdown-item" href="{% url 'petition.update' petition.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-edit"></i>
|
||||
</span>
|
||||
<span>{% trans "Modifier" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Archivage #}
|
||||
{% if not petition.archived %}
|
||||
<a class="dropdown-item" href="{% url 'petition.archive' petition.pk %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-archive"></i>
|
||||
</span>
|
||||
<span>{% trans "Archiver" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
{# Dates d'ouverture de la pétition #}
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<span class="tag is-medium is-primary">
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fas fa-calendar-week"></i>
|
||||
</span>
|
||||
<span>{{ petition.launch_date|date:"d/m/Y" }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{# Description de la pétition #}
|
||||
{% if petition.text %}
|
||||
<div class="message is-primary">
|
||||
<div class="message-header">{% trans "Texte de la pétition" %}</div>
|
||||
<div class="message-body">{{ petition.text|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Lettre aux signataires #}
|
||||
{% if petition.letter %}
|
||||
<div class="message is-primary">
|
||||
<div class="message-header">{% trans "Lettre de la pétition" %}</div>
|
||||
<div class="message-body">{{ petition.letter|linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Liste des signataires #}
|
||||
<br>
|
||||
<h3 class="subtitle">{% trans "Liste des signataires" %}</h3>
|
||||
<hr>
|
||||
|
||||
{% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
|
||||
{% include "forms/modal-form.html" with modal_id="validate" form=v_form %}
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Nom" %}</th>
|
||||
<th>{% trans "Email" %}</th>
|
||||
<th>{% trans "Statut" %}</th>
|
||||
<th>{% trans "Département" %}</th>
|
||||
<th>{% trans "Poste élu" %}</th>
|
||||
<th class="has-text-centered">{% trans "Vérifié" %}</th>
|
||||
<th class="has-text-centered">{% trans "Valide" %}</th>
|
||||
{% if not petition.archived %}
|
||||
<th class="has-text-centered">{% trans "Action" %}</th>
|
||||
{% endif %}
|
||||
<tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{% for s in petition.signatures.all %}
|
||||
<tr id="s_{{ forloop.counter }}">
|
||||
<td>{{ s.full_name }}</td>
|
||||
<td>{{ s.email }}</td>
|
||||
<td>{{ s.get_status_display }}</td>
|
||||
<td>{{ s.get_department_display }}</td>
|
||||
<td>{{ s.get_elected_display }}</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
{% if s.verified %}
|
||||
<i class="has-text-success fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="has-text-danger fas fa-times"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="has-text-centered">
|
||||
<span class="icon">
|
||||
{% if s.valid %}
|
||||
<i class="has-text-success fas fa-check"></i>
|
||||
{% else %}
|
||||
<i class="has-text-danger fas fa-times"></i>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
{% if not petition.archived %}
|
||||
<td>
|
||||
<span class="tags is-centered">
|
||||
{% if not s.valid %}
|
||||
{% blocktrans with s_name=s.full_name asvar s_validate %}Valider la signature de {{ s_name }}{% endblocktrans %}
|
||||
<a class="tag is-success has-tooltip-primary has-tooltip-left modal-button validate-signature" data-target="modal-validate" data-post_url="{% url 'petition.validate' petition.pk s.pk forloop.counter %}" data-tooltip="{{ s_validate }}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% blocktrans with s_name=s.full_name asvar s_delete %}Supprimer la signature de {{ s_name }}{% endblocktrans %}
|
||||
<a class="tag is-danger has-tooltip-primary has-tooltip-left modal-button delete-signature" data-target="modal-delete" data-post_url="{% url 'petition.delete-signature' petition.pk s.pk forloop.counter %}" data-tooltip="{{ s_delete }}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
35
petitions/templates/petitions/petition_create.html
Normal file
35
petitions/templates/petitions/petition_create.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
|
||||
{% block extra_head %}
|
||||
{# DateTimePicker #}
|
||||
<script src={% static 'vendor/datepicker/datetimepicker.js' %}></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/datepicker/datetimepicker.css' %}">
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
$('#id_launch_date').datetimepicker({
|
||||
format: 'Y-m-d',
|
||||
timepicker: false,
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="notification is-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<h1 class="title">{% trans "Création d'une pétition" %}</h1>
|
||||
<hr>
|
||||
|
||||
{% url 'election.list' as r_url %}
|
||||
{% include "forms/common-form.html" with c_size="is-12" errors=False %}
|
||||
|
||||
{% endblock %}
|
69
petitions/templates/petitions/petition_list.html
Normal file
69
petitions/templates/petitions/petition_list.html
Normal file
|
@ -0,0 +1,69 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<h1 class="title">{% trans "Liste des pétitions" %}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if perms.petitions.is_admin %}
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<a class="button is-light is-outlined is-primary" href={% url 'petition.create' %}>
|
||||
<span class="icon">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<span>{% trans "Créer une pétition" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
{% for p in petition_list %}
|
||||
<div class="panel is-primary">
|
||||
<div class="panel-heading is-size-6 is-radiusles">
|
||||
<div class="level">
|
||||
<div class="level-left is-flex-shrink-1">
|
||||
<div class="level-item">
|
||||
<span class="tag is-primary is-light">{{ p.launch_date|date:"d/m/Y" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="level-item is-flex-shrink-1">
|
||||
<a class="has-text-primary-light" href="{% url 'petition.view' p.pk %}"><u>{{ p.title }}</u></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
{% if p.archived %}
|
||||
<div class="level-item">
|
||||
<span class="tag is-danger is-light">{% trans "Pétition archivée" %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if p.created_by == user %}
|
||||
<div class="level-item">
|
||||
<a class="has-text-primary-light ml-3 has-tooltip-light" href="{% url 'petition.admin' p.pk %}" data-tooltip="{% trans "Administrer" %}">
|
||||
<span class="icon">
|
||||
<i class="fas fa-cog"></i>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="panel-block">
|
||||
{{ p.text|linebreaksbr }}
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
19
petitions/templates/petitions/petition_sign.html
Normal file
19
petitions/templates/petitions/petition_sign.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="notification is-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<h1 class="title">{% blocktrans with p_title=petition.title %}Signature de la pétition {{ p_title }}{% endblocktrans %}</h1>
|
||||
<hr>
|
||||
|
||||
{% url 'petition.view' petition.pk as r_url %}
|
||||
{% include "forms/common-form.html" with errors=False %}
|
||||
|
||||
{% endblock %}
|
34
petitions/templates/petitions/petition_update.html
Normal file
34
petitions/templates/petitions/petition_update.html
Normal file
|
@ -0,0 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n static %}
|
||||
|
||||
|
||||
{% block extra_head %}
|
||||
{# DateTimePicker #}
|
||||
<script src={% static 'vendor/datepicker/datetimepicker.js' %}></script>
|
||||
<link rel="stylesheet" href="{% static 'vendor/datepicker/datetimepicker.css' %}">
|
||||
|
||||
<script>
|
||||
$(document).ready(function($) {
|
||||
$('#id_launch_date').datetimepicker({
|
||||
format: 'Y-m-d'
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% for error in form.non_field_errors %}
|
||||
<div class="notification is-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<h1 class="title">{% trans "Modification d'une pétition" %}</h1>
|
||||
<hr>
|
||||
|
||||
{% url 'petition.admin' petition.pk as r_url %}
|
||||
{% include "forms/common-form.html" with errors=False %}
|
||||
|
||||
{% endblock %}
|
33
petitions/urls.py
Normal file
33
petitions/urls.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
# Admin views
|
||||
path("create", views.PetitionCreateView.as_view(), name="petition.create"),
|
||||
path("admin/<int:pk>", views.PetitionAdminView.as_view(), name="petition.admin"),
|
||||
path("update/<int:pk>", views.PetitionUpdateView.as_view(), name="petition.update"),
|
||||
path(
|
||||
"archive/<int:pk>", views.PetitionArchiveView.as_view(), name="petition.archive"
|
||||
),
|
||||
path(
|
||||
"delete/<int:pk>/<int:signature_pk>/<int:anchor>",
|
||||
views.DeleteSignatureView.as_view(),
|
||||
name="petition.delete-signature",
|
||||
),
|
||||
path(
|
||||
"validate/<int:pk>/<int:signature_pk>/<int:anchor>",
|
||||
views.ValidateSignatureView.as_view(),
|
||||
name="petition.validate",
|
||||
),
|
||||
# Verification views
|
||||
path(
|
||||
"email/<slug:token>",
|
||||
views.EmailValidationView.as_view(),
|
||||
name="petition.confirm-email",
|
||||
),
|
||||
# Public views
|
||||
path("", views.PetitionListView.as_view(), name="petition.list"),
|
||||
path("view/<int:pk>", views.PetitionView.as_view(), name="petition.view"),
|
||||
path("sign/<int:pk>", views.PetitionSignView.as_view(), name="petition.sign"),
|
||||
]
|
264
petitions/views.py
Normal file
264
petitions/views.py
Normal file
|
@ -0,0 +1,264 @@
|
|||
from datetime import date
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.mail import EmailMessage
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView
|
||||
from django.views.generic.edit import SingleObjectMixin
|
||||
|
||||
from shared.utils import full_url
|
||||
from shared.views import BackgroundUpdateView
|
||||
|
||||
from .forms import DeleteForm, PetitionForm, SignatureForm, ValidateForm
|
||||
from .mixins import AdminOnlyMixin, CreatorOnlyMixin
|
||||
from .models import Petition, Signature
|
||||
from .staticdefs import MAIL_SIGNATURE_DELETED, MAIL_SIGNATURE_VERIFICATION
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# #############################################################################
|
||||
# Administration views
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class PetitionCreateView(AdminOnlyMixin, CreateView):
|
||||
model = Petition
|
||||
form_class = PetitionForm
|
||||
success_message = _("Pétition créée avec succès")
|
||||
template_name = "petitions/petition_create.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("petition.admin", args=[self.object.pk])
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.created_by = self.request.user
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class PetitionAdminView(CreatorOnlyMixin, DetailView):
|
||||
model = Petition
|
||||
template_name = "petitions/petition_admin.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["d_form"] = DeleteForm()
|
||||
context["v_form"] = ValidateForm()
|
||||
context["today"] = date.today()
|
||||
return context
|
||||
|
||||
|
||||
class PetitionUpdateView(CreatorOnlyMixin, SuccessMessageMixin, UpdateView):
|
||||
model = Petition
|
||||
form_class = PetitionForm
|
||||
success_message = _("Pétition modifiée avec succès !")
|
||||
template_name = "petitions/petition_update.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("election.admin", args=[self.object.pk])
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super().get_queryset().filter(launch_date__gt=date.today(), archived=False)
|
||||
)
|
||||
|
||||
|
||||
class PetitionArchiveView(CreatorOnlyMixin, SingleObjectMixin, BackgroundUpdateView):
|
||||
model = Petition
|
||||
pattern_name = "petition.admin"
|
||||
success_message = _("Élection archivée avec succès !")
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(archived=False)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
petition = self.get_object()
|
||||
petition.archived = True
|
||||
petition.save()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class DeleteSignatureView(CreatorOnlyMixin, SingleObjectMixin, FormView):
|
||||
model = Petition
|
||||
template_name = "petitions/delete_signature.html"
|
||||
form_class = DeleteForm
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("petition.admin", args=[self.object.pk]) + "#s_{anchor}".format(
|
||||
**self.kwargs
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["signature"] = self.signature
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["anchor"] = self.kwargs["anchor"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(archived=False)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = super().get_object()
|
||||
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = super().get_object()
|
||||
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.cleaned_data["delete"] == "oui":
|
||||
# On envoie un mail à la personne lui indiquant que la signature est supprimée
|
||||
# si l'adresse mail est validée
|
||||
if self.signature.verified:
|
||||
EmailMessage(
|
||||
subject="Signature removed",
|
||||
body=MAIL_SIGNATURE_DELETED.format(
|
||||
full_name=self.signature.full_name,
|
||||
petition_name_fr=self.object.title_fr,
|
||||
petition_name_en=self.object.title_en,
|
||||
),
|
||||
to=[self.signature.email],
|
||||
).send()
|
||||
|
||||
# On supprime la signature
|
||||
self.signature.delete()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ValidateSignatureView(CreatorOnlyMixin, SingleObjectMixin, FormView):
|
||||
model = Petition
|
||||
template_name = "petitions/validate_signature.html"
|
||||
form_class = ValidateForm
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("petition.admin", args=[self.object.pk]) + "#s_{anchor}".format(
|
||||
**self.kwargs
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["signature"] = self.signature
|
||||
return kwargs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["anchor"] = self.kwargs["anchor"]
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(archived=False)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = super().get_object()
|
||||
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = super().get_object()
|
||||
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
if form.cleaned_data["validate"] == "oui":
|
||||
# On valide la signature
|
||||
self.signature.valid = True
|
||||
self.signature.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Email validation Views
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class EmailValidationView(SingleObjectMixin, BackgroundUpdateView):
|
||||
model = Signature
|
||||
slug_url_kwarg = "token"
|
||||
slug_field = "token"
|
||||
success_message = _("Adresse email vérifiée avec succès")
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(verified=False)
|
||||
|
||||
def get_redirect_url(self, *args, **kwargs):
|
||||
return reverse("petition.view", args=[self.object.petition.pk])
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
self.object.verified = True
|
||||
self.object.save()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
# #############################################################################
|
||||
# Public Views
|
||||
# #############################################################################
|
||||
|
||||
|
||||
class PetitionListView(ListView):
|
||||
model = Petition
|
||||
template_name = "petitions/petition_list.html"
|
||||
|
||||
|
||||
class PetitionView(DetailView):
|
||||
model = Petition
|
||||
template_name = "petitions/petition.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["today"] = date.today()
|
||||
context["signatures"] = self.object.signatures.filter(verified=True)
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().select_related("created_by")
|
||||
|
||||
|
||||
class PetitionSignView(CreateView):
|
||||
model = Signature
|
||||
form_class = SignatureForm
|
||||
template_name = "petitions/petition_sign.html"
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("petition.view", args=[self.petition.pk])
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.petition = Petition.objects.get(pk=self.kwargs["pk"])
|
||||
if self.petition.launch_date > date.today():
|
||||
raise Http404
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["petition"] = self.petition
|
||||
return context
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.instance.petition = self.petition
|
||||
return form
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
# On envoie un mail à l'adresse indiquée
|
||||
EmailMessage(
|
||||
subject="Confirmation de la signature",
|
||||
body=MAIL_SIGNATURE_VERIFICATION.format(
|
||||
full_name=self.object.full_name,
|
||||
petition_name_fr=self.object.petition.title_fr,
|
||||
petition_name_en=self.object.petition.title_en,
|
||||
confirm_url=full_url("petition.confirm-email", self.object.token),
|
||||
),
|
||||
to=[self.object.email],
|
||||
).send()
|
||||
return super().form_valid(form)
|
|
@ -1,3 +0,0 @@
|
|||
[tool.pyright]
|
||||
reportIncompatibleMethodOverride = false
|
||||
reportIncompatibleVariableOverride = false
|
|
@ -1,7 +1,6 @@
|
|||
django==3.2.*
|
||||
django-translated-fields==0.11.*
|
||||
django-translated-fields==0.11.1
|
||||
authens>=0.1b2
|
||||
markdown
|
||||
numpy
|
||||
networkx
|
||||
django-background-tasks
|
||||
python-csv
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
||||
|
||||
|
||||
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
|
||||
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue