Compare commits
2 commits
main
...
thubrecht/
Author | SHA1 | Date | |
---|---|---|---|
c4cf8d2c38 | |||
4b5a530f2b |
59 changed files with 711 additions and 825 deletions
|
@ -1 +0,0 @@
|
||||||
localhost
|
|
|
@ -1 +0,0 @@
|
||||||
Kadenios <kadenios@localhost>
|
|
|
@ -1 +0,0 @@
|
||||||
insecure-secret-key
|
|
|
@ -1 +0,0 @@
|
||||||
kadenios@localhost
|
|
1
.envrc
1
.envrc
|
@ -1 +0,0 @@
|
||||||
use nix
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -16,4 +16,3 @@ pyrightconfig.json
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
.vscode
|
.vscode
|
||||||
.direnv
|
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
diff --git a/src/authens/utils.py b/src/authens/utils.py
|
|
||||||
index 7306506..36063b6 100644
|
|
||||||
--- a/src/authens/utils.py
|
|
||||||
+++ b/src/authens/utils.py
|
|
||||||
@@ -16,7 +16,7 @@ def get_cas_client(request):
|
|
||||||
service_url=urlunparse(
|
|
||||||
(request.scheme, request.get_host(), request.path, "", "", "")
|
|
||||||
),
|
|
||||||
- server_url="https://cas.eleves.ens.fr/",
|
|
||||||
+ server_url="https://cas-eleves.dgnum.eu/",
|
|
||||||
)
|
|
21
LICENSE
21
LICENSE
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2023 Klub Dev ENS
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
210
app/settings.py
210
app/settings.py
|
@ -1,210 +0,0 @@
|
||||||
"""
|
|
||||||
Django settings for the kadenios project
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from loadcredential import Credentials
|
|
||||||
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
credentials = Credentials(env_prefix="KADENIOS_")
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
# WARNING: keep the secret key used in production secret!
|
|
||||||
SECRET_KEY = credentials["SECRET_KEY"]
|
|
||||||
|
|
||||||
# WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = credentials.get_json("DEBUG", False)
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = credentials.get_json("ALLOWED_HOSTS", [])
|
|
||||||
|
|
||||||
ADMINS = credentials.get_json("ADMINS", [])
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# List the installed applications
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"shared.IgnoreSrcStaticFilesConfig",
|
|
||||||
"background_task",
|
|
||||||
"shared",
|
|
||||||
"elections",
|
|
||||||
"faqs",
|
|
||||||
"authens",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# List the installed middlewares
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"django.middleware.locale.LocaleMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# The main url configuration
|
|
||||||
|
|
||||||
ROOT_URLCONF = "app.urls"
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Template configuration:
|
|
||||||
# - Django Templating Language is used
|
|
||||||
# - Application directories can be used
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.debug",
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# WSGI application configuration
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "app.wsgi.application"
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# E-Mail configuration
|
|
||||||
|
|
||||||
DEFAULT_FROM_EMAIL = credentials["FROM_EMAIL"]
|
|
||||||
EMAIL_HOST = credentials.get("EMAIL_HOST", "localhost")
|
|
||||||
EMAIL_HOST_PASSWORD = credentials.get("EMAIL_HOST_PASSWORD", "")
|
|
||||||
EMAIL_HOST_USER = credentials.get("EMAIL_HOST_USER", "")
|
|
||||||
EMAIL_USE_SSL = credentials.get("EMAIL_USE_SSL", False)
|
|
||||||
SERVER_EMAIL = credentials["SERVER_EMAIL"]
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Default primary key field type
|
|
||||||
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Database configuration
|
|
||||||
# -> https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = credentials.get_json(
|
|
||||||
"DATABASES",
|
|
||||||
{
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Authentication configuration
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
AUTH_USER_MODEL = "elections.User"
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
|
||||||
"shared.auth.backends.PwdBackend",
|
|
||||||
"shared.auth.backends.CASBackend",
|
|
||||||
"shared.auth.backends.ElectionBackend",
|
|
||||||
]
|
|
||||||
|
|
||||||
LOGIN_URL = reverse_lazy("authens:login")
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
|
||||||
|
|
||||||
AUTHENS_USE_OLDCAS = False
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Internationalization configuration
|
|
||||||
# -> https://docs.djangoproject.com/en/4.2/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = "fr-fr"
|
|
||||||
TIME_ZONE = "Europe/Paris"
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
USE_L10N = True
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
LANGUAGES = [
|
|
||||||
("fr", _("Français")),
|
|
||||||
("en", _("Anglais")),
|
|
||||||
]
|
|
||||||
|
|
||||||
LOCALE_PATHS = [BASE_DIR / "shared" / "locale"]
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Static files (CSS, JavaScript, Images) configuration
|
|
||||||
# -> https://docs.djangoproject.com/en/4.2/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
|
||||||
STATIC_ROOT = credentials["STATIC_ROOT"]
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# Background tasks configuration
|
|
||||||
# -> https://django4-background-tasks.readthedocs.io/en/latest/#settings
|
|
||||||
|
|
||||||
BACKGROUND_TASK_RUN_ASYNC = True
|
|
||||||
BACKGROUND_TASK_ASYNC_THREADS = 4
|
|
||||||
|
|
||||||
|
|
||||||
if DEBUG:
|
|
||||||
# Print the e-mails in the console
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
|
||||||
|
|
||||||
INSTALLED_APPS += [
|
|
||||||
"debug_toolbar",
|
|
||||||
"django_browser_reload",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE += [
|
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
|
||||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
INTERNAL_IPS = ["127.0.0.1"]
|
|
||||||
|
|
||||||
DEBUG_TOOLBAR_CONFIG = {"INSERT_BEFORE": "</footer>"}
|
|
67
default.nix
67
default.nix
|
@ -1,67 +0,0 @@
|
||||||
{
|
|
||||||
sources ? import ./npins,
|
|
||||||
pkgs ? import sources.nixpkgs { },
|
|
||||||
}:
|
|
||||||
|
|
||||||
let
|
|
||||||
nix-pkgs = import sources.nix-pkgs { inherit pkgs; };
|
|
||||||
|
|
||||||
python3 = pkgs.python3.override {
|
|
||||||
packageOverrides = _: _: {
|
|
||||||
inherit (nix-pkgs)
|
|
||||||
django-background-tasks
|
|
||||||
django-browser-reload
|
|
||||||
django-bulma-forms
|
|
||||||
django-translated-fields
|
|
||||||
loadcredential
|
|
||||||
pyrage
|
|
||||||
;
|
|
||||||
|
|
||||||
authens = nix-pkgs.authens.overridePythonAttrs (old: {
|
|
||||||
patches = [ ./01-authens.patch ];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
|
|
||||||
{
|
|
||||||
devShell = pkgs.mkShell {
|
|
||||||
name = "cas-eleves.dev";
|
|
||||||
|
|
||||||
packages = [
|
|
||||||
(python3.withPackages (ps: [
|
|
||||||
ps.django
|
|
||||||
ps.ipython
|
|
||||||
ps.django-stubs
|
|
||||||
|
|
||||||
ps.markdown
|
|
||||||
ps.numpy
|
|
||||||
ps.networkx
|
|
||||||
|
|
||||||
ps.authens
|
|
||||||
ps.django-background-tasks
|
|
||||||
ps.django-browser-reload
|
|
||||||
ps.django-bulma-forms
|
|
||||||
ps.django-debug-toolbar
|
|
||||||
ps.django-translated-fields
|
|
||||||
ps.loadcredential
|
|
||||||
ps.pyrage
|
|
||||||
]))
|
|
||||||
|
|
||||||
pkgs.gettext
|
|
||||||
pkgs.gtranslator
|
|
||||||
];
|
|
||||||
|
|
||||||
env = {
|
|
||||||
CREDENTIALS_DIRECTORY = builtins.toString ./.credentials;
|
|
||||||
KADENIOS_DEBUG = "true";
|
|
||||||
KADENIOS_STATIC_ROOT = builtins.toString ./.static;
|
|
||||||
};
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
if [ ! -d .static ]; then
|
|
||||||
mkdir .static
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -14,9 +14,6 @@ class ElectionForm(forms.ModelForm):
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
assert cleaned_data is not None
|
|
||||||
|
|
||||||
if cleaned_data["start_date"] < timezone.now():
|
if cleaned_data["start_date"] < timezone.now():
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"start_date", _("Impossible de faire débuter l'élection dans le passé")
|
"start_date", _("Impossible de faire débuter l'élection dans le passé")
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
# Generated by Django 4.2.12 on 2024-07-11 12:01
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('elections', '0033_inactive_users'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='vote',
|
|
||||||
name='pseudonymous_user',
|
|
||||||
field=models.CharField(blank=True, max_length=16),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='vote',
|
|
||||||
name='user',
|
|
||||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -1,44 +0,0 @@
|
||||||
# Generated by Django 4.2.12 on 2024-07-11 12:24
|
|
||||||
|
|
||||||
import random
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_password(size):
|
|
||||||
random.seed()
|
|
||||||
|
|
||||||
return "".join(random.choice(alphabet) for _ in range(size))
|
|
||||||
|
|
||||||
|
|
||||||
def pseudonymize_users(apps, _):
|
|
||||||
Question = apps.get_model("elections", "Question")
|
|
||||||
Vote = apps.get_model("elections", "Vote")
|
|
||||||
|
|
||||||
votes = set()
|
|
||||||
|
|
||||||
for q in Question.objects.filter(election__tallied=True).prefetch_related(
|
|
||||||
"options__vote_set"
|
|
||||||
):
|
|
||||||
|
|
||||||
for v in q.voters.all():
|
|
||||||
pseudonym = generate_password(16)
|
|
||||||
|
|
||||||
for opt in q.options.all():
|
|
||||||
for vote in opt.vote_set.filter(user=v):
|
|
||||||
vote.pseudonymous_user = pseudonym
|
|
||||||
vote.user = None
|
|
||||||
votes.add(vote)
|
|
||||||
|
|
||||||
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("elections", "0034_vote_pseudonymous_user_alter_vote_user"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [migrations.RunPython(pseudonymize_users)]
|
|
|
@ -1,32 +1,25 @@
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||||
from django.db.models import Q, QuerySet
|
from django.db.models import Q
|
||||||
from django.http.request import HttpRequest
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
|
||||||
from elections.typing import AuthenticatedRequest
|
from shared.mixins import LogMixin
|
||||||
|
|
||||||
from .models import Election, Option, Question
|
from .models import Election, Option, Question
|
||||||
|
|
||||||
|
|
||||||
class AdminOnlyMixin(PermissionRequiredMixin):
|
class AdminOnlyMixin(LogMixin, PermissionRequiredMixin):
|
||||||
"""Restreint l'accès aux admins"""
|
"""Restreint l'accès aux admins"""
|
||||||
|
|
||||||
request: AuthenticatedRequest
|
|
||||||
|
|
||||||
permission_required = "elections.election_admin"
|
permission_required = "elections.election_admin"
|
||||||
|
|
||||||
|
|
||||||
class SelectElectionMixin:
|
class SelectElectionMixin:
|
||||||
"""Sélectionne automatiquement les foreignkeys voulues"""
|
"""Sélectionne automatiquement les foreignkeys voulues"""
|
||||||
|
|
||||||
model: type
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
def get_queryset(self) -> QuerySet:
|
|
||||||
qs = super().get_queryset() # pyright: ignore
|
|
||||||
if self.model is Question:
|
if self.model is Question:
|
||||||
return qs.select_related("election")
|
return qs.select_related("election")
|
||||||
elif self.model is Option:
|
elif self.model is Option:
|
||||||
|
@ -37,19 +30,15 @@ class SelectElectionMixin:
|
||||||
class RestrictAccessMixin(SelectElectionMixin):
|
class RestrictAccessMixin(SelectElectionMixin):
|
||||||
"""Permet de restreindre l'accès à des élections/questions/options"""
|
"""Permet de restreindre l'accès à des élections/questions/options"""
|
||||||
|
|
||||||
f_prefixes = {
|
f_prefixes = {Election: "", Question: "election__", Option: "question__election__"}
|
||||||
Election: "",
|
|
||||||
Question: "election__",
|
|
||||||
Option: "question__election__",
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_f_prefix(self) -> str:
|
def get_f_prefix(self):
|
||||||
return self.f_prefixes.get(self.model, "")
|
return self.f_prefixes.get(self.model, None)
|
||||||
|
|
||||||
def get_filters(self) -> dict[str, Any]:
|
def get_filters(self):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet:
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
if self.model in self.f_prefixes:
|
if self.model in self.f_prefixes:
|
||||||
return qs.filter(**self.get_filters())
|
return qs.filter(**self.get_filters())
|
||||||
|
@ -60,7 +49,7 @@ class RestrictAccessMixin(SelectElectionMixin):
|
||||||
class OpenElectionOnlyMixin(RestrictAccessMixin):
|
class OpenElectionOnlyMixin(RestrictAccessMixin):
|
||||||
"""N'autorise la vue que lorsque l'élection est ouverte"""
|
"""N'autorise la vue que lorsque l'élection est ouverte"""
|
||||||
|
|
||||||
def get_filters(self) -> dict[str, Any]:
|
def get_filters(self):
|
||||||
f_prefix = self.get_f_prefix()
|
f_prefix = self.get_f_prefix()
|
||||||
# On ne peut modifier que les élections qui n'ont pas commencé, et
|
# On ne peut modifier que les élections qui n'ont pas commencé, et
|
||||||
# accessoirement qui ne sont pas dépouillées ou archivées
|
# accessoirement qui ne sont pas dépouillées ou archivées
|
||||||
|
@ -80,7 +69,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
|
||||||
def get_next_url(self):
|
def get_next_url(self):
|
||||||
return reverse("kadenios")
|
return reverse("kadenios")
|
||||||
|
|
||||||
def get_filters(self) -> dict[str, Any]:
|
def get_filters(self):
|
||||||
filters = super().get_filters()
|
filters = super().get_filters()
|
||||||
# TODO: change the way we collect the user according to the model used
|
# TODO: change the way we collect the user according to the model used
|
||||||
filters[self.get_f_prefix() + "created_by"] = self.request.user
|
filters[self.get_f_prefix() + "created_by"] = self.request.user
|
||||||
|
@ -90,7 +79,7 @@ class CreatorOnlyMixin(AdminOnlyMixin, RestrictAccessMixin, SingleObjectMixin):
|
||||||
class CreatorOnlyEditMixin(CreatorOnlyMixin):
|
class CreatorOnlyEditMixin(CreatorOnlyMixin):
|
||||||
"""Permet au créateurice de modifier l'élection implicitement"""
|
"""Permet au créateurice de modifier l'élection implicitement"""
|
||||||
|
|
||||||
def get_filters(self) -> dict[str, Any]:
|
def get_filters(self):
|
||||||
# On ne peut modifier que les élections qui n'ont pas commencé
|
# On ne peut modifier que les élections qui n'ont pas commencé
|
||||||
filters = super().get_filters()
|
filters = super().get_filters()
|
||||||
filters[self.get_f_prefix() + "start_date__gt"] = timezone.now()
|
filters[self.get_f_prefix() + "start_date__gt"] = timezone.now()
|
||||||
|
@ -100,7 +89,7 @@ class CreatorOnlyEditMixin(CreatorOnlyMixin):
|
||||||
class ClosedElectionMixin(CreatorOnlyMixin):
|
class ClosedElectionMixin(CreatorOnlyMixin):
|
||||||
"""Permet d'agir sur une élection terminée"""
|
"""Permet d'agir sur une élection terminée"""
|
||||||
|
|
||||||
def get_filters(self) -> dict[str, Any]:
|
def get_filters(self):
|
||||||
f_prefix = self.get_f_prefix()
|
f_prefix = self.get_f_prefix()
|
||||||
# L'élection doit être terminée et non archivée
|
# L'élection doit être terminée et non archivée
|
||||||
filters = super().get_filters()
|
filters = super().get_filters()
|
||||||
|
@ -115,11 +104,9 @@ class NotArchivedMixin:
|
||||||
ou dont on est l'admin
|
ou dont on est l'admin
|
||||||
"""
|
"""
|
||||||
|
|
||||||
request: HttpRequest
|
def get_queryset(self):
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet:
|
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
qs = super().get_queryset() # pyright: ignore
|
qs = super().get_queryset()
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
return qs.filter(Q(archived=False, visible=True) | Q(created_by=user))
|
return qs.filter(Q(archived=False, visible=True) | Q(created_by=user))
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,12 @@
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from translated_fields import TranslatedFieldWithFallback
|
from translated_fields import TranslatedFieldWithFallback
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.http.request import HttpRequest
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from shared.auth import CONNECTION_METHODS
|
from shared.auth import CONNECTION_METHODS
|
||||||
from shared.auth.utils import generate_password
|
|
||||||
from shared.json import Serializer
|
from shared.json import Serializer
|
||||||
from shared.utils import choices_length
|
from shared.utils import choices_length
|
||||||
|
|
||||||
|
@ -29,20 +25,12 @@ from .utils import (
|
||||||
ValidateFunctions,
|
ValidateFunctions,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.db.models.fields.related_descriptors import ManyRelatedManager
|
|
||||||
from django.utils.functional import _StrPromise
|
|
||||||
|
|
||||||
|
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
# Models regarding an election
|
# Models regarding an election
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
class Election(models.Model):
|
class Election(Serializer, models.Model):
|
||||||
registered_voters: models.Manager["User"]
|
|
||||||
questions: models.Manager["Question"]
|
|
||||||
|
|
||||||
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
|
name = TranslatedFieldWithFallback(models.CharField(_("nom"), max_length=255))
|
||||||
short_name = models.SlugField(_("nom bref"), unique=True)
|
short_name = models.SlugField(_("nom bref"), unique=True)
|
||||||
description = TranslatedFieldWithFallback(
|
description = TranslatedFieldWithFallback(
|
||||||
|
@ -92,6 +80,16 @@ class Election(models.Model):
|
||||||
_("date de publication"), null=True, default=None
|
_("date de publication"), null=True, default=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
serializable_fields = [
|
||||||
|
"name_fr",
|
||||||
|
"name_en",
|
||||||
|
"description_fr",
|
||||||
|
"description_en",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
"restricted",
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
permissions = [
|
permissions = [
|
||||||
("election_admin", _("Peut administrer des élections")),
|
("election_admin", _("Peut administrer des élections")),
|
||||||
|
@ -100,9 +98,6 @@ class Election(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class Question(Serializer, models.Model):
|
class Question(Serializer, models.Model):
|
||||||
options: models.Manager["Option"]
|
|
||||||
duels: models.Manager["Duel"]
|
|
||||||
|
|
||||||
election = models.ForeignKey(
|
election = models.ForeignKey(
|
||||||
Election, related_name="questions", on_delete=models.CASCADE
|
Election, related_name="questions", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
@ -128,42 +123,22 @@ class Question(Serializer, models.Model):
|
||||||
|
|
||||||
serializable_fields = ["text_en", "text_fr", "type"]
|
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])
|
validate_function = getattr(ValidateFunctions, VALIDATE_FUNCTIONS[self.type])
|
||||||
return vote_form.is_valid() and validate_function(vote_form)
|
return vote_form.is_valid() and validate_function(vote_form)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def cast_ballot(self, user: "User", vote_form) -> None:
|
def cast_ballot(self, user, vote_form):
|
||||||
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
|
cast_function = getattr(CastFunctions, CAST_FUNCTIONS[self.type])
|
||||||
cast_function(user, vote_form)
|
cast_function(user, vote_form)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def tally(self) -> None:
|
def tally(self):
|
||||||
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
|
tally_function = getattr(TallyFunctions, TALLY_FUNCTIONS[self.type])
|
||||||
tally_function(self)
|
tally_function(self)
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def pseudonymize(self):
|
|
||||||
"""
|
|
||||||
Generates a random id for each voter
|
|
||||||
"""
|
|
||||||
|
|
||||||
options = list(self.options.prefetch_related("vote_set"))
|
|
||||||
votes: set[Vote] = set()
|
|
||||||
|
|
||||||
for v in self.voters.all():
|
|
||||||
pseudonym = generate_password(16)
|
|
||||||
|
|
||||||
for opt in options:
|
|
||||||
for vote in opt.vote_set.filter(user=v):
|
|
||||||
vote.pseudonymous_user = pseudonym
|
|
||||||
vote.user = None
|
|
||||||
votes.add(vote)
|
|
||||||
|
|
||||||
Vote.objects.bulk_update(votes, ["pseudonymous_user", "user"])
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def results(self) -> str:
|
def results(self):
|
||||||
return render_to_string(
|
return render_to_string(
|
||||||
f"elections/results/{self.vote_type}_export.txt", {"question": self}
|
f"elections/results/{self.vote_type}_export.txt", {"question": self}
|
||||||
)
|
)
|
||||||
|
@ -185,16 +160,14 @@ class Question(Serializer, models.Model):
|
||||||
def vote_type(self):
|
def vote_type(self):
|
||||||
return BALLOT_TYPE[self.type]
|
return BALLOT_TYPE[self.type]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self):
|
||||||
return str(self.text)
|
return self.text
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["id"]
|
ordering = ["id"]
|
||||||
|
|
||||||
|
|
||||||
class Option(Serializer, models.Model):
|
class Option(Serializer, models.Model):
|
||||||
vote_set: models.Manager["Vote"]
|
|
||||||
|
|
||||||
question = models.ForeignKey(
|
question = models.ForeignKey(
|
||||||
Question, related_name="options", on_delete=models.CASCADE
|
Question, related_name="options", on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
|
@ -205,7 +178,7 @@ class Option(Serializer, models.Model):
|
||||||
voters = models.ManyToManyField(
|
voters = models.ManyToManyField(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
related_name="votes",
|
related_name="votes",
|
||||||
through="elections.Vote",
|
through="Vote",
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
# For now, we store the amount of votes received after the election is tallied
|
# For now, we store the amount of votes received after the election is tallied
|
||||||
|
@ -219,13 +192,13 @@ class Option(Serializer, models.Model):
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_abbr(self, default: str) -> str:
|
def get_abbr(self, default):
|
||||||
return self.abbreviation or default
|
return self.abbreviation or default
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self):
|
||||||
if self.abbreviation:
|
if self.abbreviation:
|
||||||
return f"{self.abbreviation} - {self.text}"
|
return self.abbreviation + " - " + self.text
|
||||||
return str(self.text)
|
return self.text
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["id"]
|
ordering = ["id"]
|
||||||
|
@ -233,22 +206,12 @@ class Option(Serializer, models.Model):
|
||||||
|
|
||||||
class Vote(models.Model):
|
class Vote(models.Model):
|
||||||
option = models.ForeignKey(Option, on_delete=models.CASCADE)
|
option = models.ForeignKey(Option, on_delete=models.CASCADE)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True
|
|
||||||
)
|
|
||||||
pseudonymous_user = models.CharField(max_length=16, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["option"]
|
ordering = ["option"]
|
||||||
|
|
||||||
|
|
||||||
class RankedVote(Vote):
|
|
||||||
rank: "Rank"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class Rank(models.Model):
|
class Rank(models.Model):
|
||||||
vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
|
vote = models.OneToOneField(Vote, on_delete=models.CASCADE)
|
||||||
rank = models.PositiveSmallIntegerField(_("rang de l'option"))
|
rank = models.PositiveSmallIntegerField(_("rang de l'option"))
|
||||||
|
@ -275,11 +238,7 @@ class Duel(models.Model):
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(Serializer, AbstractUser):
|
||||||
cast_elections: "ManyRelatedManager[Election]"
|
|
||||||
cast_questions: "ManyRelatedManager[Question]"
|
|
||||||
votes: "ManyRelatedManager[Vote]"
|
|
||||||
|
|
||||||
election = models.ForeignKey(
|
election = models.ForeignKey(
|
||||||
Election,
|
Election,
|
||||||
related_name="registered_voters",
|
related_name="registered_voters",
|
||||||
|
@ -290,31 +249,31 @@ class User(AbstractUser):
|
||||||
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
|
full_name = models.CharField(_("Nom et Prénom"), max_length=150, blank=True)
|
||||||
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
|
has_valid_email = models.BooleanField(_("email valide"), null=True, default=None)
|
||||||
|
|
||||||
|
serializable_fields = ["username", "email", "is_staff"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_username(self) -> str:
|
def base_username(self):
|
||||||
return "__".join(self.username.split("__")[1:])
|
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
|
# Si c'est un·e utilisateur·ice CAS, iel peut voter dans les élections
|
||||||
# ouvertes à tou·te·s
|
# ouvertes à tou·te·s
|
||||||
if self.election is None:
|
if self.election is None:
|
||||||
# If the user is connected via CAS, request.session["CASCONNECTED"] is set
|
# If the user is connected via CAS, request.session["CASCONNECTED"] is set
|
||||||
# to True by authens
|
# to True by authens
|
||||||
return not election.restricted and request.session.get(
|
return not election.restricted and request.session.get("CASCONNECTED")
|
||||||
"CASCONNECTED", False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Pour les élections restreintes, il faut y être associé
|
# Pour les élections restreintes, il faut y être associé
|
||||||
return election.restricted and (self.election == election)
|
return election.restricted and (self.election == election)
|
||||||
|
|
||||||
def is_admin(self, election: Election) -> bool:
|
def is_admin(self, election):
|
||||||
return election.created_by == self or self.is_staff
|
return election.created_by == self or self.is_staff
|
||||||
|
|
||||||
def get_prefix(self) -> str:
|
def get_prefix(self):
|
||||||
return self.username.split("__")[0]
|
return self.username.split("__")[0]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connection_method(self) -> "_StrPromise":
|
def connection_method(self):
|
||||||
method = self.username.split("__")[0]
|
method = self.username.split("__")[0]
|
||||||
return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))
|
return CONNECTION_METHODS.get(method, _("identifiants spécifiques"))
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,27 @@
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
MAIL_VOTERS = """Dear {full_name},
|
MAIL_VOTERS = (
|
||||||
|
"Dear {full_name},\n"
|
||||||
|
"\n"
|
||||||
|
"\n"
|
||||||
|
"Election URL: {election_url}\n"
|
||||||
|
"The election will take place from {start} to {end}.\n"
|
||||||
|
"\n"
|
||||||
|
"Your voter ID: {username}\n"
|
||||||
|
"Your password: {password}\n"
|
||||||
|
"\n"
|
||||||
|
"-- \n"
|
||||||
|
"Kadenios"
|
||||||
|
)
|
||||||
|
|
||||||
Election URL: {election_url}
|
MAIL_VOTE_DELETED = (
|
||||||
The election will take place from {start} to {end}.
|
"Dear {full_name},\n"
|
||||||
|
"\n"
|
||||||
Your voter ID: {username}
|
"Your vote for {election_name} has been removed."
|
||||||
Your password: {password}
|
"\n"
|
||||||
|
"-- \n"
|
||||||
--
|
"Kadenios"
|
||||||
Kadenios
|
)
|
||||||
"""
|
|
||||||
|
|
||||||
MAIL_VOTE_DELETED = """Dear {full_name},
|
|
||||||
|
|
||||||
Your vote for {election_name} has been removed.
|
|
||||||
|
|
||||||
--
|
|
||||||
Kadenios
|
|
||||||
"""
|
|
||||||
|
|
||||||
QUESTION_TYPES = [
|
QUESTION_TYPES = [
|
||||||
("assentiment", _("Assentiment")),
|
("assentiment", _("Assentiment")),
|
||||||
|
|
|
@ -5,16 +5,8 @@ from .utils import send_mail
|
||||||
|
|
||||||
|
|
||||||
@background
|
@background
|
||||||
def send_election_mail(election_pk: int, subject: str, body: str, reply_to: str):
|
def send_election_mail(election_pk, subject, body, reply_to):
|
||||||
election = Election.objects.get(pk=election_pk)
|
election = Election.objects.get(pk=election_pk)
|
||||||
send_mail(election, subject, body, reply_to)
|
send_mail(election, subject, body, reply_to)
|
||||||
election.sent_mail = True
|
election.sent_mail = True
|
||||||
election.save(update_fields=["sent_mail"])
|
election.save(update_fields=["sent_mail"])
|
||||||
|
|
||||||
|
|
||||||
@background
|
|
||||||
def pseudonimize_election(election_pk: int):
|
|
||||||
election = Election.objects.get(pk=election_pk)
|
|
||||||
|
|
||||||
for q in election.questions.all():
|
|
||||||
q.pseudonymize()
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
@ -7,10 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from .test_utils import create_election
|
from .test_utils import create_election
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class UserTests(TestCase):
|
class UserTests(TestCase):
|
||||||
|
@ -45,11 +40,8 @@ class UserTests(TestCase):
|
||||||
session["CASCONNECTED"] = True
|
session["CASCONNECTED"] = True
|
||||||
session.save()
|
session.save()
|
||||||
|
|
||||||
assert session.session_key is not None
|
|
||||||
|
|
||||||
# On sauvegarde le cookie de session
|
# On sauvegarde le cookie de session
|
||||||
session_cookie_name = settings.SESSION_COOKIE_NAME
|
session_cookie_name = settings.SESSION_COOKIE_NAME
|
||||||
|
|
||||||
self.client.cookies[session_cookie_name] = session.session_key
|
self.client.cookies[session_cookie_name] = session.session_key
|
||||||
|
|
||||||
self.assertFalse(self.cas_user.can_vote(self.client, self.election_1))
|
self.assertFalse(self.cas_user.can_vote(self.client, self.election_1))
|
||||||
|
|
|
@ -1,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.contrib.auth.models import Permission
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .test_utils import create_election
|
from .test_utils import create_election
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class AdminViewsTest(TestCase):
|
class AdminViewsTest(TestCase):
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
from django.http.request import HttpRequest
|
|
||||||
|
|
||||||
from elections.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedRequest(HttpRequest):
|
|
||||||
user: User
|
|
|
@ -1,46 +1,31 @@
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import smtplib
|
import smtplib
|
||||||
from typing import TYPE_CHECKING, TypeGuard
|
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from networkx.algorithms.dag import ancestors, descendants
|
from networkx.algorithms.dag import ancestors, descendants
|
||||||
from numpy._typing import NDArray
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.base import File
|
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.forms import BaseFormSet
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from shared.auth.utils import generate_password
|
from shared.auth.utils import generate_password
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from elections.forms import RankVoteForm, SelectVoteForm
|
|
||||||
from elections.models import Election, Question, RankedVote, Vote
|
|
||||||
from elections.typing import User
|
|
||||||
|
|
||||||
|
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
# Classes pour différencier les différents types de questions
|
# Classes pour différencier les différents types de questions
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
def has_rank(v: "Vote") -> TypeGuard["RankedVote"]:
|
|
||||||
return hasattr(v, "rank")
|
|
||||||
|
|
||||||
|
|
||||||
class CastFunctions:
|
class CastFunctions:
|
||||||
"""Classe pour enregistrer les votes"""
|
"""Classe pour enregistrer les votes"""
|
||||||
|
|
||||||
@staticmethod
|
def cast_select(user, vote_form):
|
||||||
def cast_select(user: "User", vote_form: "BaseFormSet[SelectVoteForm]"):
|
|
||||||
"""On enregistre un vote classique"""
|
"""On enregistre un vote classique"""
|
||||||
selected, n_selected = [], []
|
selected, n_selected = [], []
|
||||||
for v in vote_form:
|
for v in vote_form:
|
||||||
|
@ -52,8 +37,7 @@ class CastFunctions:
|
||||||
user.votes.add(*selected)
|
user.votes.add(*selected)
|
||||||
user.votes.remove(*n_selected)
|
user.votes.remove(*n_selected)
|
||||||
|
|
||||||
@staticmethod
|
def cast_rank(user, vote_form):
|
||||||
def cast_rank(user: "User", vote_form: "BaseFormSet[RankVoteForm]"):
|
|
||||||
"""On enregistre un vote par classement"""
|
"""On enregistre un vote par classement"""
|
||||||
from .models import Rank, Vote
|
from .models import Rank, Vote
|
||||||
|
|
||||||
|
@ -69,8 +53,7 @@ class CastFunctions:
|
||||||
|
|
||||||
for v in vote_form:
|
for v in vote_form:
|
||||||
vote = votes[v.instance]
|
vote = votes[v.instance]
|
||||||
|
if hasattr(vote, "rank"):
|
||||||
if has_rank(vote):
|
|
||||||
vote.rank.rank = v.cleaned_data["rank"]
|
vote.rank.rank = v.cleaned_data["rank"]
|
||||||
ranks_update.append(vote.rank)
|
ranks_update.append(vote.rank)
|
||||||
else:
|
else:
|
||||||
|
@ -83,8 +66,7 @@ class CastFunctions:
|
||||||
class TallyFunctions:
|
class TallyFunctions:
|
||||||
"""Classe pour gérer les dépouillements"""
|
"""Classe pour gérer les dépouillements"""
|
||||||
|
|
||||||
@staticmethod
|
def tally_select(question):
|
||||||
def tally_select(question: "Question") -> None:
|
|
||||||
"""On dépouille un vote classique"""
|
"""On dépouille un vote classique"""
|
||||||
from .models import Option
|
from .models import Option
|
||||||
|
|
||||||
|
@ -104,8 +86,7 @@ class TallyFunctions:
|
||||||
|
|
||||||
Option.objects.bulk_update(options, ["nb_votes", "winner"])
|
Option.objects.bulk_update(options, ["nb_votes", "winner"])
|
||||||
|
|
||||||
@staticmethod
|
def tally_schultze(question):
|
||||||
def tally_schultze(question: "Question") -> None:
|
|
||||||
"""On dépouille un vote par classement et on crée la matrice des duels"""
|
"""On dépouille un vote par classement et on crée la matrice des duels"""
|
||||||
from .models import Duel, Option, Rank
|
from .models import Duel, Option, Rank
|
||||||
|
|
||||||
|
@ -121,12 +102,12 @@ class TallyFunctions:
|
||||||
else:
|
else:
|
||||||
ranks_by_user[user] = [r]
|
ranks_by_user[user] = [r]
|
||||||
|
|
||||||
ballots: list[NDArray[np.int_]] = []
|
ballots = []
|
||||||
|
|
||||||
# Pour chaque votant·e, on regarde son classement
|
# Pour chaque votant·e, on regarde son classement
|
||||||
for user in ranks_by_user:
|
for user in ranks_by_user:
|
||||||
votes = ranks_by_user[user]
|
votes = ranks_by_user[user]
|
||||||
ballot = np.zeros((nb_options, nb_options), dtype=int)
|
ballot = np.zeros((nb_options, nb_options))
|
||||||
|
|
||||||
for i in range(nb_options):
|
for i in range(nb_options):
|
||||||
for j in range(i):
|
for j in range(i):
|
||||||
|
@ -140,9 +121,6 @@ class TallyFunctions:
|
||||||
# des duels
|
# des duels
|
||||||
duels = sum(ballots)
|
duels = sum(ballots)
|
||||||
|
|
||||||
# As ballots is not empty, sum cannot be 0
|
|
||||||
assert duels != 0
|
|
||||||
|
|
||||||
# Configuration du graphe
|
# Configuration du graphe
|
||||||
graph = nx.DiGraph()
|
graph = nx.DiGraph()
|
||||||
|
|
||||||
|
@ -185,11 +163,11 @@ class TallyFunctions:
|
||||||
# le plus faible
|
# le plus faible
|
||||||
min_weight = min(nx.get_edge_attributes(graph, "weight").values())
|
min_weight = min(nx.get_edge_attributes(graph, "weight").values())
|
||||||
min_edges = []
|
min_edges = []
|
||||||
for u, v in graph.edges():
|
for (u, v) in graph.edges():
|
||||||
if graph[u][v]["weight"] == min_weight:
|
if graph[u][v]["weight"] == min_weight:
|
||||||
min_edges.append((u, v))
|
min_edges.append((u, v))
|
||||||
|
|
||||||
for u, v in min_edges:
|
for (u, v) in min_edges:
|
||||||
graph.remove_edge(u, v)
|
graph.remove_edge(u, v)
|
||||||
|
|
||||||
# Les options gagnantes sont celles encore présentes dans le graphe
|
# Les options gagnantes sont celles encore présentes dans le graphe
|
||||||
|
@ -203,31 +181,29 @@ class TallyFunctions:
|
||||||
class ValidateFunctions:
|
class ValidateFunctions:
|
||||||
"""Classe pour valider les formsets selon le type de question"""
|
"""Classe pour valider les formsets selon le type de question"""
|
||||||
|
|
||||||
@staticmethod
|
def always_true(vote_form):
|
||||||
def always_true(_) -> bool:
|
"""Retourne True pour les votes sans validation particulière"""
|
||||||
"""Renvoie True pour les votes sans validation particulière"""
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
def unique_selected(vote_form):
|
||||||
def unique_selected(vote_form: "BaseFormSet[SelectVoteForm]") -> bool:
|
|
||||||
"""Vérifie qu'une seule option est choisie"""
|
"""Vérifie qu'une seule option est choisie"""
|
||||||
|
nb_selected = 0
|
||||||
nb_selected = sum(v.cleaned_data["selected"] for v in vote_form)
|
for v in vote_form:
|
||||||
|
nb_selected += v.cleaned_data["selected"]
|
||||||
|
|
||||||
if nb_selected == 0:
|
if nb_selected == 0:
|
||||||
vote_form._non_form_errors.append( # pyright: ignore
|
vote_form._non_form_errors.append(
|
||||||
ValidationError(_("Vous devez sélectionnner une option."))
|
ValidationError(_("Vous devez sélectionnner une option."))
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
elif nb_selected > 1:
|
elif nb_selected > 1:
|
||||||
vote_form._non_form_errors.append( # pyright: ignore
|
vote_form._non_form_errors.append(
|
||||||
ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option."))
|
ValidationError(_("Vous ne pouvez pas sélectionner plus d'une option."))
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
def limit_ranks(vote_form):
|
||||||
def limit_ranks(vote_form: "BaseFormSet[RankVoteForm]"):
|
|
||||||
"""Limite le classement au nombre d'options"""
|
"""Limite le classement au nombre d'options"""
|
||||||
nb_options = len(vote_form)
|
nb_options = len(vote_form)
|
||||||
valid = True
|
valid = True
|
||||||
|
@ -253,13 +229,11 @@ class ValidateFunctions:
|
||||||
class ResultsData:
|
class ResultsData:
|
||||||
"""Classe pour afficher des informations supplémentaires après la fin d'une élection"""
|
"""Classe pour afficher des informations supplémentaires après la fin d'une élection"""
|
||||||
|
|
||||||
@staticmethod
|
def select(question):
|
||||||
def select(_: "Question") -> str:
|
|
||||||
"""On renvoie l'explication des couleurs"""
|
"""On renvoie l'explication des couleurs"""
|
||||||
return render_to_string("elections/results/select.html")
|
return render_to_string("elections/results/select.html")
|
||||||
|
|
||||||
@staticmethod
|
def rank(question):
|
||||||
def rank(question: "Question") -> str:
|
|
||||||
"""On récupère la matrice des résultats et on l'affiche"""
|
"""On récupère la matrice des résultats et on l'affiche"""
|
||||||
duels = question.duels.all()
|
duels = question.duels.all()
|
||||||
options = list(question.options.all())
|
options = list(question.options.all())
|
||||||
|
@ -296,40 +270,38 @@ class ResultsData:
|
||||||
class BallotsData:
|
class BallotsData:
|
||||||
"""Classe pour afficher les bulletins d'une question"""
|
"""Classe pour afficher les bulletins d'une question"""
|
||||||
|
|
||||||
@staticmethod
|
def select(question):
|
||||||
def select(question: "Question") -> str:
|
|
||||||
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
|
"""Renvoie un tableau affichant les options sélectionnées pour chaque bulletin"""
|
||||||
from .models import Vote
|
from .models import Vote
|
||||||
|
|
||||||
votes = Vote.objects.filter(option__question=question)
|
votes = Vote.objects.filter(option__question=question).select_related("user")
|
||||||
|
|
||||||
options = list(question.options.all())
|
options = list(question.options.all())
|
||||||
|
|
||||||
ballots = {}
|
ballots = {}
|
||||||
for v in votes:
|
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
|
ballot[options.index(v.option)] = True
|
||||||
|
|
||||||
ballots[v.pseudonymous_user] = ballot
|
ballots[v.user] = ballot
|
||||||
|
|
||||||
return render_to_string(
|
return render_to_string(
|
||||||
"elections/ballots/select.html",
|
"elections/ballots/select.html",
|
||||||
{"options": options, "ballots": sorted(ballots.values(), reverse=True)},
|
{"options": options, "ballots": sorted(ballots.values(), reverse=True)},
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def rank(question):
|
||||||
def rank(question: "Question") -> str:
|
|
||||||
"""Renvoie un tableau contenant les classements des options par bulletin"""
|
"""Renvoie un tableau contenant les classements des options par bulletin"""
|
||||||
from .models import Rank
|
from .models import Rank
|
||||||
|
|
||||||
options = list(question.options.all())
|
options = list(question.options.all())
|
||||||
ranks = Rank.objects.select_related("vote").filter(
|
ranks = Rank.objects.select_related("vote__user").filter(
|
||||||
vote__option__in=options
|
vote__option__in=options
|
||||||
)
|
)
|
||||||
ranks_by_user = {}
|
ranks_by_user = {}
|
||||||
|
|
||||||
for r in ranks:
|
for r in ranks:
|
||||||
user = r.vote.pseudonymous_user
|
user = r.vote.user
|
||||||
if user in ranks_by_user:
|
if user in ranks_by_user:
|
||||||
ranks_by_user[user].append(r.rank)
|
ranks_by_user[user].append(r.rank)
|
||||||
else:
|
else:
|
||||||
|
@ -346,7 +318,7 @@ class BallotsData:
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
def create_users(election: "Election", csv_file: File):
|
def create_users(election, csv_file):
|
||||||
"""Crée les votant·e·s pour l'élection donnée, en remplissant les champs
|
"""Crée les votant·e·s pour l'élection donnée, en remplissant les champs
|
||||||
`username`, `election` et `full_name`.
|
`username`, `election` et `full_name`.
|
||||||
"""
|
"""
|
||||||
|
@ -359,9 +331,10 @@ def create_users(election: "Election", csv_file: File):
|
||||||
users = [
|
users = [
|
||||||
User(
|
User(
|
||||||
election=election,
|
election=election,
|
||||||
username=f"{election.pk}__{username}",
|
username=f"{election.id}__{username}",
|
||||||
email=email,
|
email=email,
|
||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
|
is_active=False,
|
||||||
)
|
)
|
||||||
for (username, full_name, email) in reader
|
for (username, full_name, email) in reader
|
||||||
]
|
]
|
||||||
|
@ -369,7 +342,7 @@ def create_users(election: "Election", csv_file: File):
|
||||||
User.objects.bulk_create(users)
|
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é"""
|
"""Vérifie que le fichier donnant la liste de votant·e·s est bien formé"""
|
||||||
try:
|
try:
|
||||||
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
|
dialect = csv.Sniffer().sniff(csv_file.readline().decode("utf-8"))
|
||||||
|
@ -422,14 +395,15 @@ def check_csv(csv_file: File):
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> None:
|
def send_mail(election, subject, body, reply_to):
|
||||||
"""Envoie le mail d'annonce de l'élection avec identifiants et mot de passe
|
"""Envoie le mail d'annonce de l'élection avec identifiants et mot de passe
|
||||||
aux votant·e·s, le mdp est généré en même temps que le mail est envoyé.
|
aux votant·e·s, le mdp est généré en même temps que le mail est envoyé.
|
||||||
"""
|
"""
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
# On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un
|
# On n'envoie le mail qu'aux personnes qui n'en n'ont pas déjà reçu un
|
||||||
voters = list(election.registered_voters.exclude(has_valid_email=True))
|
voters = list(election.registered_voters.exclude(has_valid_email=True))
|
||||||
e_url = reverse("election.view", args=[election.pk])
|
e_url = reverse("election.view", args=[election.id])
|
||||||
url = f"https://vote.eleves.ens.fr{e_url}"
|
url = f"https://vote.eleves.ens.fr{e_url}"
|
||||||
start = election.start_date.strftime("%d/%m/%Y %H:%M %Z")
|
start = election.start_date.strftime("%d/%m/%Y %H:%M %Z")
|
||||||
end = election.end_date.strftime("%d/%m/%Y %H:%M %Z")
|
end = election.end_date.strftime("%d/%m/%Y %H:%M %Z")
|
||||||
|
@ -452,17 +426,18 @@ def send_mail(election: "Election", subject: str, body: str, reply_to: str) -> N
|
||||||
to=[v.email],
|
to=[v.email],
|
||||||
reply_to=[reply_to],
|
reply_to=[reply_to],
|
||||||
# On modifie l'adresse de retour d'erreur
|
# On modifie l'adresse de retour d'erreur
|
||||||
headers={"From": "Kadenios <klub-dev@ens.fr>"},
|
headers={"Return-Path": "kadenios@www.eleves.ens.fr"},
|
||||||
),
|
),
|
||||||
v,
|
v,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
for m, v in messages:
|
for (m, v) in messages:
|
||||||
try:
|
try:
|
||||||
m.send()
|
m.send()
|
||||||
v.has_valid_email = True
|
|
||||||
except smtplib.SMTPException:
|
except smtplib.SMTPException:
|
||||||
v.has_valid_email = False
|
v.has_valid_email = False
|
||||||
|
else:
|
||||||
|
v.has_valid_email = True
|
||||||
|
|
||||||
v.save()
|
User.objects.bulk_update(voters, ["password", "has_valid_email"])
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import csv
|
import csv
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
@ -19,7 +19,6 @@ from django.views.generic import (
|
||||||
View,
|
View,
|
||||||
)
|
)
|
||||||
|
|
||||||
from elections.typing import AuthenticatedRequest
|
|
||||||
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
|
from shared.json import JsonCreateView, JsonDeleteView, JsonUpdateView
|
||||||
from shared.views import BackgroundUpdateView, TimeMixin
|
from shared.views import BackgroundUpdateView, TimeMixin
|
||||||
|
|
||||||
|
@ -41,15 +40,10 @@ from .mixins import (
|
||||||
)
|
)
|
||||||
from .models import Election, Option, Question, Vote
|
from .models import Election, Option, Question, Vote
|
||||||
from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES
|
from .staticdefs import MAIL_VOTE_DELETED, MAIL_VOTERS, QUESTION_TYPES, VOTE_RULES
|
||||||
from .tasks import pseudonimize_election, send_election_mail
|
from .tasks import send_election_mail
|
||||||
from .utils import create_users
|
from .utils import create_users
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
# TODO: access control *everywhere*
|
# TODO: access control *everywhere*
|
||||||
|
|
||||||
|
@ -59,8 +53,6 @@ else:
|
||||||
|
|
||||||
|
|
||||||
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
||||||
object: Election
|
|
||||||
|
|
||||||
model = Election
|
model = Election
|
||||||
form_class = ElectionForm
|
form_class = ElectionForm
|
||||||
success_message = _("Élection créée avec succès !")
|
success_message = _("Élection créée avec succès !")
|
||||||
|
@ -69,13 +61,15 @@ class ElectionCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse("election.admin", args=[self.object.pk])
|
return reverse("election.admin", args=[self.object.pk])
|
||||||
|
|
||||||
def form_valid(self, form: ElectionForm):
|
def form_valid(self, form):
|
||||||
# We need to add the short name and the creator od the election
|
# We need to add the short name and the creator od the election
|
||||||
form.instance.short_name = slugify(
|
form.instance.short_name = slugify(
|
||||||
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
|
form.instance.start_date.strftime("%Y-%m-%d") + "_" + form.instance.name
|
||||||
)[:50]
|
)[:50]
|
||||||
# TODO: Change this if we modify the user model
|
# TODO: Change this if we modify the user model
|
||||||
form.instance.created_by = self.request.user
|
form.instance.created_by = self.request.user
|
||||||
|
|
||||||
|
self.log_info("Election created", data={"election": form.instance.get_data()})
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,22 +77,25 @@ class ElectionDeleteView(CreatorOnlyMixin, BackgroundUpdateView):
|
||||||
model = Election
|
model = Election
|
||||||
pattern_name = "election.list"
|
pattern_name = "election.list"
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self, queryset=None):
|
||||||
obj: Election = super().get_object()
|
obj = self.get_object()
|
||||||
# On ne peut supprimer que les élections n'ayant pas eu de vote et dont
|
# On ne peut supprimer que les élections n'ayant pas eu de vote et dont
|
||||||
# le mail d'annonce n'a pas été fait
|
# le mail d'annonce n'a pas été fait
|
||||||
if obj.voters.exists() or obj.sent_mail:
|
if obj.voters.exists() or obj.send_election_mail:
|
||||||
|
self.log_warn("Cannot delete election")
|
||||||
raise Http404
|
raise Http404
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
self.get_object().delete()
|
obj = self.get_object()
|
||||||
|
|
||||||
|
self.log_info("Election deleted", data={"election": obj.get_data()})
|
||||||
|
|
||||||
|
obj.delete()
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
|
class ElectionAdminView(CreatorOnlyMixin, TimeMixin, DetailView):
|
||||||
object: Election
|
|
||||||
|
|
||||||
model = Election
|
model = Election
|
||||||
template_name = "elections/election_admin.html"
|
template_name = "elections/election_admin.html"
|
||||||
|
|
||||||
|
@ -125,9 +122,14 @@ class ElectionSetVisibleView(CreatorOnlyMixin, BackgroundUpdateView):
|
||||||
success_message = _("Élection visible !")
|
success_message = _("Élection visible !")
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
self.election: Election = self.get_object()
|
self.election = self.get_object()
|
||||||
self.election.visible = True
|
self.election.visible = True
|
||||||
self.election.save()
|
self.election.save()
|
||||||
|
|
||||||
|
self.log_info(
|
||||||
|
"Election set to visible", data={"election": self.election.get_data()}
|
||||||
|
)
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -141,9 +143,13 @@ class ExportVotersView(CreatorOnlyMixin, View):
|
||||||
response["Content-Disposition"] = "attachment; filename=voters.csv"
|
response["Content-Disposition"] = "attachment; filename=voters.csv"
|
||||||
writer.writerow(["Nom", "login"])
|
writer.writerow(["Nom", "login"])
|
||||||
|
|
||||||
for v in self.get_object().voters.all():
|
obj = self.get_object()
|
||||||
|
|
||||||
|
for v in obj.voters.all():
|
||||||
writer.writerow([v.full_name, v.base_username])
|
writer.writerow([v.full_name, v.base_username])
|
||||||
|
|
||||||
|
self.log_info("Voters exported", data={"election": obj.get_data()})
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -178,6 +184,9 @@ class ElectionUploadVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormVi
|
||||||
# existant déjà pour ne pas avoir de doublons
|
# existant déjà pour ne pas avoir de doublons
|
||||||
self.object.registered_voters.all().delete()
|
self.object.registered_voters.all().delete()
|
||||||
create_users(self.object, form.cleaned_data["csv_file"])
|
create_users(self.object, form.cleaned_data["csv_file"])
|
||||||
|
|
||||||
|
self.log_info("Voters imported", data={"election": self.object.get_data()})
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -215,6 +224,11 @@ class ElectionMailVotersView(CreatorOnlyEditMixin, SuccessMessageMixin, FormView
|
||||||
body=form.cleaned_data["message"],
|
body=form.cleaned_data["message"],
|
||||||
reply_to=self.request.user.email,
|
reply_to=self.request.user.email,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.log_info(
|
||||||
|
"Started sending e-mails", data={"election": self.object.get_data()}
|
||||||
|
)
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -226,7 +240,7 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
def get_form(self, form_class=None):
|
||||||
form = super().get_form(form_class)
|
form = super().get_form(form_class)
|
||||||
if self.object.sent_mail:
|
if self.object.sent_mail or self.object.sent_mail is None:
|
||||||
form.fields["restricted"].disabled = True
|
form.fields["restricted"].disabled = True
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
@ -238,12 +252,13 @@ class ElectionUpdateView(CreatorOnlyEditMixin, SuccessMessageMixin, UpdateView):
|
||||||
# pré-enregistré·e·s
|
# pré-enregistré·e·s
|
||||||
if not form.cleaned_data["restricted"]:
|
if not form.cleaned_data["restricted"]:
|
||||||
self.object.registered_voters.all().delete()
|
self.object.registered_voters.all().delete()
|
||||||
|
|
||||||
|
self.log_info("Updated election", data={"election": self.object.get_data()})
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
|
class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
|
||||||
voter: User
|
|
||||||
|
|
||||||
model = Election
|
model = Election
|
||||||
|
|
||||||
def get_message(self):
|
def get_message(self):
|
||||||
|
@ -276,6 +291,12 @@ class DeleteVoteView(ClosedElectionMixin, JsonDeleteView):
|
||||||
# On marque les questions comme non votées
|
# On marque les questions comme non votées
|
||||||
self.voter.cast_elections.remove(election)
|
self.voter.cast_elections.remove(election)
|
||||||
self.voter.cast_questions.remove(*list(election.questions.all()))
|
self.voter.cast_questions.remove(*list(election.questions.all()))
|
||||||
|
|
||||||
|
self.log_warn(
|
||||||
|
"Vote deleted",
|
||||||
|
data={"election": election.get_data(), "voter": self.voter.get_data()},
|
||||||
|
)
|
||||||
|
|
||||||
return self.render_to_json(action="delete")
|
return self.render_to_json(action="delete")
|
||||||
|
|
||||||
|
|
||||||
|
@ -301,7 +322,7 @@ class ElectionTallyView(ClosedElectionMixin, BackgroundUpdateView):
|
||||||
election.time_tallied = timezone.now()
|
election.time_tallied = timezone.now()
|
||||||
election.save()
|
election.save()
|
||||||
|
|
||||||
pseudonimize_election(election.pk)
|
self.log_info("Election tallied", data={"election": election.get_data()})
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -323,6 +344,14 @@ class ElectionChangePublicationView(ClosedElectionMixin, BackgroundUpdateView):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.election.save()
|
self.election.save()
|
||||||
|
|
||||||
|
self.log_info(
|
||||||
|
"Election published"
|
||||||
|
if self.election.results_public
|
||||||
|
else "Election unpublished",
|
||||||
|
data={"election": self.election.get_data()},
|
||||||
|
)
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -333,11 +362,15 @@ class DownloadResultsView(CreatorOnlyMixin, View):
|
||||||
return super().get_queryset().filter(tallied=True)
|
return super().get_queryset().filter(tallied=True)
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
content = "\n".join([q.results for q in self.get_object().questions.all()])
|
obj = self.get_object()
|
||||||
|
|
||||||
|
content = "\n".join([q.results for q in obj.questions.all()])
|
||||||
|
|
||||||
response = HttpResponse(content, content_type="text/plain")
|
response = HttpResponse(content, content_type="text/plain")
|
||||||
response["Content-Disposition"] = "attachment; filename=results.txt"
|
response["Content-Disposition"] = "attachment; filename=results.txt"
|
||||||
|
|
||||||
|
self.log_info("Results downloaded", data={"election": obj.get_data()})
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -350,6 +383,9 @@ class ElectionArchiveView(ClosedElectionMixin, BackgroundUpdateView):
|
||||||
election = self.get_object()
|
election = self.get_object()
|
||||||
election.archived = True
|
election.archived = True
|
||||||
election.save()
|
election.save()
|
||||||
|
|
||||||
|
self.log_info("Election archived", data={"election": election.get_data()})
|
||||||
|
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -431,7 +467,7 @@ class ElectionView(NotArchivedMixin, DetailView):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["current_time"] = timezone.now()
|
context["current_time"] = timezone.now()
|
||||||
|
|
||||||
if user.is_authenticated and isinstance(user, User):
|
if user.is_authenticated:
|
||||||
context["can_vote"] = user.can_vote(self.request, context["election"])
|
context["can_vote"] = user.can_vote(self.request, context["election"])
|
||||||
context["cast_questions"] = user.cast_questions.all()
|
context["cast_questions"] = user.cast_questions.all()
|
||||||
context["has_voted"] = user.cast_elections.filter(
|
context["has_voted"] = user.cast_elections.filter(
|
||||||
|
@ -459,7 +495,7 @@ class ElectionVotersView(NotArchivedMixin, DetailView):
|
||||||
election = context["election"]
|
election = context["election"]
|
||||||
voters = list(election.voters.all())
|
voters = list(election.voters.all())
|
||||||
|
|
||||||
if user.is_authenticated and isinstance(user, User):
|
if user.is_authenticated:
|
||||||
context["can_vote"] = user.can_vote(self.request, context["election"])
|
context["can_vote"] = user.can_vote(self.request, context["election"])
|
||||||
context["is_admin"] = user.is_admin(election)
|
context["is_admin"] = user.is_admin(election)
|
||||||
can_delete = (
|
can_delete = (
|
||||||
|
@ -491,8 +527,6 @@ class ElectionBallotsView(NotArchivedMixin, DetailView):
|
||||||
|
|
||||||
|
|
||||||
class VoteView(OpenElectionOnlyMixin, DetailView):
|
class VoteView(OpenElectionOnlyMixin, DetailView):
|
||||||
request: AuthenticatedRequest
|
|
||||||
|
|
||||||
model = Question
|
model = Question
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
|
5
kadenios/apps.py
Normal file
5
kadenios/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.contrib.staticfiles.apps import StaticFilesConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
|
||||||
|
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]
|
1
kadenios/settings/.gitignore
vendored
Normal file
1
kadenios/settings/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
secret.py
|
0
kadenios/settings/__init__.py
Normal file
0
kadenios/settings/__init__.py
Normal file
151
kadenios/settings/common.py
Normal file
151
kadenios/settings/common.py
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
"""
|
||||||
|
Paramètres communs entre dev et prod
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Secrets
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import secret
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"The secret.py file is missing.\n"
|
||||||
|
"For a development environment, simply copy secret_example.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_secret(name):
|
||||||
|
"""
|
||||||
|
Shorthand for importing a value from the secret module and raising an
|
||||||
|
informative exception if a secret is missing.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return getattr(secret, name)
|
||||||
|
except AttributeError:
|
||||||
|
raise RuntimeError("Secret missing: {}".format(name))
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = import_secret("SECRET_KEY")
|
||||||
|
ADMINS = import_secret("ADMINS")
|
||||||
|
SERVER_EMAIL = import_secret("SERVER_EMAIL")
|
||||||
|
EMAIL_HOST = import_secret("EMAIL_HOST")
|
||||||
|
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres par défaut pour Django
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
TESTING = len(sys.argv) > 1 and sys.argv[1] == "test"
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"kadenios.apps.IgnoreSrcStaticFilesConfig",
|
||||||
|
"background_task",
|
||||||
|
"shared",
|
||||||
|
"elections",
|
||||||
|
"faqs",
|
||||||
|
"authens",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.locale.LocaleMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "kadenios.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "kadenios.wsgi.application"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
|
DEFAULT_FROM_EMAIL = "Kadenios <klub-dev@ens.fr>"
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres d'authentification
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = "elections.User"
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"shared.auth.backends.PwdBackend",
|
||||||
|
"shared.auth.backends.CASBackend",
|
||||||
|
"shared.auth.backends.ElectionBackend",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOGIN_URL = reverse_lazy("authens:login")
|
||||||
|
LOGIN_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
AUTHENS_USE_OLDCAS = False
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres de langage
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "fr-fr"
|
||||||
|
TIME_ZONE = "Europe/Paris"
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
LANGUAGES = [
|
||||||
|
("fr", _("Français")),
|
||||||
|
("en", _("Anglais")),
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCALE_PATHS = [os.path.join(BASE_DIR, "shared", "locale")]
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres des fichiers statiques
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
55
kadenios/settings/local.py
Normal file
55
kadenios/settings/local.py
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
"""
|
||||||
|
Paramètre pour le développement local
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .common import * # noqa
|
||||||
|
from .common import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres Django
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use the default cache backend for local development
|
||||||
|
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
|
|
||||||
|
# Pas besoin de sécurité en local
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres pour la Django Debug Toolbar
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def show_toolbar(request):
|
||||||
|
"""
|
||||||
|
On active la debug-toolbar en mode développement local sauf :
|
||||||
|
- dans l'admin où ça ne sert pas à grand chose;
|
||||||
|
- si la variable d'environnement DJANGO_NO_DDT est à 1 → ça permet de la désactiver
|
||||||
|
sans modifier ce fichier en exécutant `export DJANGO_NO_DDT=1` dans le terminal
|
||||||
|
qui lance `./manage.py runserver`.
|
||||||
|
"""
|
||||||
|
env_no_ddt = bool(os.environ.get("DJANGO_NO_DDT", None))
|
||||||
|
return DEBUG and not env_no_ddt and not request.path.startswith("/admin/")
|
||||||
|
|
||||||
|
|
||||||
|
if not TESTING:
|
||||||
|
INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"]
|
||||||
|
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": show_toolbar}
|
68
kadenios/settings/prod.py
Normal file
68
kadenios/settings/prod.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
"""
|
||||||
|
Paramètres pour la mise en production
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .common import * # noqa
|
||||||
|
from .common import BASE_DIR, import_secret
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Secrets de production
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
REDIS_PASSWD = import_secret("REDIS_PASSWD")
|
||||||
|
REDIS_DB = import_secret("REDIS_DB")
|
||||||
|
REDIS_HOST = import_secret("REDIS_HOST")
|
||||||
|
REDIS_PORT = import_secret("REDIS_PORT")
|
||||||
|
|
||||||
|
DBNAME = import_secret("DBNAME")
|
||||||
|
DBUSER = import_secret("DBUSER")
|
||||||
|
DBPASSWD = import_secret("DBPASSWD")
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# À modifier possiblement lors de la mise en production
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["vote.eleves.ens.fr"]
|
||||||
|
|
||||||
|
STATIC_ROOT = os.path.join(os.path.dirname(BASE_DIR), "static")
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres du cache
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django_redis.cache.RedisCache",
|
||||||
|
"LOCATION": "redis://:{passwd}@{host}:{port}/{db}".format(
|
||||||
|
passwd=REDIS_PASSWD, host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres de la base de données
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||||
|
"NAME": DBNAME,
|
||||||
|
"USER": DBUSER,
|
||||||
|
"PASSWORD": DBPASSWD,
|
||||||
|
"HOST": os.environ.get("DBHOST", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Paramètres Https
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SECURE_SSL_REDIRECT = True
|
||||||
|
|
||||||
|
SECURE_HSTS_SECONDS = 31536000
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
14
kadenios/settings/secret_example.py
Normal file
14
kadenios/settings/secret_example.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
SECRET_KEY = "f*!6tw8c74)&k_&4$toiw@e=8m00xv_(tmjf9_#wq30wg_7n^8"
|
||||||
|
ADMINS = None
|
||||||
|
SERVER_EMAIL = "root@localhost"
|
||||||
|
EMAIL_HOST = None
|
||||||
|
|
||||||
|
|
||||||
|
DBUSER = "kadenios"
|
||||||
|
DBNAME = "kadenios"
|
||||||
|
DBPASSWD = "O1LxCADDA6Px5SiKvifjvdp3DSjfbp"
|
||||||
|
|
||||||
|
REDIS_PASSWD = "dummy"
|
||||||
|
REDIS_PORT = 6379
|
||||||
|
REDIS_DB = 0
|
||||||
|
REDIS_HOST = "127.0.0.1"
|
|
@ -1,5 +1,4 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
@ -17,8 +16,7 @@ urlpatterns = [
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("__reload__/", include("django_browser_reload.urls")),
|
]
|
||||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
|
||||||
|
|
||||||
if "debug_toolbar" in settings.INSTALLED_APPS:
|
if "debug_toolbar" in settings.INSTALLED_APPS:
|
||||||
from debug_toolbar import urls as djdt_urls
|
from debug_toolbar import urls as djdt_urls
|
|
@ -11,6 +11,6 @@ import os
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kadenios.settings')
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
|
@ -5,7 +5,7 @@ import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "kadenios.settings.local")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|
|
@ -1,80 +0,0 @@
|
||||||
# Generated by npins. Do not modify; will be overwritten regularly
|
|
||||||
let
|
|
||||||
data = builtins.fromJSON (builtins.readFile ./sources.json);
|
|
||||||
version = data.version;
|
|
||||||
|
|
||||||
mkSource =
|
|
||||||
spec:
|
|
||||||
assert spec ? type;
|
|
||||||
let
|
|
||||||
path =
|
|
||||||
if spec.type == "Git" then
|
|
||||||
mkGitSource spec
|
|
||||||
else if spec.type == "GitRelease" then
|
|
||||||
mkGitSource spec
|
|
||||||
else if spec.type == "PyPi" then
|
|
||||||
mkPyPiSource spec
|
|
||||||
else if spec.type == "Channel" then
|
|
||||||
mkChannelSource spec
|
|
||||||
else
|
|
||||||
builtins.throw "Unknown source type ${spec.type}";
|
|
||||||
in
|
|
||||||
spec // { outPath = path; };
|
|
||||||
|
|
||||||
mkGitSource =
|
|
||||||
{
|
|
||||||
repository,
|
|
||||||
revision,
|
|
||||||
url ? null,
|
|
||||||
hash,
|
|
||||||
branch ? null,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
assert repository ? type;
|
|
||||||
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
|
|
||||||
# In the latter case, there we will always be an url to the tarball
|
|
||||||
if url != null then
|
|
||||||
(builtins.fetchTarball {
|
|
||||||
inherit url;
|
|
||||||
sha256 = hash;
|
|
||||||
})
|
|
||||||
else
|
|
||||||
assert repository.type == "Git";
|
|
||||||
let
|
|
||||||
urlToName =
|
|
||||||
url: rev:
|
|
||||||
let
|
|
||||||
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
|
|
||||||
|
|
||||||
short = builtins.substring 0 7 rev;
|
|
||||||
|
|
||||||
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
|
|
||||||
in
|
|
||||||
"${if matched == null then "source" else builtins.head matched}${appendShort}";
|
|
||||||
name = urlToName repository.url revision;
|
|
||||||
in
|
|
||||||
builtins.fetchGit {
|
|
||||||
url = repository.url;
|
|
||||||
rev = revision;
|
|
||||||
inherit name;
|
|
||||||
narHash = hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
mkPyPiSource =
|
|
||||||
{ url, hash, ... }:
|
|
||||||
builtins.fetchurl {
|
|
||||||
inherit url;
|
|
||||||
sha256 = hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
mkChannelSource =
|
|
||||||
{ url, hash, ... }:
|
|
||||||
builtins.fetchTarball {
|
|
||||||
inherit url;
|
|
||||||
sha256 = hash;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
if version == 4 then
|
|
||||||
builtins.mapAttrs (_: mkSource) data.pins
|
|
||||||
else
|
|
||||||
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"
|
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"pins": {
|
|
||||||
"nix-pkgs": {
|
|
||||||
"type": "Git",
|
|
||||||
"repository": {
|
|
||||||
"type": "Git",
|
|
||||||
"url": "https://git.hubrecht.ovh/hubrecht/nix-pkgs.git"
|
|
||||||
},
|
|
||||||
"branch": "main",
|
|
||||||
"revision": "22e90684e355bdd1e257c661b6275c7490f8c50b",
|
|
||||||
"url": null,
|
|
||||||
"hash": "sha256-yEZAv3bK7+gxNM8/31ONwdPIXlyQ5QnNnPDnWl3bXZo="
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"type": "Channel",
|
|
||||||
"name": "nixpkgs-unstable",
|
|
||||||
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-24.11pre646460.0aeab749216e/nixexprs.tar.xz",
|
|
||||||
"hash": "0xa73bs0n28x731hf6ipqrlji0p3qf2a42vfm6g8snnhaab9mfwj"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": 4
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
[tool.pyright]
|
|
||||||
reportIncompatibleMethodOverride = false
|
|
||||||
reportIncompatibleVariableOverride = false
|
|
|
@ -4,4 +4,5 @@ authens>=0.1b2
|
||||||
markdown
|
markdown
|
||||||
numpy
|
numpy
|
||||||
networkx
|
networkx
|
||||||
|
python-csv
|
||||||
django-background-tasks
|
django-background-tasks
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
from django.contrib.staticfiles.apps import StaticFilesConfig
|
|
||||||
|
|
||||||
|
|
||||||
class IgnoreSrcStaticFilesConfig(StaticFilesConfig):
|
|
||||||
ignore_patterns = StaticFilesConfig.ignore_patterns + ["src/**"]
|
|
|
@ -1,7 +1,6 @@
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin.sites import AlreadyRegistered
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
models = apps.get_models()
|
models = apps.get_models()
|
||||||
|
@ -9,5 +8,5 @@ if settings.DEBUG:
|
||||||
for model in models:
|
for model in models:
|
||||||
try:
|
try:
|
||||||
admin.site.register(model)
|
admin.site.register(model)
|
||||||
except AlreadyRegistered:
|
except admin.sites.AlreadyRegistered:
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from .staticdefs import CONNECTION_METHODS
|
from .staticdefs import CONNECTION_METHODS
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [CONNECTION_METHODS]
|
||||||
"CONNECTION_METHODS",
|
|
||||||
]
|
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from authens.backends import ENSCASBackend
|
from authens.backends import ENSCASBackend
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class CASBackend(ENSCASBackend):
|
class CASBackend(ENSCASBackend):
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from django.contrib.auth import forms as auth_forms
|
from django.contrib.auth import forms as auth_forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class ElectionAuthForm(forms.Form):
|
class ElectionAuthForm(forms.Form):
|
||||||
|
|
|
@ -14,5 +14,6 @@ urlpatterns = [
|
||||||
"permissions", views.PermissionManagementView.as_view(), name="auth.permissions"
|
"permissions", views.PermissionManagementView.as_view(), name="auth.permissions"
|
||||||
),
|
),
|
||||||
path("accounts", views.AccountListView.as_view(), name="auth.accounts"),
|
path("accounts", views.AccountListView.as_view(), name="auth.accounts"),
|
||||||
|
path("journal", views.JournalView.as_view(), name="auth.journal"),
|
||||||
path("admins", views.AdminAccountsView.as_view(), name="auth.admins"),
|
path("admins", views.AdminAccountsView.as_view(), name="auth.admins"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,10 +4,12 @@ import random
|
||||||
# Fonctions universelles
|
# Fonctions universelles
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
||||||
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_password(size=15):
|
def generate_password(size=15):
|
||||||
random.seed()
|
random.seed()
|
||||||
|
alphabet = "abcdefghjkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
password = ""
|
||||||
|
for i in range(size):
|
||||||
|
password += random.choice(alphabet)
|
||||||
|
|
||||||
return "".join(random.choice(alphabet) for _ in range(size))
|
return password
|
||||||
|
|
|
@ -1,25 +1,20 @@
|
||||||
from typing import TYPE_CHECKING
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.db.models import QuerySet
|
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import CreateView, FormView, ListView, TemplateView
|
from django.views.generic import CreateView, FormView, ListView, TemplateView
|
||||||
|
|
||||||
from elections.typing import AuthenticatedRequest
|
from shared.mixins import LogMixin
|
||||||
|
from shared.models import Event
|
||||||
|
|
||||||
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
|
from .forms import ElectionAuthForm, PwdUserForm, UserAdminForm
|
||||||
from .utils import generate_password
|
from .utils import generate_password
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
@ -27,14 +22,12 @@ else:
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
class StaffMemberMixin(UserPassesTestMixin):
|
class StaffMemberMixin(LogMixin, UserPassesTestMixin):
|
||||||
"""
|
"""
|
||||||
Mixin permettant de restreindre l'accès aux membres `staff`, si la personne
|
Mixin permettant de restreindre l'accès aux membres `staff`, si la personne
|
||||||
n'est pas connectée, renvoie sur la page d'authentification
|
n'est pas connectée, renvoie sur la page d'authentification
|
||||||
"""
|
"""
|
||||||
|
|
||||||
request: AuthenticatedRequest
|
|
||||||
|
|
||||||
def test_func(self):
|
def test_func(self):
|
||||||
return self.request.user.is_active and self.request.user.is_staff
|
return self.request.user.is_active and self.request.user.is_staff
|
||||||
|
|
||||||
|
@ -81,6 +74,10 @@ class CreatePwdAccount(StaffMemberMixin, SuccessMessageMixin, CreateView):
|
||||||
# On enregistre un mot de passe aléatoire
|
# On enregistre un mot de passe aléatoire
|
||||||
form.instance.password = make_password(generate_password(32))
|
form.instance.password = make_password(generate_password(32))
|
||||||
|
|
||||||
|
self.log_info(
|
||||||
|
"Password account created", data={"user": form.instance.get_data()}
|
||||||
|
)
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@ -95,7 +92,7 @@ class AccountListView(StaffMemberMixin, ListView):
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
qs: QuerySet = self.get_queryset() # pyright: ignore
|
qs = self.get_queryset()
|
||||||
|
|
||||||
ctx["cas_users"] = qs.filter(username__startswith="cas__")
|
ctx["cas_users"] = qs.filter(username__startswith="cas__")
|
||||||
ctx["pwd_users"] = qs.filter(username__startswith="pwd__")
|
ctx["pwd_users"] = qs.filter(username__startswith="pwd__")
|
||||||
|
@ -153,21 +150,34 @@ class PermissionManagementView(StaffMemberMixin, SuccessMessageMixin, FormView):
|
||||||
# Election admin
|
# Election admin
|
||||||
election_perm = Permission.objects.get(codename="election_admin")
|
election_perm = Permission.objects.get(codename="election_admin")
|
||||||
if form.cleaned_data["election_admin"]:
|
if form.cleaned_data["election_admin"]:
|
||||||
election_perm.user_set.add(user) # pyright: ignore
|
election_perm.user_set.add(user)
|
||||||
else:
|
else:
|
||||||
election_perm.user_set.remove(user) # pyright: ignore
|
election_perm.user_set.remove(user)
|
||||||
|
|
||||||
# FAQ admin
|
# FAQ admin
|
||||||
faq_perm = Permission.objects.get(codename="faq_admin")
|
faq_perm = Permission.objects.get(codename="faq_admin")
|
||||||
if form.cleaned_data["faq_admin"]:
|
if form.cleaned_data["faq_admin"]:
|
||||||
faq_perm.user_set.add(user) # pyright: ignore
|
faq_perm.user_set.add(user)
|
||||||
else:
|
else:
|
||||||
faq_perm.user_set.remove(user) # pyright: ignore
|
faq_perm.user_set.remove(user)
|
||||||
|
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
self.log_info("Permissions changed", data={"user": user.get_data()})
|
||||||
|
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Log history
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
class JournalView(StaffMemberMixin, ListView):
|
||||||
|
queryset = Event.objects.select_related("user")
|
||||||
|
template_name = "auth/journal.html"
|
||||||
|
|
||||||
|
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
# List of special accounts
|
# List of special accounts
|
||||||
# #############################################################################
|
# #############################################################################
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,15 +8,21 @@ class Serializer:
|
||||||
def get_serializable_fields(self):
|
def get_serializable_fields(self):
|
||||||
return self.serializable_fields
|
return self.serializable_fields
|
||||||
|
|
||||||
def to_json(self):
|
def get_data(self):
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
for field in self.get_serializable_fields():
|
for field in self.get_serializable_fields():
|
||||||
if hasattr(self, field):
|
if hasattr(self, field):
|
||||||
data.update({field: getattr(self, field)})
|
if isinstance(getattr(self, field), datetime.date):
|
||||||
|
data.update({field: getattr(self, field).isoformat()})
|
||||||
|
else:
|
||||||
|
data.update({field: getattr(self, field)})
|
||||||
else:
|
else:
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
"This object does not have a field named '{}'".format(field)
|
"This object does not have a field named '{}'".format(field)
|
||||||
)
|
)
|
||||||
|
|
||||||
return json.dumps(data)
|
return data
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
return json.dumps(self.get_data())
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.template.response import TemplateResponse
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic.base import TemplateResponseMixin, View
|
from django.views.generic.base import TemplateResponseMixin, View
|
||||||
|
@ -51,17 +48,10 @@ class JsonMessageMixin:
|
||||||
|
|
||||||
def get_data(self, **kwargs):
|
def get_data(self, **kwargs):
|
||||||
kwargs.update(message=self.get_message())
|
kwargs.update(message=self.get_message())
|
||||||
return super().get_data(**kwargs) # pyright: ignore
|
return super().get_data(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class TypedResponseMixin(TemplateResponseMixin):
|
class JsonDetailView(JsonMixin, SingleObjectMixin, TemplateResponseMixin, View):
|
||||||
def render_to_response(
|
|
||||||
self, context: dict[str, Any], **response_kwargs: Any
|
|
||||||
) -> TemplateResponse:
|
|
||||||
return super().render_to_response(context, **response_kwargs) # pyright: ignore
|
|
||||||
|
|
||||||
|
|
||||||
class JsonDetailView(JsonMixin, SingleObjectMixin, TypedResponseMixin, View):
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
context = self.get_context_data(object=self.object)
|
context = self.get_context_data(object=self.object)
|
||||||
|
@ -79,7 +69,7 @@ class JsonDeleteView(JsonMessageMixin, JsonDetailView):
|
||||||
|
|
||||||
@method_decorator(require_POST, name="dispatch")
|
@method_decorator(require_POST, name="dispatch")
|
||||||
class JsonCreateView(
|
class JsonCreateView(
|
||||||
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
|
JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView
|
||||||
):
|
):
|
||||||
def render_to_json(self, **kwargs):
|
def render_to_json(self, **kwargs):
|
||||||
context = self.get_context_data(object=self.object)
|
context = self.get_context_data(object=self.object)
|
||||||
|
@ -91,7 +81,7 @@ class JsonCreateView(
|
||||||
|
|
||||||
@method_decorator(require_POST, name="dispatch")
|
@method_decorator(require_POST, name="dispatch")
|
||||||
class JsonUpdateView(
|
class JsonUpdateView(
|
||||||
JsonMessageMixin, JsonModelFormMixin, TypedResponseMixin, ProcessFormView
|
JsonMessageMixin, JsonModelFormMixin, TemplateResponseMixin, ProcessFormView
|
||||||
):
|
):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
User = get_user_model()
|
||||||
from elections.typing import User
|
|
||||||
else:
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
@ -43,11 +38,5 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
Permission.objects.get(
|
Permission.objects.get(codename="election_admin").user_set.add(user)
|
||||||
codename="election_admin"
|
Permission.objects.get(codename="faq_admin").user_set.add(user)
|
||||||
).user_set.add( # pyright: ignore
|
|
||||||
user
|
|
||||||
)
|
|
||||||
Permission.objects.get(codename="faq_admin").user_set.add( # pyright: ignore
|
|
||||||
user
|
|
||||||
)
|
|
||||||
|
|
54
shared/migrations/0001_initial.py
Normal file
54
shared/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
# Generated by Django 3.2.11 on 2022-01-12 01:10
|
||||||
|
|
||||||
|
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="Event",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("message", models.TextField(default="")),
|
||||||
|
(
|
||||||
|
"level",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("info", "INFO"),
|
||||||
|
("warning", "WARNING"),
|
||||||
|
("error", "ERROR"),
|
||||||
|
],
|
||||||
|
max_length=7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("timestamp", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("data", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="events",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
shared/migrations/__init__.py
Normal file
0
shared/migrations/__init__.py
Normal file
23
shared/mixins.py
Normal file
23
shared/mixins.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
from .models import Event
|
||||||
|
|
||||||
|
# #############################################################################
|
||||||
|
# Fonctions pour la journalisation
|
||||||
|
# #############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
class LogMixin:
|
||||||
|
"""Utility to log events related to the current user"""
|
||||||
|
|
||||||
|
def _log(self, message, level, data={}):
|
||||||
|
Event.objects.create(
|
||||||
|
message=message, level=level, user=self.request.user, data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_info(self, message, data={}):
|
||||||
|
self._log(message, "info", data=data)
|
||||||
|
|
||||||
|
def log_warn(self, message, data={}):
|
||||||
|
self._log(message, "warn", data=data)
|
||||||
|
|
||||||
|
def log_error(self, message, data={}):
|
||||||
|
self._log(message, "error", data=data)
|
28
shared/models.py
Normal file
28
shared/models.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from .utils import choices_length
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
LOG_LEVELS = (
|
||||||
|
("info", "INFO"),
|
||||||
|
("warning", "WARNING"),
|
||||||
|
("error", "ERROR"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Event(models.Model):
|
||||||
|
message = models.TextField(default="")
|
||||||
|
level = models.CharField(choices=LOG_LEVELS, max_length=choices_length(LOG_LEVELS))
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User, related_name="events", on_delete=models.SET_NULL, null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-timestamp"]
|
|
@ -35,4 +35,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tile is-ancestor">
|
||||||
|
<div class="tile is-parent">
|
||||||
|
<a class="tile is-child notification is-light px-0" href="{% url 'auth.journal' %}">
|
||||||
|
<div class="subtitle has-text-centered">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-server"></i>
|
||||||
|
</span>
|
||||||
|
<span class="ml-3">{% trans "Journal d'évènements" %}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Placeholder #}
|
||||||
|
<div class="tile is-parent py-0"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
53
shared/templates/auth/journal.html
Normal file
53
shared/templates/auth/journal.html
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load i18n bulma %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block custom_js %}
|
||||||
|
<script>
|
||||||
|
_$('a[data-data]').forEach(b => b.addEventListener('click', () => {
|
||||||
|
_id(b.dataset.data).classList.toggle('is-hidden');
|
||||||
|
_$('i', b).forEach(i => i.classList.toggle('is-hidden'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1 class="title">{% trans "Journal d'évènements" %}</h1>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<table class="table is-fullwidth is-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Niveau" %}</th>
|
||||||
|
<th>{% trans "Message" %}</th>
|
||||||
|
<th>{% trans "Origine" %}</th>
|
||||||
|
<th>{% trans "Heure" %}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in object_list %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="tag is-{{ e.level|bulma_message_tag }}">{{ e.get_level_display }}</span></td>
|
||||||
|
<td>{{ e.message }}</td>
|
||||||
|
<td>{% if e.user %}<i>{{ e.user }}</i>{% endif %}</td>
|
||||||
|
<td>{{ e.timestamp }}</td>
|
||||||
|
<td>
|
||||||
|
<a class="icon has-text-primary is-pulled-right" data-data="data-{{ e.pk }}">
|
||||||
|
<i class="fas fa-expand"></i>
|
||||||
|
<i class="fas fa-compress is-hidden"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="data-{{ e.pk }}" class="is-hidden">
|
||||||
|
<td colspan="5" class="is-fullwidth">{{ e.data }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -68,7 +68,4 @@ def bulmafy(field, css_class):
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def bulma_message_tag(tag):
|
def bulma_message_tag(tag):
|
||||||
if tag == "error":
|
return "danger" if tag == "error" else tag
|
||||||
return "danger"
|
|
||||||
|
|
||||||
return tag
|
|
||||||
|
|
|
@ -5,5 +5,7 @@
|
||||||
|
|
||||||
def choices_length(choices):
|
def choices_length(choices):
|
||||||
"""Renvoie la longueur maximale des choix de choices"""
|
"""Renvoie la longueur maximale des choix de choices"""
|
||||||
|
m = 0
|
||||||
return max(len(c[0]) for c in choices)
|
for c in choices:
|
||||||
|
m = max(m, len(c[0]))
|
||||||
|
return m
|
||||||
|
|
|
@ -23,4 +23,4 @@ class BackgroundUpdateView(RedirectView):
|
||||||
class TimeMixin:
|
class TimeMixin:
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs.update(current_time=timezone.now())
|
kwargs.update(current_time=timezone.now())
|
||||||
return super().get_context_data(**kwargs) # pyright: ignore
|
return super().get_context_data(**kwargs)
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
(import ./. { }).devShell
|
|
Loading…
Add table
Add a link
Reference in a new issue