Module de pétitions inspiré de degette

This commit is contained in:
Tom Hubrecht 2021-05-31 20:39:37 +02:00
parent 1353c4e702
commit 7068c6dd18
24 changed files with 1587 additions and 3 deletions

View file

@ -55,6 +55,7 @@ INSTALLED_APPS = [
"kadenios.apps.IgnoreSrcStaticFilesConfig", "kadenios.apps.IgnoreSrcStaticFilesConfig",
"shared", "shared",
"elections", "elections",
"petitions",
"authens", "authens",
] ]
@ -93,6 +94,8 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
DEFAULT_FROM_EMAIL = "Kadenios <klub-dev@ens.fr>" DEFAULT_FROM_EMAIL = "Kadenios <klub-dev@ens.fr>"
SERVER_DOMAIN = "vote.eleves.ens.fr"
# ############################################################################# # #############################################################################
# Paramètres d'authentification # Paramètres d'authentification
# ############################################################################# # #############################################################################

View file

@ -8,6 +8,7 @@ urlpatterns = [
path("", HomeView.as_view(), name="kadenios"), path("", HomeView.as_view(), name="kadenios"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("elections/", include("elections.urls")), path("elections/", include("elections.urls")),
path("petitions/", include("petitions.urls")),
path("auth/", include("shared.auth.urls")), path("auth/", include("shared.auth.urls")),
path("authens/", include("authens.urls")), path("authens/", include("authens.urls")),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),

0
petitions/__init__.py Normal file
View file

6
petitions/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PetitionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "petitions"

68
petitions/forms.py Normal file
View file

@ -0,0 +1,68 @@
from translated_fields import language_code_formfield_callback
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import Petition, Signature
class PetitionForm(forms.ModelForm):
formfield_callback = language_code_formfield_callback
class Meta:
model = Petition
fields = [
*Petition.title.fields,
*Petition.text.fields,
*Petition.letter.fields,
"launch_date",
]
widgets = {
"text_en": forms.Textarea(attrs={"rows": 3}),
"text_fr": forms.Textarea(attrs={"rows": 4}),
"letter_en": forms.Textarea(attrs={"rows": 3}),
"letter_fr": forms.Textarea(attrs={"rows": 4}),
}
class DeleteForm(forms.Form):
def __init__(self, **kwargs):
signature = kwargs.pop("signature", None)
super().__init__(**kwargs)
if signature is not None:
self.fields["delete"].label = _("Supprimer la signature de {} ?").format(
signature.full_name
)
delete = forms.ChoiceField(
label=_("Supprimer"), choices=(("non", _("Non")), ("oui", _("Oui")))
)
class ValidateForm(forms.Form):
def __init__(self, **kwargs):
signature = kwargs.pop("signature", None)
super().__init__(**kwargs)
if signature is not None:
self.fields["validate"].label = _("Valider la signature de {} ?").format(
signature.full_name
)
validate = forms.ChoiceField(
label=_("Valider"), choices=(("non", _("Non")), ("oui", _("Oui")))
)
class SignatureForm(forms.ModelForm):
def clean_email(self):
email = self.cleaned_data["email"]
if self.instance.petition.signatures.filter(email__iexact=email):
self.add_error(
"email",
_("Une personne a déjà signé la pétition avec cette adresse mail."),
)
return email
class Meta:
model = Signature
fields = ["full_name", "email", "status", "department", "elected"]

View file

@ -0,0 +1,262 @@
# Generated by Django 3.2.3 on 2021-05-29 20:34
import datetime
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Petition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title_fr", models.CharField(max_length=255, verbose_name="titre")),
(
"title_en",
models.CharField(blank=True, max_length=255, verbose_name="titre"),
),
("text_fr", models.TextField(blank=True, verbose_name="texte")),
("text_en", models.TextField(blank=True, verbose_name="texte")),
("letter_fr", models.TextField(blank=True, verbose_name="lettre")),
("letter_en", models.TextField(blank=True, verbose_name="lettre")),
(
"launch_date",
models.DateField(
default=datetime.date.today, verbose_name="date d'ouverture"
),
),
(
"archived",
models.BooleanField(default=False, verbose_name="archivée"),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="petitions_created",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-launch_date"],
},
),
migrations.CreateModel(
name="Signature",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"full_name",
models.CharField(max_length=255, verbose_name="nom complet"),
),
(
"email",
models.EmailField(max_length=254, verbose_name="adresse mail"),
),
(
"status",
models.CharField(
choices=[
("normalien-license", "Normalien·ne en licence"),
("normalien-master", "Normalien·ne en master"),
("normalien-cesure", "Normalien·ne en césure"),
("normalien-pre-these", "Normalien·ne en pré-thèse"),
(
"normalien-concours",
"Normalien·ne préparant un concours (Agrégation, ENA...)",
),
(
"normalien-stage",
"Normalien·ne en stage ou en année de formation complémentaire",
),
(
"normalien-administration",
"Normalien·ne dans l'administration publique",
),
("normalien-entreprise", "Normalien·ne dans l'entreprise"),
(
"normalien-chercheur",
"Normalien·ne et chercheur·se en Université",
),
("masterien", "Mastérien·ne"),
("these", "Doctorant·e"),
("postdoc", "Post-doctorant·e"),
("archicube", "Ancien·ne élève ou étudiant·e"),
("chercheur-ens", "Chercheur·se à lENS"),
("enseignant-ens", "Enseignant·e à lENS"),
(
"enseignant-chercheur",
"Enseignant·e et chercheur·se à lENS",
),
("enseignant-cpge", "Enseignant·e en classe préparatoire"),
("charge-td", "Chargé·e de TD"),
("direction-ens", "Membre de la direction de l'ENS"),
(
"direction-departement",
"Membre de la direction d'un département",
),
(
"directeur",
"Directeur·rice de l'Ecole Normale Supérieure",
),
(
"employe-cost",
"Employé·e du Service des Concours, de la Scolarité et des Thèses",
),
(
"employe-srh",
"Employé·e du Service des Ressources Humaines",
),
(
"employe-spr",
"Employé·e du Service Partenariat de la Recherche",
),
(
"employe-sfc",
"Employé·e du Service Financier et Comptable",
),
(
"employe-cri",
"Employé·e du Centre de Ressources Informatiques",
),
("employe-sp", "Employé·e du Service Patrimoine"),
(
"employe-sps",
"Employé·e du Service Prévention et Sécurité",
),
("employe-sl", "Employé·e du Service Logistique"),
("employe-sr", "Employé·e du Service de la Restauration"),
("employe-ps", "Employé·e du Pôle Santé"),
(
"employe-spi",
"Employé·e du Service de Prestations Informatiques",
),
(
"employe-bibliotheque",
"Employé·e d'une des bibliothèques",
),
(
"employe-exterieur",
"Employé·e d'une société prestataire de service à l'ENS",
),
("pei", "Élève du PEI"),
("autre", "Autre"),
],
max_length=24,
verbose_name="statut",
),
),
(
"elected",
models.CharField(
choices=[
("", "Aucun"),
("dg", "Membre de la Délégation Générale"),
("cof", "Membre du bureau du COF"),
("bda", "Membre du bureau du BdA"),
("bds", "Membre du bureau du BDS"),
("cs", "Membre du Conseil Scientifique"),
("ca", "Membre du Conseil d'Administration"),
("ce", "Membre de la Commission des Études"),
("chsct", "Membre du CHSCT"),
],
default="",
max_length=5,
verbose_name="poste d'élu",
),
),
(
"department",
models.CharField(
choices=[
("", "Aucun département"),
("arts", "Département Arts"),
("litteratures", "Département Littératures et langage"),
("histoire", "Département dHistoire"),
("economie", "Département dÉconomie"),
("philosophie", "Département de Philosophie"),
("sciences-sociales", "Département de Sciences Sociales"),
("antiquite", "Département des Sciences de lAntiquité"),
("ecla", "Espace des cultures et langues dailleurs"),
("geographie", "Département Géographie et Territoires"),
("di", "Département dInformatique"),
("cognition", "Département d'Études cognitives"),
("biologie", "Département de Biologie"),
("chimie", "Département de Chimie"),
("geosciences", "Département de Géosciences"),
("math", "Département de Mathématiques et applications"),
("phys", "Département de Physique"),
(
"environnement",
"Centre de formation sur lEnvironnement et la Société",
),
],
default="",
max_length=17,
verbose_name="département",
),
),
(
"verified",
models.BooleanField(
default=False, verbose_name="adresse mail vérifiée"
),
),
(
"valid",
models.BooleanField(
default=False, verbose_name="signature vérifiée"
),
),
(
"timestamp",
models.DateTimeField(
auto_now_add=True, verbose_name="jour de signature"
),
),
(
"petition",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="signatures",
to="petitions.petition",
),
),
],
),
migrations.AddConstraint(
model_name="signature",
constraint=models.UniqueConstraint(
fields=("petition", "email"), name="unique_signature"
),
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 3.2.3 on 2021-05-29 20:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("petitions", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name="petition",
options={
"ordering": ["-launch_date"],
"permissions": [("is_admin", "Peut administrer des pétitions")],
},
),
]

View file

@ -0,0 +1,143 @@
# Generated by Django 3.2.3 on 2021-05-30 15:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("petitions", "0002_alter_petition_options"),
]
operations = [
migrations.AlterField(
model_name="signature",
name="department",
field=models.CharField(
blank=True,
choices=[
("", "Aucun département"),
("arts", "Département Arts"),
("litteratures", "Département Littératures et langage"),
("histoire", "Département dHistoire"),
("economie", "Département dÉconomie"),
("philosophie", "Département de Philosophie"),
("sciences-sociales", "Département de Sciences Sociales"),
("antiquite", "Département des Sciences de lAntiquité"),
("ecla", "Espace des cultures et langues dailleurs"),
("geographie", "Département Géographie et Territoires"),
("di", "Département dInformatique"),
("cognition", "Département d'Études cognitives"),
("biologie", "Département de Biologie"),
("chimie", "Département de Chimie"),
("geosciences", "Département de Géosciences"),
("math", "Département de Mathématiques et applications"),
("phys", "Département de Physique"),
(
"environnement",
"Centre de formation sur lEnvironnement et la Société",
),
],
default="",
max_length=17,
verbose_name="département",
),
),
migrations.AlterField(
model_name="signature",
name="elected",
field=models.CharField(
blank=True,
choices=[
("", "Aucun"),
("dg", "Membre de la Délégation Générale"),
("cof", "Membre du bureau du COF"),
("bda", "Membre du bureau du BdA"),
("bds", "Membre du bureau du BDS"),
("cs", "Membre du Conseil Scientifique"),
("ca", "Membre du Conseil d'Administration"),
("ce", "Membre de la Commission des Études"),
("chsct", "Membre du CHSCT"),
],
default="",
max_length=5,
verbose_name="poste d'élu",
),
),
migrations.AlterField(
model_name="signature",
name="status",
field=models.CharField(
choices=[
("autre", "Autre"),
("normalien-license", "Normalien·ne en licence"),
("normalien-master", "Normalien·ne en master"),
("normalien-cesure", "Normalien·ne en césure"),
("normalien-pre-these", "Normalien·ne en pré-thèse"),
(
"normalien-concours",
"Normalien·ne préparant un concours (Agrégation, ENA...)",
),
(
"normalien-stage",
"Normalien·ne en stage ou en année de formation complémentaire",
),
(
"normalien-administration",
"Normalien·ne dans l'administration publique",
),
("normalien-entreprise", "Normalien·ne dans l'entreprise"),
(
"normalien-chercheur",
"Normalien·ne et chercheur·se en Université",
),
("masterien", "Mastérien·ne"),
("these", "Doctorant·e"),
("postdoc", "Post-doctorant·e"),
("archicube", "Ancien·ne élève ou étudiant·e"),
("chercheur-ens", "Chercheur·se à lENS"),
("enseignant-ens", "Enseignant·e à lENS"),
("enseignant-chercheur", "Enseignant·e et chercheur·se à lENS"),
("enseignant-cpge", "Enseignant·e en classe préparatoire"),
("charge-td", "Chargé·e de TD"),
("direction-ens", "Membre de la direction de l'ENS"),
(
"direction-departement",
"Membre de la direction d'un département",
),
("directeur", "Directeur·rice de l'Ecole Normale Supérieure"),
(
"employe-cost",
"Employé·e du Service des Concours, de la Scolarité et des Thèses",
),
("employe-srh", "Employé·e du Service des Ressources Humaines"),
("employe-spr", "Employé·e du Service Partenariat de la Recherche"),
("employe-sfc", "Employé·e du Service Financier et Comptable"),
("employe-cri", "Employé·e du Centre de Ressources Informatiques"),
("employe-sp", "Employé·e du Service Patrimoine"),
("employe-sps", "Employé·e du Service Prévention et Sécurité"),
("employe-sl", "Employé·e du Service Logistique"),
("employe-sr", "Employé·e du Service de la Restauration"),
("employe-ps", "Employé·e du Pôle Santé"),
(
"employe-spi",
"Employé·e du Service de Prestations Informatiques",
),
("employe-bibliotheque", "Employé·e d'une des bibliothèques"),
(
"employe-exterieur",
"Employé·e d'une société prestataire de service à l'ENS",
),
("pei", "Élève du PEI"),
],
default="autre",
max_length=24,
verbose_name="statut",
),
),
migrations.AlterField(
model_name="signature",
name="timestamp",
field=models.DateTimeField(auto_now=True, verbose_name="horodatage"),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.2.3 on 2021-05-30 18:27
from django.db import migrations, models
import shared.utils
class Migration(migrations.Migration):
dependencies = [
("petitions", "0003_auto_20210530_1740"),
]
operations = [
migrations.AddField(
model_name="signature",
name="token",
field=models.SlugField(
default=shared.utils.token_generator,
editable=False,
verbose_name="token",
),
),
]

View file

18
petitions/mixins.py Normal file
View file

@ -0,0 +1,18 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.urls import reverse
class AdminOnlyMixin(PermissionRequiredMixin):
"""Restreint l'accès aux admins"""
permission_required = "petitions.is_admin"
class CreatorOnlyMixin(AdminOnlyMixin):
"""Restreint l'accès au créateurice de l'élection"""
def get_next_url(self):
return reverse("kadenios")
def get_queryset(self):
return super().get_queryset().filter(created_by=self.request.user)

85
petitions/models.py Normal file
View file

@ -0,0 +1,85 @@
from datetime import date
from translated_fields import TranslatedFieldWithFallback
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from shared.utils import choices_length, token_generator
from .staticdefs import DEPARTMENTS, ELECTED, STATUSES
User = get_user_model()
# #############################################################################
# Petition related models
# #############################################################################
class Petition(models.Model):
title = TranslatedFieldWithFallback(models.CharField(_("titre"), max_length=255))
text = TranslatedFieldWithFallback(models.TextField(_("texte"), blank=True))
letter = TranslatedFieldWithFallback(models.TextField(_("lettre"), blank=True))
created_by = models.ForeignKey(
User,
related_name="petitions_created",
on_delete=models.SET_NULL,
blank=True,
null=True,
)
launch_date = models.DateField(_("date d'ouverture"), default=date.today)
archived = models.BooleanField(_("archivée"), default=False)
class Meta:
permissions = [
("is_admin", _("Peut administrer des pétitions")),
]
ordering = ["-launch_date"]
class Signature(models.Model):
petition = models.ForeignKey(
Petition, related_name="signatures", on_delete=models.CASCADE
)
full_name = models.CharField(_("nom complet"), max_length=255)
email = models.EmailField(_("adresse mail"))
status = models.CharField(
_("statut"),
choices=STATUSES,
max_length=choices_length(STATUSES),
default="autre",
)
elected = models.CharField(
_("poste d'élu"),
choices=ELECTED,
max_length=choices_length(ELECTED),
default="",
blank=True,
)
department = models.CharField(
_("département"),
choices=DEPARTMENTS,
max_length=choices_length(DEPARTMENTS),
default="",
blank=True,
)
verified = models.BooleanField(_("adresse mail vérifiée"), default=False)
valid = models.BooleanField(_("signature vérifiée"), default=False)
token = models.SlugField(_("token"), editable=False, default=token_generator)
timestamp = models.DateTimeField(_("horodatage"), auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["petition", "email"], name="unique_signature"
)
]

118
petitions/staticdefs.py Normal file
View file

@ -0,0 +1,118 @@
from django.utils.translation import gettext_lazy as _
DEPARTMENTS = [
("", _("Aucun département")),
("arts", _("Département Arts")),
("litteratures", _("Département Littératures et langage")),
("histoire", _("Département dHistoire")),
("economie", _("Département dÉconomie")),
("philosophie", _("Département de Philosophie")),
("sciences-sociales", _("Département de Sciences Sociales")),
("antiquite", _("Département des Sciences de lAntiquité")),
("ecla", _("Espace des cultures et langues dailleurs")),
("geographie", _("Département Géographie et Territoires")),
("di", _("Département dInformatique")),
("cognition", "Département d'Études cognitives"),
("biologie", _("Département de Biologie")),
("chimie", _("Département de Chimie")),
("geosciences", _("Département de Géosciences")),
("math", _("Département de Mathématiques et applications")),
("phys", _("Département de Physique")),
("environnement", _("Centre de formation sur lEnvironnement et la Société")),
]
STATUSES = [
("autre", _("Autre")),
("normalien-license", _("Normalien·ne en licence")),
("normalien-master", _("Normalien·ne en master")),
("normalien-cesure", _("Normalien·ne en césure")),
("normalien-pre-these", _("Normalien·ne en pré-thèse")),
(
"normalien-concours",
_("Normalien·ne préparant un concours (Agrégation, ENA...)"),
),
(
"normalien-stage",
_("Normalien·ne en stage ou en année de formation complémentaire"),
),
("normalien-administration", _("Normalien·ne dans l'administration publique")),
("normalien-entreprise", _("Normalien·ne dans l'entreprise")),
("normalien-chercheur", _("Normalien·ne et chercheur·se en Université")),
("masterien", _("Mastérien·ne")),
("these", _("Doctorant·e")),
("postdoc", _("Post-doctorant·e")),
("archicube", _("Ancien·ne élève ou étudiant·e")),
("chercheur-ens", _("Chercheur·se à lENS")),
("enseignant-ens", _("Enseignant·e à lENS")),
("enseignant-chercheur", _("Enseignant·e et chercheur·se à lENS")),
("enseignant-cpge", _("Enseignant·e en classe préparatoire")),
("charge-td", _("Chargé·e de TD")),
("direction-ens", _("Membre de la direction de l'ENS")),
("direction-departement", _("Membre de la direction d'un département")),
("directeur", _("Directeur·rice de l'Ecole Normale Supérieure")),
(
"employe-cost",
_("Employé·e du Service des Concours, de la Scolarité et des Thèses"),
),
("employe-srh", _("Employé·e du Service des Ressources Humaines")),
("employe-spr", _("Employé·e du Service Partenariat de la Recherche")),
("employe-sfc", _("Employé·e du Service Financier et Comptable")),
("employe-cri", _("Employé·e du Centre de Ressources Informatiques")),
("employe-sp", _("Employé·e du Service Patrimoine")),
("employe-sps", _("Employé·e du Service Prévention et Sécurité")),
("employe-sl", _("Employé·e du Service Logistique")),
("employe-sr", _("Employé·e du Service de la Restauration")),
("employe-ps", _("Employé·e du Pôle Santé")),
("employe-spi", _("Employé·e du Service de Prestations Informatiques")),
("employe-bibliotheque", _("Employé·e d'une des bibliothèques")),
("employe-exterieur", _("Employé·e d'une société prestataire de service à l'ENS")),
("pei", _("Élève du PEI")),
]
ELECTED = [
("", _("Aucun")),
("dg", _("Membre de la Délégation Générale")),
("cof", _("Membre du bureau du COF")),
("bda", _("Membre du bureau du BdA")),
("bds", _("Membre du bureau du BDS")),
("cs", _("Membre du Conseil Scientifique")),
("ca", _("Membre du Conseil d'Administration")),
("ce", _("Membre de la Commission des Études")),
("chsct", _("Membre du CHSCT")),
]
MAIL_SIGNATURE_DELETED = (
"Bonjour {full_name},\n"
"\n"
"Votre signature pour la pétition « {petition_name_fr} » a été supprimée.\n"
"\n"
"----------"
"\n"
"Dear {full_name},\n"
"\n"
"Your signature for the petition “{petition_name_en}” has been removed.\n"
"\n"
"-- \n"
"Kadenios"
)
MAIL_SIGNATURE_VERIFICATION = (
"Bonjour {full_name},\n"
"\n"
"Merci d'avoir signé la pétition « {petition_name_fr} », pour confirmer votre "
"signature, merci de cliquer sur le lien suivant :\n"
"\n"
"{confirm_url}\n"
"\n"
"----------"
"\n"
"Dear {full_name},\n"
"\n"
"Thank you for signing the petition “{petition_name_en}”, to confirm your "
"signature, please click on the following link:\n"
"\n"
"{confirm_url}\n"
"\n"
"-- \n"
"Kadenios"
)

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load i18n string %}
{% block content %}
<h1 class="title">{% trans "Supprimer une signature" %}</h1>
<hr>
{% url 'election.voters' election.pk as r_url %}
{% include "forms/common-form.html" with c_size="is-half" r_anchor="v_"|concatenate:anchor %}
{% endblock %}

View file

@ -0,0 +1,132 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="level">
{# Titre de la pétition #}
<div class="level-left is-flex-shrink-1">
<h1 class="title">{{ petition.title }}</h1>
</div>
<div class="level-right">
{# Lien vers la page d'administration #}
{% if petition.created_by == user %}
<div class="level-item">
<a class="button has-tooltip-primary" href="{% url 'petition.admin' petition.pk %}" data-tooltip="{% trans "Administrer" %}">
<span class="icon">
<i class="fas fa-cog"></i>
</span>
</a>
</div>
{% endif %}
</div>
</div>
<div class="level">
{# Date d'ouverture de la pétition #}
<div class="level-left">
<div class="level-item">
<span class="tag is-medium is-primary">
<span class="icon-text">
<span class="icon">
<i class="fas fa-calendar-week"></i>
</span>
<span>{{ petition.launch_date|date:"d/m/Y" }}</span>
</span>
</span>
</div>
{# Créateurice de la pétition #}
<div class="level-item">
<span class="tag is-primary is-light is-outlined">{% blocktrans with creator=petition.created_by.full_name %}Créé par {{ creator }}{% endblocktrans %}</span>
</div>
</div>
</div>
<hr>
{# Signature #}
{% if petition.launch_date <= today and not petition.archived %}
<div class="columns is-centered tile is-ancestor">
<div class="column is-6 tile is-parent">
<a class="tile is-child notification is-primary" href="{% url 'petition.sign' petition.pk %}">
<div class="subtitle has-text-centered">
<span class="icon-text">
<span class="icon has-text-white">
<i class="fas fa-signature"></i>
</span>
<span class="ml-3">{% trans "Signer cette pétition" %}</span>
</span>
</div>
</a>
</div>
</div>
{% endif %}
{# Description de la pétition #}
{% if petition.text %}
<div class="message is-primary">
<div class="message-header">{% trans "Texte de la pétition" %}</div>
<div class="message-body">{{ petition.text|linebreaksbr }}</div>
</div>
{% endif %}
{# Lettre aux signataires #}
{% if petition.letter %}
<div class="message is-primary">
<div class="message-header">{% trans "Lettre de la pétition" %}</div>
<div class="message-body">{{ petition.letter|linebreaksbr }}</div>
</div>
{% endif %}
{# Liste des signataires #}
<br>
<h3 class="subtitle">{% trans "Liste des signataires" %}</h3>
<hr>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>{% trans "Nom" %}</th>
<th>{% trans "Statut" %}</th>
<th>{% trans "Département" %}</th>
<th>{% trans "Poste élu" %}</th>
<th class="has-text-centered">{% trans "Vérifié" %}</th>
<th class="has-text-centered">{% trans "Validé" %}</th>
<tr>
</thead>
<tbody>
{% for s in signatures %}
<tr>
<td>{{ s.full_name }}</td>
<td>{{ s.get_status_display }}</td>
<td>{{ s.get_department_display }}</td>
<td>{{ s.get_elected_display }}</td>
<td class="has-text-centered">
<span class="icon">
{% if s.verified %}
<i class="has-text-success fas fa-check"></i>
{% else %}
<i class="has-text-danger fas fa-times"></i>
{% endif %}
</span>
</td>
<td class="has-text-centered">
<span class="icon">
{% if s.valid %}
<i class="has-text-success fas fa-check"></i>
{% else %}
<i class="has-text-danger fas fa-times"></i>
{% endif %}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,216 @@
{% extends "base.html" %}
{% load i18n %}
{% block extra_head %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const $del_modal = document.getElementById('modal-delete');
const $del_title = $del_modal.querySelector('.modal-card-title');
const $del_form = $del_modal.querySelector('form');
const $val_modal = document.getElementById('modal-validate');
const $val_title = $val_modal.querySelector('.modal-card-title');
const $val_form = $val_modal.querySelector('form');
$del_buttons = document.querySelectorAll('.modal-button.delete-signature')
$val_buttons = document.querySelectorAll('.modal-button.validate-signature')
$del_buttons.forEach($del => {
$del.addEventListener('click', () => {
$del_form.action = $del.dataset.post_url;
$del_title.innerHTML = $del.dataset.tooltip;
});
});
$val_buttons.forEach($val => {
$val.addEventListener('click', () => {
$val_form.action = $val.dataset.post_url;
$val_title.innerHTML = $val.dataset.tooltip;
});
});
});
</script>
{% endblock %}
{% block content %}
<div class="level is-block-tablet is-block-desktop is-flex-fullhd">
{# Titre de la pétition #}
<div class="level-left is-flex-shrink-1">
<h1 class="title">{{ petition.title }}</h1>
</div>
<div class="level-right">
<div class="level-item">
<div class="dropdown is-right">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu">
<span class="icon-text">
<span class="icon">
<i class="fas fa-cog" aria-hidden="true"></i>
</span>
<span>{% trans "Actions" %}</span>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
{# Vue classique #}
<a class="dropdown-item" href="{% url 'petition.view' petition.pk %}">
<span class="icon">
<i class="fas fa-exchange-alt"></i>
</span>
<span>{% trans "Vue classique" %}
</a>
{# Téléchargement de la liste des signataires #}
<a class="dropdown-item" href="{% url 'election.export-voters' petition.pk %}">
<span class="icon">
<i class="fas fa-file-download"></i>
</span>
<span>{% trans "Exporter les signataires" %}
</a>
{# Modification de la pétition #}
{% if petition.launch_date > today %}
<a class="dropdown-item" href="{% url 'petition.update' petition.pk %}">
<span class="icon">
<i class="fas fa-edit"></i>
</span>
<span>{% trans "Modifier" %}</span>
</a>
{% endif %}
{# Archivage #}
{% if not petition.archived %}
<a class="dropdown-item" href="{% url 'petition.archive' petition.pk %}">
<span class="icon">
<i class="fas fa-archive"></i>
</span>
<span>{% trans "Archiver" %}</span>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="level">
{# Dates d'ouverture de la pétition #}
<div class="level-left">
<div class="level-item">
<span class="tag is-medium is-primary">
<span class="icon-text">
<span class="icon">
<i class="fas fa-calendar-week"></i>
</span>
<span>{{ petition.launch_date|date:"d/m/Y" }}</span>
</span>
</span>
</div>
</div>
</div>
<hr>
{# Description de la pétition #}
{% if petition.text %}
<div class="message is-primary">
<div class="message-header">{% trans "Texte de la pétition" %}</div>
<div class="message-body">{{ petition.text|linebreaksbr }}</div>
</div>
{% endif %}
{# Lettre aux signataires #}
{% if petition.letter %}
<div class="message is-primary">
<div class="message-header">{% trans "Lettre de la pétition" %}</div>
<div class="message-body">{{ petition.letter|linebreaksbr }}</div>
</div>
{% endif %}
{# Liste des signataires #}
<br>
<h3 class="subtitle">{% trans "Liste des signataires" %}</h3>
<hr>
{% include "forms/modal-form.html" with modal_id="delete" form=d_form %}
{% include "forms/modal-form.html" with modal_id="validate" form=v_form %}
<div class="table-container">
<table class="table is-striped is-fullwidth">
<thead>
<tr>
<th>{% trans "Nom" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Statut" %}</th>
<th>{% trans "Département" %}</th>
<th>{% trans "Poste élu" %}</th>
<th class="has-text-centered">{% trans "Vérifié" %}</th>
<th class="has-text-centered">{% trans "Valide" %}</th>
{% if not petition.archived %}
<th class="has-text-centered">{% trans "Action" %}</th>
{% endif %}
<tr>
</thead>
<tbody>
{% for s in petition.signatures.all %}
<tr id="s_{{ forloop.counter }}">
<td>{{ s.full_name }}</td>
<td>{{ s.email }}</td>
<td>{{ s.get_status_display }}</td>
<td>{{ s.get_department_display }}</td>
<td>{{ s.get_elected_display }}</td>
<td class="has-text-centered">
<span class="icon">
{% if s.verified %}
<i class="has-text-success fas fa-check"></i>
{% else %}
<i class="has-text-danger fas fa-times"></i>
{% endif %}
</span>
</td>
<td class="has-text-centered">
<span class="icon">
{% if s.valid %}
<i class="has-text-success fas fa-check"></i>
{% else %}
<i class="has-text-danger fas fa-times"></i>
{% endif %}
</span>
</td>
{% if not petition.archived %}
<td>
<span class="tags is-centered">
{% if not s.valid %}
{% blocktrans with s_name=s.full_name asvar s_validate %}Valider la signature de {{ s_name }}{% endblocktrans %}
<a class="tag is-success has-tooltip-primary has-tooltip-left modal-button validate-signature" data-target="modal-validate" data-post_url="{% url 'petition.validate' petition.pk s.pk forloop.counter %}" data-tooltip="{{ s_validate }}">
<span class="icon">
<i class="fas fa-check"></i>
</span>
</a>
{% endif %}
{% blocktrans with s_name=s.full_name asvar s_delete %}Supprimer la signature de {{ s_name }}{% endblocktrans %}
<a class="tag is-danger has-tooltip-primary has-tooltip-left modal-button delete-signature" data-target="modal-delete" data-post_url="{% url 'petition.delete-signature' petition.pk s.pk forloop.counter %}" data-tooltip="{{ s_delete }}">
<span class="icon">
<i class="fas fa-trash"></i>
</span>
</a>
</span>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<hr>
</div>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% load i18n static %}
{% block extra_head %}
{# DateTimePicker #}
<script src={% static 'vendor/datepicker/datetimepicker.js' %}></script>
<link rel="stylesheet" href="{% static 'vendor/datepicker/datetimepicker.css' %}">
<script>
document.addEventListener('DOMContentLoaded', () => {
$('#id_launch_date').datetimepicker({
format: 'Y-m-d',
timepicker: false,
});
});
</script>
{% endblock %}
{% block content %}
{% for error in form.non_field_errors %}
<div class="notification is-danger">
{{ error }}
</div>
{% endfor %}
<h1 class="title">{% trans "Création d'une pétition" %}</h1>
<hr>
{% url 'election.list' as r_url %}
{% include "forms/common-form.html" with c_size="is-12" errors=False %}
{% endblock %}

View file

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="level">
<div class="level-left">
<div class="level-item">
<h1 class="title">{% trans "Liste des pétitions" %}</h1>
</div>
</div>
{% if perms.petitions.is_admin %}
<div class="level-right">
<div class="level-item">
<a class="button is-light is-outlined is-primary" href={% url 'petition.create' %}>
<span class="icon">
<i class="fas fa-plus"></i>
</span>
<span>{% trans "Créer une pétition" %}</span>
</a>
</div>
</div>
{% endif %}
</div>
<hr>
{% for p in petition_list %}
<div class="panel is-primary">
<div class="panel-heading is-size-6 is-radiusles">
<div class="level">
<div class="level-left is-flex-shrink-1">
<div class="level-item">
<span class="tag is-primary is-light">{{ p.launch_date|date:"d/m/Y" }}</span>
</div>
<div class="level-item is-flex-shrink-1">
<a class="has-text-primary-light" href="{% url 'petition.view' p.pk %}"><u>{{ p.title }}</u></a>
</div>
</div>
<div class="level-right">
{% if p.archived %}
<div class="level-item">
<span class="tag is-danger is-light">{% trans "Pétition archivée" %}</span>
</div>
{% endif %}
{% if p.created_by == user %}
<div class="level-item">
<a class="has-text-primary-light ml-3 has-tooltip-light" href="{% url 'petition.admin' p.pk %}" data-tooltip="{% trans "Administrer" %}">
<span class="icon">
<i class="fas fa-cog"></i>
</span>
</a>
</div>
{% endif %}
</div>
</div>
</div>
<p class="panel-block">
{{ p.text|linebreaksbr }}
</p>
</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load i18n static %}
{% block content %}
{% for error in form.non_field_errors %}
<div class="notification is-danger">
{{ error }}
</div>
{% endfor %}
<h1 class="title">{% blocktrans with p_title=petition.title %}Signature de la pétition {{ p_title }}{% endblocktrans %}</h1>
<hr>
{% url 'petition.view' petition.pk as r_url %}
{% include "forms/common-form.html" with errors=False %}
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% load i18n static %}
{% block extra_head %}
{# DateTimePicker #}
<script src={% static 'vendor/datepicker/datetimepicker.js' %}></script>
<link rel="stylesheet" href="{% static 'vendor/datepicker/datetimepicker.css' %}">
<script>
$(document).ready(function($) {
$('#id_launch_date').datetimepicker({
format: 'Y-m-d'
});
});
</script>
{% endblock %}
{% block content %}
{% for error in form.non_field_errors %}
<div class="notification is-danger">
{{ error }}
</div>
{% endfor %}
<h1 class="title">{% trans "Modification d'une pétition" %}</h1>
<hr>
{% url 'petition.admin' petition.pk as r_url %}
{% include "forms/common-form.html" with errors=False %}
{% endblock %}

33
petitions/urls.py Normal file
View file

@ -0,0 +1,33 @@
from django.urls import path
from . import views
urlpatterns = [
# Admin views
path("create", views.PetitionCreateView.as_view(), name="petition.create"),
path("admin/<int:pk>", views.PetitionAdminView.as_view(), name="petition.admin"),
path("update/<int:pk>", views.PetitionUpdateView.as_view(), name="petition.update"),
path(
"archive/<int:pk>", views.PetitionArchiveView.as_view(), name="petition.archive"
),
path(
"delete/<int:pk>/<int:signature_pk>/<int:anchor>",
views.DeleteSignatureView.as_view(),
name="petition.delete-signature",
),
path(
"validate/<int:pk>/<int:signature_pk>/<int:anchor>",
views.ValidateSignatureView.as_view(),
name="petition.validate",
),
# Verification views
path(
"email/<slug:token>",
views.EmailValidationView.as_view(),
name="petition.confirm-email",
),
# Public views
path("", views.PetitionListView.as_view(), name="petition.list"),
path("view/<int:pk>", views.PetitionView.as_view(), name="petition.view"),
path("sign/<int:pk>", views.PetitionSignView.as_view(), name="petition.sign"),
]

264
petitions/views.py Normal file
View file

@ -0,0 +1,264 @@
from datetime import date
from django.contrib.auth import get_user_model
from django.contrib.messages.views import SuccessMessageMixin
from django.core.mail import EmailMessage
from django.http import Http404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView
from django.views.generic.edit import SingleObjectMixin
from shared.utils import full_url
from shared.views import BackgroundUpdateView
from .forms import DeleteForm, PetitionForm, SignatureForm, ValidateForm
from .mixins import AdminOnlyMixin, CreatorOnlyMixin
from .models import Petition, Signature
from .staticdefs import MAIL_SIGNATURE_DELETED, MAIL_SIGNATURE_VERIFICATION
User = get_user_model()
# #############################################################################
# Administration views
# #############################################################################
class PetitionCreateView(AdminOnlyMixin, CreateView):
model = Petition
form_class = PetitionForm
success_message = _("Pétition créée avec succès")
template_name = "petitions/petition_create.html"
def get_success_url(self):
return reverse("petition.admin", args=[self.object.pk])
def form_valid(self, form):
form.instance.created_by = self.request.user
return super().form_valid(form)
class PetitionAdminView(CreatorOnlyMixin, DetailView):
model = Petition
template_name = "petitions/petition_admin.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["d_form"] = DeleteForm()
context["v_form"] = ValidateForm()
context["today"] = date.today()
return context
class PetitionUpdateView(CreatorOnlyMixin, SuccessMessageMixin, UpdateView):
model = Petition
form_class = PetitionForm
success_message = _("Pétition modifiée avec succès !")
template_name = "petitions/petition_update.html"
def get_success_url(self):
return reverse("election.admin", args=[self.object.pk])
def get_queryset(self):
return (
super().get_queryset().filter(launch_date__gt=date.today(), archived=False)
)
class PetitionArchiveView(CreatorOnlyMixin, SingleObjectMixin, BackgroundUpdateView):
model = Petition
pattern_name = "petition.admin"
success_message = _("Élection archivée avec succès !")
def get_queryset(self):
return super().get_queryset().filter(archived=False)
def get(self, request, *args, **kwargs):
petition = self.get_object()
petition.archived = True
petition.save()
return super().get(request, *args, **kwargs)
class DeleteSignatureView(CreatorOnlyMixin, SingleObjectMixin, FormView):
model = Petition
template_name = "petitions/delete_signature.html"
form_class = DeleteForm
def get_success_url(self):
return reverse("petition.admin", args=[self.object.pk]) + "#s_{anchor}".format(
**self.kwargs
)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["signature"] = self.signature
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["anchor"] = self.kwargs["anchor"]
return context
def get_queryset(self):
return super().get_queryset().filter(archived=False)
def get(self, request, *args, **kwargs):
self.object = super().get_object()
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = super().get_object()
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
return super().post(request, *args, **kwargs)
def form_valid(self, form):
if form.cleaned_data["delete"] == "oui":
# On envoie un mail à la personne lui indiquant que la signature est supprimée
# si l'adresse mail est validée
if self.signature.verified:
EmailMessage(
subject="Signature removed",
body=MAIL_SIGNATURE_DELETED.format(
full_name=self.signature.full_name,
petition_name_fr=self.object.title_fr,
petition_name_en=self.object.title_en,
),
to=[self.signature.email],
).send()
# On supprime la signature
self.signature.delete()
return super().form_valid(form)
class ValidateSignatureView(CreatorOnlyMixin, SingleObjectMixin, FormView):
model = Petition
template_name = "petitions/validate_signature.html"
form_class = ValidateForm
def get_success_url(self):
return reverse("petition.admin", args=[self.object.pk]) + "#s_{anchor}".format(
**self.kwargs
)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["signature"] = self.signature
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["anchor"] = self.kwargs["anchor"]
return context
def get_queryset(self):
return super().get_queryset().filter(archived=False)
def get(self, request, *args, **kwargs):
self.object = super().get_object()
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = super().get_object()
self.signature = self.object.signatures.get(pk=self.kwargs["signature_pk"])
return super().post(request, *args, **kwargs)
def form_valid(self, form):
if form.cleaned_data["validate"] == "oui":
# On valide la signature
self.signature.valid = True
self.signature.save()
return super().form_valid(form)
# #############################################################################
# Email validation Views
# #############################################################################
class EmailValidationView(SingleObjectMixin, BackgroundUpdateView):
model = Signature
slug_url_kwarg = "token"
slug_field = "token"
success_message = _("Adresse email vérifiée avec succès")
def get_queryset(self):
return super().get_queryset().filter(verified=False)
def get_redirect_url(self, *args, **kwargs):
return reverse("petition.view", args=[self.object.petition.pk])
def get(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.verified = True
self.object.save()
return super().get(request, *args, **kwargs)
# #############################################################################
# Public Views
# #############################################################################
class PetitionListView(ListView):
model = Petition
template_name = "petitions/petition_list.html"
class PetitionView(DetailView):
model = Petition
template_name = "petitions/petition.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["today"] = date.today()
context["signatures"] = self.object.signatures.filter(verified=True)
return context
def get_queryset(self):
return super().get_queryset().select_related("created_by")
class PetitionSignView(CreateView):
model = Signature
form_class = SignatureForm
template_name = "petitions/petition_sign.html"
def get_success_url(self):
return reverse("petition.view", args=[self.petition.pk])
def dispatch(self, request, *args, **kwargs):
self.petition = Petition.objects.get(pk=self.kwargs["pk"])
if self.petition.launch_date > date.today():
raise Http404
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["petition"] = self.petition
return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.instance.petition = self.petition
return form
def form_valid(self, form):
self.object = form.save(commit=False)
# On envoie un mail à l'adresse indiquée
EmailMessage(
subject="Confirmation de la signature",
body=MAIL_SIGNATURE_VERIFICATION.format(
full_name=self.object.full_name,
petition_name_fr=self.object.petition.title_fr,
petition_name_en=self.object.petition.title_en,
confirm_url=full_url("petition.confirm-email", self.object.token),
),
to=[self.object.email],
).send()
return super().form_valid(form)

View file

@ -114,16 +114,22 @@
<nav class="level has-background-primary"> <nav class="level has-background-primary">
<div class="level-left px-4"> <div class="level-left px-4">
<div class="level-item"> <div class="level-item">
<a href="{% url "kadenios" %}"> <a href="{% url 'kadenios' %}">
<h1 class="has-text-primary-light is-size-1 is-family-secondary">Kadenios</h1> <h1 class="has-text-primary-light is-size-1 is-family-secondary">Kadenios</h1>
</a> </a>
</div> </div>
<div class="level-item px-4"> <div class="level-item px-5">
<a href="{% url "election.list" %}"> <a href="{% url 'election.list' %}">
<h3 class="has-text-primary-light has-text-weight-semibold is-size-3">{% trans "Élections" %}</h3> <h3 class="has-text-primary-light has-text-weight-semibold is-size-3">{% trans "Élections" %}</h3>
</a> </a>
</div> </div>
<div class="level-item px-5">
<a href="{% url 'petition.list' %}">
<h3 class="has-text-primary-light has-text-weight-semibold is-size-3">{% trans "Pétitions" %}</h3>
</a>
</div>
</div> </div>
<div class="level-right px-5"> <div class="level-right px-5">

View file

@ -1,3 +1,8 @@
from secrets import token_urlsafe
from django.conf import settings
from django.urls import reverse
# ############################################################################# # #############################################################################
# Fonctions universelles # Fonctions universelles
# ############################################################################# # #############################################################################
@ -9,3 +14,13 @@ def choices_length(choices):
for c in choices: for c in choices:
m = max(m, len(c[0])) m = max(m, len(c[0]))
return m return m
def token_generator():
"""Renvoie un token aléatoire pouvant être utilisé dans une url"""
return token_urlsafe(36)
def full_url(name, *args, **kwargs):
url = reverse(name, args=args, kwargs=kwargs)
return f"https://{settings.SERVER_DOMAIN}{url}"