beaucoup trop d'avancées pour les detailler

This commit is contained in:
Etienne GILLE
2026-01-23 12:17:57 +01:00
parent 60d35b8494
commit 425da48f77
14 changed files with 357 additions and 92 deletions

73
manifeste_velo/package-lock.json generated Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "manifeste_velo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"bootstrap": "5.3.*",
"bootstrap-icons": "1.13.*",
"htmx.org": "2.0.*",
"leaflet": "~1.9.4"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT",
"peerDependencies": {
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap-icons": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/htmx.org": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
"integrity": "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==",
"license": "0BSD"
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"bootstrap": "5.3.*",
"bootstrap-icons": "1.13.*",
"htmx.org": "2.0.*",
"leaflet": "~1.9.4"
}
}

View File

@@ -1,9 +1,24 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from questionnaire import models from questionnaire import models
# Register your models here. # Register your models here.
class UtilisateurInline(admin.StackedInline):
model = models.Utilisateur
can_delete = False
class UserAdmin(BaseUserAdmin):
inlines = [UtilisateurInline]
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
admin.site.register(models.Ville) admin.site.register(models.Ville)
admin.site.register(models.PointManifeste) admin.site.register(models.PointManifeste)
admin.site.register(models.Liste) admin.site.register(models.Liste)

View File

@@ -0,0 +1,24 @@
# Generated by Django 6.0.1 on 2026-01-23 09:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('questionnaire', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Utilisateur',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('ville', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='questionnaire.ville')),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-23 09:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('questionnaire', '0002_utilisateur'),
]
operations = [
migrations.AddField(
model_name='candidat',
name='email',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django.db import models from django.db import models
from django_extensions.db.fields import AutoSlugField from django_extensions.db.fields import AutoSlugField
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
@@ -14,6 +15,14 @@ class Ville(models.Model):
return f"{self.nom} ({self.code_insee})" return f"{self.nom} ({self.code_insee})"
class Utilisateur(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
ville = models.ForeignKey(Ville, on_delete=models.SET_NULL, blank=True, null=True)
def __str__(self):
return self.user
class PointManifeste(models.Model): class PointManifeste(models.Model):
titre = models.CharField(max_length=255) titre = models.CharField(max_length=255)
texte = models.TextField() texte = models.TextField()
@@ -32,9 +41,10 @@ class PointManifeste(models.Model):
class Candidat(TimeStampedModel, models.Model): class Candidat(TimeStampedModel, models.Model):
nom = models.CharField(max_length=255) nom = models.CharField(max_length=255)
prenom = models.CharField(max_length=255, verbose_name="Prénom") prenom = models.CharField(max_length=255, verbose_name="Prénom")
email = models.CharField(max_length=255, blank=True, null=True)
def __str__(self): def __str__(self):
return f"{self.prenom} {self.nom} - {self.ville.nom}" return f"{self.prenom} {self.nom}"
class Liste(TimeStampedModel, models.Model): class Liste(TimeStampedModel, models.Model):
@@ -50,10 +60,11 @@ class Liste(TimeStampedModel, models.Model):
responsable_mobilites = models.OneToOneField( responsable_mobilites = models.OneToOneField(
Candidat, on_delete=models.PROTECT, verbose_name="Responsable mobilités de la liste" Candidat, on_delete=models.PROTECT, verbose_name="Responsable mobilités de la liste"
) )
email_ouvert = models.DateTimeField(blank=True, null=True, verbose_name="L'e-mail au candidat a été ouvert") email_envoye = models.DateTimeField(blank=True, null=True, verbose_name="Date et heure de l'envoi de l'e-mail")
email_ouvert = models.DateTimeField(blank=True, null=True, verbose_name="Date et heure d'ouverture de l'e-mail")
def __str__(self): def __str__(self):
return f"{self.nom} - {self.ville.nom} - {self.tete_liste.prenom} {self.tete_liste.nom}" return f"{self.nom} - {self.ville.nom} - {self.tete_liste}"
class Meta: class Meta:
ordering = ["ville", "nom"] ordering = ["ville", "nom"]
@@ -64,6 +75,10 @@ class ReponseListe(TimeStampedModel, models.Model):
expression_libre = models.TextField(verbose_name="Expression libre") expression_libre = models.TextField(verbose_name="Expression libre")
email_confirmation = models.BooleanField(verbose_name="E-mail de confirmation envoyé") email_confirmation = models.BooleanField(verbose_name="E-mail de confirmation envoyé")
finalise = models.BooleanField(verbose_name="Réponse finalisée") finalise = models.BooleanField(verbose_name="Réponse finalisée")
date_heure_finalisation = models.DateTimeField(blank=True, null=True)
def __str__(self):
return f"Réponse pour la liste {self.liste}"
class EngagementChoices(models.TextChoices): class EngagementChoices(models.TextChoices):

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}{% load static %}
{% block body %}
<div class="container-fluid">
<nav class="navbar navbar-expand-md bg-dark border-bottom border-body" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="{% url "questionnaire:admin_landing" %}">
<img src="{% static "questionnaire/logo.png" %}" alt="" width="30" class="d-inline-block align-text-top">
Place au Vélo Angers
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Ouvrir ou Fermer le menu">
<span class="navbar-toggler-icon"></i>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a href="{% url "questionnaire:admin_questionnaires" %}" class="nav-link">Questionnaire</a>
</li>
<li class="nav-item">
<a href="{% url "questionnaire:admin_reponses" %}" class="nav-link">Réponses enregistrées</a>
</li>
<li class="nav-item">
<a href="{% url "questionnaire:admin_candidats" %}" class="nav-link">Gestion des candidats</a>
</li>
</ul>
<form class="d-flex" method="post" action="{% url "questionnaire:logout" %}">{% csrf_token %}
<button type="submit" class="btn btn-outline-success"><span class="bi bi-key"></span>Se déconnecter</button>
</form>
</div>
</div>
</nav>
</div>
{% block admin_body %}
<div class="container-fluid">
<p class="lead">Bienvenue sur la page d'administration, vous pouvez sélectionner une fonction dans le menu</p>
</div>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends "questionnaire/admin.html" %}
{% block admin_body %}
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}{% load static %}
{% block body %}
<div class="container">
<div class="row mb-2">
<div class="col-md-4"></div>
<div class="col-md-4">
<div class="row">
<div class="col d-flex justify-content-center align-items-center">
<img src="{% static "questionnaire/logo.png" %}" alt="Place au Vélo Angers">
</div>
</div>
<div class="row">
<div class="col">
<h2 class="text-center">Connexion à la plateforme de gestion du manifeste</h2>
</div>
</div>
</div>
<div class="col-md-4"></div>
</div>
{% if form.errors %}
<div class="row mb-2">
<div class="col-md-4"></div>
<div class="col-md-4 alert alert-danger" role="alert"><ul class="list-group border-0">
{% for error in form.non_field_errors %}
<li class="list-group-item list-group-item-danger border-0"><span class="fw-bold">{{ error|escape }}</span></li>
{% endfor %}
</ul></div>
<div class="col-md-4"></div>
</div>
{% endif %}
<form method="post" action="{% url "questionnaire:login" %}">{% csrf_token %}<input type="hidden" id="next" value="{{ next }}">
<div class="row mb-2">
<div class="col-md-4"></div>
<div class="col-md-4">
<div class="row mb-1">
<label class="col-md-4 col-form-label" for="{{ form.username.id_for_label }}">{{ form.username.label }}</label>
<div class="col-md-8">
<input type="text" class="form-control" value="{{ form.username.value|default_if_none:"" }}" placeholder="{{ form.username.label }}" name="{{ form.username.html_name }}" id="{{ form.username.id_for_label }}">
</div>
</div>
<div class="row">
<label class="col-md-4 col-form-label" for="{{ form.password.id_for_label }}">{{ form.password.label }}</label>
<div class="col-md-8">
<input type="password" class="form-control" name="{{ form.password.html_name }}" id="{{ form.password.id_for_label }}">
</div>
</div>
</div>
<div class="col-md-4"></div>
</div>
<div class="row">
<div class="col-md-4"></div>
<div class="col-md-4 text-center">
<input type="submit" value="Se connecter" class="btn btn-primary d-block w-100">
</div>
<div class="col-md-4"></div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "questionnaire/admin.html" %}
{% block admin_body %}
<div class="container">
{% for question in object_list %}
<div class="row">
<div class="col-md-2"></div>
<div class="col-md-8">
{% if question.est_commun %}
<h5 class="text-success fw-bold">{{ question.ordre }}. {{ question.titre }} (Commun)</h5>
<p>{{ question.texte|truncatewords_html:10 }}</p>
{% else %}
<h5 class="text-success fw-bold">{{ question.ordre }}. {{ question.titre }}{% if request.user.is_staff %} ({{ question.ville.nom }}){% endif %}</h5>
<p>{{ question.texte|safe }}</p>
{% if question.exemple %}
<div class="d-block w-100 p-1 border border-warning">{{ question.exemple }}</div>
{% endif %}
{% endif %}
</div>
<div class="col-md-2"></div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,4 @@
{% extends "questionnaire/admin.html" %}
{% block admin_body %}
{% endblock %}

View File

@@ -1,9 +1,16 @@
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path from django.urls import path
from questionnaire import views
app_name = "questionnaire" app_name = "questionnaire"
urlpatterns = [ urlpatterns = [
path("<uuid:liste>/logo.png", views.OuvertureEmail.as_view(), name="ouverture-email"),
path("login", LoginView.as_view(template_name="questionnaire/login.html"), name="login"), path("login", LoginView.as_view(template_name="questionnaire/login.html"), name="login"),
path("logout", LogoutView.as_view(template_name="questionnaire/logout.html"), name="logout"), path("logout", LogoutView.as_view(template_name="questionnaire/logout.html"), name="logout"),
path("admin/candidats", views.CandidatsLanding.as_view(), name="admin_candidats"),
path("admin/questions", views.QuestionnairesLanding.as_view(), name="admin_questionnaires"),
path("admin/reponses", views.ReponsesLanding.as_view(), name="admin_reponses"),
path("admin", views.AdminLanding.as_view(), name="admin_landing"),
] ]

View File

@@ -1,3 +1,68 @@
from django.shortcuts import render from datetime import datetime
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.urls import reverse_lazy
from django.views.generic.base import RedirectView, TemplateView
from django.views.generic.list import ListView
from questionnaire.models import Liste, PointManifeste, ReponseListe
# Create your views here. # Create your views here.
class OuvertureEmail(RedirectView):
permanent = True
query_string = False
url = settings.STATIC_URL + "/questionnaire/logo.png"
def get_redirect_url(self, *args, **kwargs):
liste = Liste.objects.get(uuid=kwargs["liste"])
if liste.email_ouvert is None and liste.email_envoye is not None:
liste.email_ouvert = datetime.now()
liste.save()
return super().get_redirect_url(*args, **kwargs)
class AdminViewMixin(LoginRequiredMixin):
login_url = reverse_lazy("questionnaire:login")
redirect_field_name = "next"
class AdminLanding(AdminViewMixin, TemplateView):
template_name = "questionnaire/admin.html"
class ReponsesLanding(AdminViewMixin, ListView):
model = ReponseListe
template_name = "questionnaire/candidats.html"
def get_queryset(self):
if self.request.user.is_staff:
return super().get_queryset()
return ReponseListe.objects.filter(liste__ville=self.request.user.profile.ville)
class CandidatsLanding(AdminViewMixin, ListView):
model = Liste
template_name = "questionnaire/candidats.html"
def get_queryset(self):
if self.request.user.is_staff:
return super().get_queryset()
return Liste.objects.filter(ville=self.request.user.profile.ville)
class QuestionnairesLanding(AdminViewMixin, ListView):
model = PointManifeste
template_name = "questionnaire/questions.html"
def get_queryset(self):
if self.request.user.is_staff:
queryset = super().get_queryset()
print(queryset.explain())
print("Return full queryset")
return super().get_queryset()
return PointManifeste.objects.filter(Q(est_commun=True) | Q(ville=self.request.user.profile.ville))

View File

@@ -1,88 +0,0 @@
{% extends 'base.html' %}{% load static %}{% load simple_menu %}
{% block body %}{% generate_menu %}
<div class="container-fluid">
<div class="row">
<div class="col">
<a class="d-flex align-items-center mt-2 pb-3 mb-3 link-body-emphasis text-decoration-none border-bottom" href="{% url 'admin_benevolat:index' %}">
<img src="{% get_media_prefix %}{{ config.LOGO_ASSO }}" height="100px" class="me-3">
<span class="fs-4 fw-semibold">{{ config.NOM_ASSO }}</span>
</a>
</div>
</div>
<div class="row">
<div class="col-3 p-3 border-end" role="navigation">
<!--<div class="flex-shrink-0 p-3">-->
<ul class="list-unstyled ps-0">
{% for item in menus.admin %}
<li class="mb-1">
<button
class="btn btn-toggle d-inline-flex align-items-center rounded border-0"
data-bs-toggle="collapse" data-bs-target="#{{ item.title|slugify }}-collapse" aria-expanded="false">
<i class="bi bi-chevron-right me-1" id="{{ item.title|slugify }}-icon"></i>
{% if item.icon != "" %}<i class="bi bi-{{ item.icon }} me-1"></i>{% endif %}{{ item.title }}
</button>
<div id="{{ item.title|slugify}}-collapse" class="collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal ms-3 small">
{% for subitem in item.children %}
<li>
<a hx-get="{{ subitem.url }}" hx-target="#admin_body" hx-push-url="true" class="link-body-emphasis d-inline-flex text-decoration-none rounded">
{% if subitem.icon %}<i class="bi bi-{{ subitem.icon }} me-1"></i>{% endif %}{{ subitem.title }}
</a>
{% if subitem.children %}
<ul class="nav nav-pills flex-column mb-auto ms-4 smaller">
{% for child in subitem.children %}
<li class="nav-item">
<a class="nav-link{% if child.selected %} active{% endif %}" hx-get="{{ child.url }}" hx-target="#admin_body" hx-push-url="true">
{% if child.icon %}<i class="bi bi-{{ child.icon }} me-1"></i>{% endif %}{{ child.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</li>
{% endfor %}
</ul>
<!--</div>-->
</div>
<div class="col-9" id="admin_body">
{% block admin_body %}
{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% block javascripts_footer %}
{{ block.super }}
<script type="text/javascript">
document.querySelectorAll('[id$=-collapse]').forEach(elt => {
elt.addEventListener('show.bs.collapse', event => {
var title = '#'+event.target.id.split('-collapse')[0] + '-icon';
console.log(title);
document.querySelector(title).classList.remove('bi-chevron-right');
document.querySelector(title).classList.add('bi-chevron-down');
});
});
document.querySelectorAll('[id$=-collapse]').forEach(elt => {
elt.addEventListener('hide.bs.collapse', event => {
var title = '#'+event.target.id.split('-collapse')[0] + '-icon';
console.log(title);
document.querySelector(title).classList.remove('bi-chevron-down');
document.querySelector(title).classList.add('bi-chevron-right');
});
});
document.querySelectorAll("div[role=navigation] a").forEach( elt => {
elt.addEventListener('htmx:afterRequest', event => {
console.log("so triggered")
document.querySelectorAll("div[role=navigation] a").forEach(subelt => { subelt.classList.remove("active"); });
event.target.classList.add("active");
});
});
</script>
{% endblock %}