feat: implement authorization request module including forms, templates, and view logic

This commit is contained in:
Karl Determe 2026-09-08 16:06:25 +02:00
parent 787799cf11
commit 64e93d7ae4
4 changed files with 294 additions and 10 deletions

View file

@ -134,8 +134,38 @@ class AuthorizationFilterForm(forms.Form):
widget=forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
required=False,
)
agent = forms.MultipleChoiceField(
label=_("Agent traitant"),
widget=forms.CheckboxSelectMultiple(attrs={'class': 'form-check-input'}),
required=False,
)
search_text = forms.CharField(
max_length=250,
required=False,
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Recherche…')}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from django.contrib.auth import get_user_model
from django.db.models import Q
from common.utils import get_short_name
User = get_user_model()
agent_ids = RoadAuthorization.objects.exclude(agent__isnull=True).values_list('agent_id', flat=True)
agent_qs = User.objects.filter(
Q(id__in=agent_ids) |
Q(config__accessible_views__code='authorizations') |
Q(is_superuser=True)
).distinct()
agent_choices = [
('unassigned', _("Non assigné")),
]
user_list = [
(str(u.pk), get_short_name(u)) for u in agent_qs
]
user_list.sort(key=lambda x: x[1].lower())
agent_choices.extend(user_list)
self.fields['agent'].choices = agent_choices

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load i18n static %}
{% load i18n static short_name %}
{% block head %}
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
@ -63,13 +63,13 @@
<form method="get" class="card mb-3 shadow-sm">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<div class="col-xl-3 col-md-4">
<label for="{{ filter_form.search_text.id_for_label }}" class="form-label small fw-semibold">
{% translate "Recherche" %}
</label>
{{ filter_form.search_text }}
</div>
<div class="col-md-4">
<div class="col-xl-3 col-md-4">
<label class="form-label small fw-semibold">{% translate "Type" %}</label>
<div class="dropdown">
<button class="btn btn-light border w-100 text-start d-flex justify-content-between align-items-center dropdown-toggle"
@ -108,7 +108,46 @@
</div>
</div>
</div>
<div class="col-md-2">
<div class="col-xl-2 col-md-4">
<label class="form-label small fw-semibold">{% translate "Agent" %}</label>
<div class="dropdown">
<button class="btn btn-light border w-100 text-start d-flex justify-content-between align-items-center dropdown-toggle"
type="button"
id="agentDropdownMenuButton"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
style="background-color: #fff; border-color: #dee2e6; color: #212529; font-size: 0.875rem; padding: 0.4375rem 0.75rem; border-radius: 6px; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;">
<span id="selected-agents-label" class="text-truncate me-2">{% translate "Tous les agents" %}</span>
<span class="badge rounded-pill bg-primary d-none" id="selected-agents-badge">0</span>
</button>
<div class="dropdown-menu p-3 shadow-lg" aria-labelledby="agentDropdownMenuButton" style="max-height: 350px; overflow-y: auto; min-width: 280px; border-radius: 10px; border: 1px solid rgba(0,0,0,0.08); z-index: 1050;">
<div class="mb-2 pb-2 border-bottom d-flex align-items-center justify-content-between gap-2">
<div class="input-group input-group-sm">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-search text-muted" style="font-size: 0.8rem;"></i></span>
<input type="text" id="agent-search-input" class="form-control bg-light border-start-0 ps-0" placeholder="{% translate 'Rechercher un agent...' %}" style="font-size: 0.8rem;">
</div>
<button type="button" class="btn btn-link btn-xs p-0 text-decoration-none text-danger small flex-shrink-0" id="clear-agents-btn" style="font-size: 0.75rem;">
<i class="bi bi-x-circle me-1"></i>{% translate "Effacer" %}
</button>
</div>
<div id="agent-choices-list">
{% for choice in filter_form.agent %}
<div class="form-check dropdown-item-checkbox p-2 rounded d-flex align-items-center" style="transition: background-color 0.2s; margin-bottom: 2px;">
{{ choice.tag }}
<label class="form-check-label ms-2 cursor-pointer w-100 text-truncate" for="{{ choice.id_for_label }}" style="font-size: 0.85rem; color: #495057; cursor: pointer; user-select: none;">
{{ choice.choice_label }}
</label>
</div>
{% endfor %}
</div>
<div id="agent-no-results" class="text-center text-muted py-3 d-none" style="font-size: 0.8rem;">
{% translate "Aucun agent correspondant" %}
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-6">
<div class="form-check mt-2">
{{ filter_form.include_archives }}
<label class="form-check-label" for="{{ filter_form.include_archives.id_for_label }}">
@ -116,7 +155,7 @@
</label>
</div>
</div>
<div class="col-md-2 d-flex gap-2">
<div class="col-xl-2 col-md-6 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-search"></i> {% translate "Filtrer" %}
</button>
@ -139,6 +178,7 @@
<th class="sortable" style="cursor: pointer; user-select: none;">{% translate "Type" %} <i class="bi bi-arrow-down-up ms-1 text-muted" style="font-size: 0.8rem;"></i></th>
<th class="sortable" style="cursor: pointer; user-select: none;">{% translate "Statut" %} <i class="bi bi-arrow-down-up ms-1 text-muted" style="font-size: 0.8rem;"></i></th>
<th class="sortable" style="cursor: pointer; user-select: none;">{% translate "Demandeur" %} <i class="bi bi-arrow-down-up ms-1 text-muted" style="font-size: 0.8rem;"></i></th>
<th class="sortable" style="cursor: pointer; user-select: none;">{% translate "Agent" %} <i class="bi bi-arrow-down-up ms-1 text-muted" style="font-size: 0.8rem;"></i></th>
<th class="sortable" style="cursor: pointer; user-select: none;">{% translate "Localisation" %} <i class="bi bi-arrow-down-up ms-1 text-muted" style="font-size: 0.8rem;"></i></th>
<th class="sortable" style="cursor: pointer; user-select: none;">{% translate "Date de création" %} <i class="bi bi-arrow-down ms-1 text-primary" style="font-size: 0.8rem;"></i></th>
<th></th>
@ -163,6 +203,7 @@
{% endif %}
</td>
<td data-sort="{{ auth.applicant_name }}">{{ auth.applicant_name }}</td>
<td data-sort="{{ auth.agent|short_name|default:'' }}">{{ auth.agent|short_name|default:"—" }}</td>
<td data-sort="{{ auth.location }}" class="text-truncate" style="max-width: 200px;" title="{{ auth.location }}">{{ auth.location }}</td>
<td data-sort="{{ auth.creation_date|date:'Y-m-d-H-i-s' }}">{{ auth.creation_date|date:"d/m/Y" }}</td>
<td>
@ -173,7 +214,7 @@
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<td colspan="8" class="text-center text-muted py-4">
{% translate "Aucune demande trouvée." %}
</td>
</tr>
@ -192,6 +233,7 @@
{% block script %}
<script>
document.addEventListener('DOMContentLoaded', () => {
// Type dropdown management
const checkboxes = document.querySelectorAll('#type-choices-list input[type="checkbox"]');
const label = document.getElementById('selected-types-label');
const badge = document.getElementById('selected-types-badge');
@ -268,10 +310,87 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Agent dropdown management
const agentCheckboxes = document.querySelectorAll('#agent-choices-list input[type="checkbox"]');
const agentLabel = document.getElementById('selected-agents-label');
const agentBadge = document.getElementById('selected-agents-badge');
const clearAgentsBtn = document.getElementById('clear-agents-btn');
const agentSearchInput = document.getElementById('agent-search-input');
const agentNoResults = document.getElementById('agent-no-results');
function updateAgentDropdownLabel() {
const checked = Array.from(agentCheckboxes).filter(cb => cb.checked);
const count = checked.length;
if (count === 0) {
agentLabel.textContent = "{% translate 'Tous les agents' %}";
agentBadge.classList.add('d-none');
} else if (count === 1) {
const labelText = checked[0].nextElementSibling.textContent.trim();
agentLabel.textContent = labelText;
agentBadge.textContent = '1';
agentBadge.classList.remove('d-none');
} else {
agentLabel.textContent = `${count} {% translate 'agents sélectionnés' %}`;
agentBadge.textContent = count;
agentBadge.classList.remove('d-none');
}
}
updateAgentDropdownLabel();
agentCheckboxes.forEach(cb => {
cb.addEventListener('change', updateAgentDropdownLabel);
});
if (clearAgentsBtn) {
clearAgentsBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
agentCheckboxes.forEach(cb => {
cb.checked = false;
});
updateAgentDropdownLabel();
});
}
if (agentSearchInput) {
agentSearchInput.addEventListener('input', () => {
const query = agentSearchInput.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
let visibleCount = 0;
const items = document.querySelectorAll('#agent-choices-list .dropdown-item-checkbox');
items.forEach(item => {
const labelText = item.querySelector('.form-check-label').textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (labelText.includes(query)) {
item.classList.remove('d-none');
visibleCount++;
} else {
item.classList.add('d-none');
}
});
if (visibleCount === 0) {
agentNoResults.classList.remove('d-none');
} else {
agentNoResults.classList.add('d-none');
}
});
agentSearchInput.addEventListener('click', (e) => {
e.stopPropagation();
});
agentSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
}
});
}
// Dynamic table sorting
const headers = document.querySelectorAll('th.sortable');
const tbody = document.querySelector('table tbody');
let currentSortedColIndex = 5; // Date de création index
let currentSortedColIndex = 6; // Date de création index
let currentAscending = false; // descending by default (date de création desc)
headers.forEach((header) => {

View file

@ -1,8 +1,15 @@
from django.test import TestCase
from django.utils import timezone
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.contrib.auth import get_user_model
from django.urls import reverse
from datetime import datetime
from authorizations.models import RoadAuthorization, RoadAuthorizationDate
from common.models import UserConfig, AppView
User = get_user_model()
class RoadAuthorizationDateTest(TestCase):
def setUp(self):
@ -47,3 +54,116 @@ class RoadAuthorizationDateTest(TestCase):
end_date=end
)
self.assertEqual(str(auth_date), "— → 29/06/2026 18:00")
class RoadAuthorizationListAgentTest(TestCase):
def setUp(self):
poly = Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
self.multipoly = MultiPolygon(poly, srid=4326)
# Users
self.staff_user = User.objects.create_user(
username="staff_auth",
password="password123",
first_name="Staff",
last_name="Member"
)
self.config = UserConfig.objects.create(user=self.staff_user, is_intern=True)
self.auth_view, _ = AppView.objects.get_or_create(
code="authorizations",
defaults={"name_fr": "Autorisations", "name_nl": "Autorisaties", "url_name": "authorizations:authorization_list"}
)
self.config.accessible_views.add(self.auth_view)
self.agent1 = User.objects.create_user(
username="adupont",
password="password123",
first_name="Alice",
last_name="Dupont"
)
self.agent2 = User.objects.create_user(
username="bsmith",
password="password123",
first_name="Bob",
last_name="Smith"
)
# Authorizations
self.auth1 = RoadAuthorization.objects.create(
type=2,
description="Terrasse Alice",
applicant_name="Demandeur Alpha",
applicant_email="alpha@example.com",
applicant_address="Rue 1",
location="Bruxelles Centre",
geom=self.multipoly,
agent=self.agent1
)
self.auth2 = RoadAuthorization.objects.create(
type=21,
description="Échafaudage Bob",
applicant_name="Demandeur Beta",
applicant_email="beta@example.com",
applicant_address="Rue 2",
location="Ixelles",
geom=self.multipoly,
agent=self.agent2
)
self.auth3 = RoadAuthorization.objects.create(
type=22,
description="Conteneur Non Assigné",
applicant_name="Demandeur Gamma",
applicant_email="gamma@example.com",
applicant_address="Rue 3",
location="Schaerbeek",
geom=self.multipoly,
agent=None
)
self.client.login(username="staff_auth", password="password123")
self.url = reverse('authorizations:authorization_list')
def test_list_view_displays_agent_short_names(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
content = response.content.decode('utf-8')
# Alice Dupont -> Alice D.
self.assertIn("Alice D.", content)
# Bob Smith -> Bob S.
self.assertIn("Bob S.", content)
# Auth3 has no agent -> should display dash "—"
self.assertIn("—", content)
def test_filter_by_specific_agent(self):
response = self.client.get(self.url, {'agent': [str(self.agent1.pk)]})
self.assertEqual(response.status_code, 200)
auths = list(response.context['authorizations'])
self.assertIn(self.auth1, auths)
self.assertNotIn(self.auth2, auths)
self.assertNotIn(self.auth3, auths)
def test_filter_by_unassigned_agent(self):
response = self.client.get(self.url, {'agent': ['unassigned']})
self.assertEqual(response.status_code, 200)
auths = list(response.context['authorizations'])
self.assertIn(self.auth3, auths)
self.assertNotIn(self.auth1, auths)
self.assertNotIn(self.auth2, auths)
def test_filter_by_multiple_agents(self):
response = self.client.get(self.url, {'agent': [str(self.agent1.pk), 'unassigned']})
self.assertEqual(response.status_code, 200)
auths = list(response.context['authorizations'])
self.assertIn(self.auth1, auths)
self.assertIn(self.auth3, auths)
self.assertNotIn(self.auth2, auths)
def test_search_by_agent_name(self):
response = self.client.get(self.url, {'search_text': 'Alice'})
self.assertEqual(response.status_code, 200)
auths = list(response.context['authorizations'])
self.assertIn(self.auth1, auths)
self.assertNotIn(self.auth2, auths)
self.assertNotIn(self.auth3, auths)

View file

@ -330,12 +330,12 @@ def authorization_list(request):
from dateutil.relativedelta import relativedelta
filter_form = AuthorizationFilterForm(request.GET or None)
qs = RoadAuthorization.objects.prefetch_related('dates', 'documents').order_by('-creation_date')
qs = RoadAuthorization.objects.select_related('agent').prefetch_related('dates', 'documents').order_by('-creation_date')
if filter_form.is_valid():
data = filter_form.cleaned_data
# Text search over description, location, applicant fields and code
# Text search over description, location, applicant fields, code and agent
search = data.get('search_text', '').strip()
if search:
from django.db.models import Q
@ -344,7 +344,10 @@ def authorization_list(request):
Q(location__icontains=search) |
Q(applicant_name__icontains=search) |
Q(applicant_email__icontains=search) |
Q(code__icontains=search)
Q(code__icontains=search) |
Q(agent__first_name__icontains=search) |
Q(agent__last_name__icontains=search) |
Q(agent__username__icontains=search)
)
# Type filter
@ -352,6 +355,18 @@ def authorization_list(request):
if types:
qs = qs.filter(type__in=types)
# Agent filter
agents = data.get('agent')
if agents:
from django.db.models import Q
agent_q = Q()
if 'unassigned' in agents:
agent_q |= Q(agent__isnull=True)
user_agent_ids = [a for a in agents if a != 'unassigned']
if user_agent_ids:
agent_q |= Q(agent_id__in=user_agent_ids)
qs = qs.filter(agent_q)
# Archive filter: exclude entries whose latest end_date is > 2 years ago
if not data.get('include_archives'):
cutoff = datetime.now().astimezone() - relativedelta(years=2)