Compare commits

..

1 commit

Author SHA1 Message Date
7068c6dd18 Module de pétitions inspiré de degette 2021-05-31 20:39:37 +02:00
147 changed files with 3867 additions and 4613 deletions

View file

@ -1 +0,0 @@
localhost

View file

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

View file

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

View file

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

1
.envrc
View file

@ -1 +0,0 @@
use nix

2
.gitignore vendored
View file

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

View file

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

View file

@ -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

View file

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

View file

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

12
elections/admin.py Normal file
View 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

View file

@ -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}),
}

View file

@ -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"),
),
]

View file

@ -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"),
),
]

View file

@ -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"),
),
]

View file

@ -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"
),
),
]

View file

@ -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")],
},
),
]

View file

@ -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é",
),
),
]

View file

@ -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),
]

View file

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

View file

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

View file

@ -1,32 +1,23 @@
from typing import Any
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.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)

View file

@ -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"))

View file

@ -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")),

View file

@ -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()

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}

View 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 %}

View 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 %}

View file

@ -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>

View file

@ -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 %}

View file

@ -1,3 +0,0 @@
{{ question.text }} :
{% for o in question.options.all %}- {{ o.nb_votes }} {{ o.text }}{% if not forloop.last %}
{% endif %}{% endfor %}

View file

@ -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 %}

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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))

View file

@ -1,16 +1,11 @@
from typing import TYPE_CHECKING
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.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):

View file

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

View file

@ -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

View file

@ -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"])

View file

@ -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):

View file

@ -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"}),
}

View file

@ -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"
),
),
]

View file

@ -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")]},
),
]

View file

@ -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)

View file

@ -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")
]

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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"),
]

View file

@ -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
View file

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

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

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

153
kadenios/settings/common.py Normal file
View 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"]

View file

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

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

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

View file

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

View file

@ -1,5 +1,4 @@
from django.conf import settings
from django.conf.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

View file

@ -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()

View file

@ -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:

View file

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

View file

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

6
petitions/apps.py Normal file
View 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
View 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"]

View 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 à lENS"),
("enseignant-ens", "Enseignant·e à lENS"),
(
"enseignant-chercheur",
"Enseignant·e et chercheur·se à lENS",
),
("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 dHistoire"),
("economie", "Département dÉconomie"),
("philosophie", "Département de Philosophie"),
("sciences-sociales", "Département de Sciences Sociales"),
("antiquite", "Département des Sciences de lAntiquité"),
("ecla", "Espace des cultures et langues dailleurs"),
("geographie", "Département Géographie et Territoires"),
("di", "Département dInformatique"),
("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 lEnvironnement 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"
),
),
]

View 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")],
},
),
]

View 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 dHistoire"),
("economie", "Département dÉconomie"),
("philosophie", "Département de Philosophie"),
("sciences-sociales", "Département de Sciences Sociales"),
("antiquite", "Département des Sciences de lAntiquité"),
("ecla", "Espace des cultures et langues dailleurs"),
("geographie", "Département Géographie et Territoires"),
("di", "Département dInformatique"),
("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 lEnvironnement 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 à lENS"),
("enseignant-ens", "Enseignant·e à lENS"),
("enseignant-chercheur", "Enseignant·e et chercheur·se à lENS"),
("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"),
),
]

View 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
View 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
View 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
View 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 dHistoire")),
("economie", _("Département dÉconomie")),
("philosophie", _("Département de Philosophie")),
("sciences-sociales", _("Département de Sciences Sociales")),
("antiquite", _("Département des Sciences de lAntiquité")),
("ecla", _("Espace des cultures et langues dailleurs")),
("geographie", _("Département Géographie et Territoires")),
("di", _("Département dInformatique")),
("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 lEnvironnement 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 à lENS")),
("enseignant-ens", _("Enseignant·e à lENS")),
("enseignant-chercheur", _("Enseignant·e et chercheur·se à lENS")),
("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"
)

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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
View 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)

View file

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

View file

@ -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

View file

@ -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