feat: implement desktop view of inspection list view with filtering, export capabilities, and map integration

This commit is contained in:
kdeterme 2026-08-28 14:29:42 +02:00
parent 96492a9726
commit 0fc77df306
11 changed files with 3509 additions and 248 deletions

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-08-28 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0013_userconfig_exceptional_transport_can_edit_and_more'),
]
operations = [
migrations.AddField(
model_name='userconfig',
name='inspection_list_filters',
field=models.JSONField(blank=True, default=dict, help_text='Préférences de filtrage pour la vue liste des inspections.', verbose_name='Filtres de la liste des inspections'),
),
migrations.AddField(
model_name='userconfig',
name='inspection_list_sort',
field=models.JSONField(blank=True, default=dict, help_text='Préférences de tri pour la vue liste des inspections.', verbose_name='Tri de la liste des inspections'),
),
]

View file

@ -229,6 +229,18 @@ class UserConfig(models.Model):
verbose_name="Tri de la liste des contrôles", verbose_name="Tri de la liste des contrôles",
help_text="Préférences de tri pour la vue liste des contrôles.", help_text="Préférences de tri pour la vue liste des contrôles.",
) )
inspection_list_filters = models.JSONField(
default=dict,
blank=True,
verbose_name="Filtres de la liste des inspections",
help_text="Préférences de filtrage pour la vue liste des inspections.",
)
inspection_list_sort = models.JSONField(
default=dict,
blank=True,
verbose_name="Tri de la liste des inspections",
help_text="Préférences de tri pour la vue liste des inspections.",
)
ASSETS_LIST_LAYOUT_CHOICES = [ ASSETS_LIST_LAYOUT_CHOICES = [
('both', 'Tableau et carte'), ('both', 'Tableau et carte'),
('table_only', 'Tableau seul'), ('table_only', 'Tableau seul'),

View file

@ -14,6 +14,7 @@ urlpatterns = [
path("save-intervention-list-preferences/", views.save_intervention_list_preferences, name="save_intervention_list_preferences"), path("save-intervention-list-preferences/", views.save_intervention_list_preferences, name="save_intervention_list_preferences"),
path("save-assets-list-layout/", views.save_assets_list_layout, name="save_assets_list_layout"), path("save-assets-list-layout/", views.save_assets_list_layout, name="save_assets_list_layout"),
path("save-observation-list-preferences/", views.save_observation_list_preferences, name="save_observation_list_preferences"), path("save-observation-list-preferences/", views.save_observation_list_preferences, name="save_observation_list_preferences"),
path("save-inspection-list-preferences/", views.save_inspection_list_preferences, name="save_inspection_list_preferences"),
path("save-control-list-preferences/", views.save_control_list_preferences, name="save_control_list_preferences"), path("save-control-list-preferences/", views.save_control_list_preferences, name="save_control_list_preferences"),
path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"), path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"),
path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"), path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"),

View file

@ -445,6 +445,37 @@ def save_control_list_preferences(request):
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404) return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required
@require_POST
def save_inspection_list_preferences(request):
"""API endpoint to save the user's preferred filters and sort for the inspections list."""
import json
try:
data = json.loads(request.body)
filters = data.get('filters', {})
sort = data.get('sort', {})
except (json.JSONDecodeError, KeyError):
return JsonResponse({'error': _('Données invalides')}, status=400)
try:
user_config = request.user.config
if not isinstance(filters, dict):
return JsonResponse({'error': _('Format de filtres invalide')}, status=400)
if not isinstance(sort, dict):
return JsonResponse({'error': _('Format de tri invalide')}, status=400)
user_config.inspection_list_filters = filters
user_config.inspection_list_sort = sort
user_config.save(update_fields=['inspection_list_filters', 'inspection_list_sort'])
return JsonResponse({'success': True, 'filters': filters, 'sort': sort})
except UserConfig.DoesNotExist:
return JsonResponse({'error': _('Configuration utilisateur non trouvée')}, status=404)
@login_required @login_required
@require_POST @require_POST
def save_assets_index_preferences(request): def save_assets_index_preferences(request):

View file

@ -0,0 +1,199 @@
/* Layout helpers for Inspections desktop view */
.inspections-layout #inspections-table-container {
position: relative;
min-height: 420px;
}
#filter-dropdown-wrapper {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 10;
}
.filter-dropdown {
min-width: min(640px, calc(100vw - 4rem));
}
.inspections-map {
height: calc(100vh - 210px);
min-height: 360px;
position: relative;
width: 100%;
margin-top: 50px;
}
.inspections-map-filter {
position: absolute;
top: 0.75rem;
right: 2.75rem;
z-index: 5;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.15);
}
.popup-scrollable {
max-height: 240px;
overflow-y: auto;
}
/* Supprime le contour bleu Bootstrap au focus sur l'input de recherche */
#inspections-search-input:focus {
outline: none;
box-shadow: none;
border-color: #dee2e6;
}
/* Styles pour la modale de filtre */
#filterModal .modal-dialog {
max-width: 95%;
}
#filterModal .modal-content {
display: flex;
flex-direction: column;
max-height: 90vh;
}
#filterModal .modal-body {
flex: 1;
overflow-y: auto;
}
#filterModal .modal-footer {
flex-shrink: 0;
}
.filter-section {
margin-bottom: 1rem;
}
/* Default badge styling */
.filter-option label {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
font-weight: 500;
cursor: pointer;
background-color: #e7f1ff;
color: #0c5cc9;
border-radius: 1rem;
padding: 0.4rem 0.5rem;
font-size: 0.75rem;
border: 1px solid #b8d4f1;
transition: all 0.2s ease;
width: 140px;
flex: 1 1 auto;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Contextual color badges - inactive state */
.filter-option label.context-badge {
border-color: currentColor;
color: inherit;
background-color: transparent;
}
.filter-option label.btn-outline-primary { border-color: #0c5cc9; color: #0c5cc9; }
.filter-option label.btn-outline-danger { border-color: #dc3545; color: #dc3545; }
.filter-option label.btn-outline-warning { border-color: #ffc107; color: #ffc107; }
.filter-option label.btn-outline-success { border-color: #28a745; color: #28a745; }
.filter-option label.btn-outline-info { border-color: #17a2b8; color: #17a2b8; }
.filter-option label.btn-outline-dark { border-color: #343a40; color: #343a40; }
.filter-option label.btn-outline-secondary { border-color: #6c757d; color: #6c757d; }
/* Default badges - checked state */
.filter-option input[type="checkbox"]:checked + label:not(.context-badge) {
background-color: #0c5cc9; color: white; border-color: #0c5cc9;
}
/* Contextual badges - checked state */
.filter-option input[type="checkbox"]:checked + label.context-badge { color: white; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-primary { background-color: #0c5cc9; border-color: #0c5cc9; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-danger { background-color: #dc3545; border-color: #dc3545; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-warning { background-color: #ffc107; border-color: #ffc107; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-success { background-color: #28a745; border-color: #28a745; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-info { background-color: #17a2b8; border-color: #17a2b8; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-dark { background-color: #343a40; border-color: #343a40; }
.filter-option input[type="checkbox"]:checked + label.btn-outline-secondary { background-color: #6c757d; border-color: #6c757d; }
/* Default badges - hover */
.filter-option input[type="checkbox"]:hover + label:not(.context-badge) { background-color: #cfe2f3; }
.filter-option.subcategory-option label {
font-size: 0.875em;
color: #555;
}
.filter-option-break {
flex-basis: 100%;
height: 0;
}
.filter-subcat-controls {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.filter-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
gap: 1rem;
}
.filter-section-header label {
font-weight: 600;
color: #333;
margin: 0;
flex-shrink: 0;
}
.filter-section-buttons {
display: flex;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
}
.filter-section-buttons button {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
white-space: nowrap;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2.5rem;
padding: 0.5rem;
background-color: #f8f9fa;
border-radius: 0.375rem;
border: 1px solid #dee2e6;
}
.filter-option {
display: flex;
align-items: center;
}
.filter-option input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
cursor: pointer;
}
@media (max-width: 991.98px) {
.inspections-layout #filter-dropdown-wrapper {
position: static;
margin-bottom: 1rem;
}
.inspections-layout #inspections-table-container {
padding-top: 0;
}
.filter-dropdown {
width: 100%;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,399 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static 'inspections/inspections_index.css' %}">
{% endblock head %}
{% block content %}
<!-- Modale de filtre des inspections -->
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les inspections" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body">
<form method="get" id="filter-form" class="mb-4">
<!-- Thématique -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Thématique" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="thematic">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="thematic">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="thematic">
{% for key, value, icon in thematic_choices %}
<div class="filter-option">
<input type="checkbox" name="thematic" id="thematic_{{ key }}" value="{{ key }}" {% if key in selected_thematics %}checked{% endif %}>
<label for="thematic_{{ key }}">
{% if icon %}<i class="bi {{ icon }} me-1"></i>{% endif %}{{ value }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Catégorie / Type d'asset -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Catégorie / Type d'asset" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="category">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="category">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="category">
{% for id, name, thematic_code, parent_id in category_choices %}
{% if parent_id %}{% ifchanged parent_id %}<div class="filter-option-break"></div>{% endifchanged %}{% endif %}
<div class="filter-option{% if parent_id %} subcategory-option{% endif %}" data-thematic="{{ thematic_code }}" data-category-id="{{ id }}" data-parent-id="{{ parent_id|default:'' }}">
<input type="checkbox" name="category" id="category_{{ id }}" value="{{ id }}" {% if id|stringformat:"s" in selected_categories %}checked{% endif %}>
<label for="category_{{ id }}">{% if parent_id %}<i class="bi bi-arrow-return-right me-1 text-muted small"></i>{% endif %}{{ name }}</label>
</div>
{% endfor %}
</div>
</div>
<!-- Résultat du contrôle -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Résultat du contrôle" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="result_status">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="result_status">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="result_status">
{% for key, label, badge, color_hex, icon in result_status_choices %}
<div class="filter-option">
<input type="checkbox" name="result_status" id="result_status_{{ key }}" value="{{ key }}" {% if key in selected_result_statuses %}checked{% endif %}>
<label for="result_status_{{ key }}" class="context-badge btn-outline-{{ badge }}">
<i class="bi {{ icon }} me-1"></i>{{ label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Nature -->
<div class="col-12 filter-section">
<div class="filter-section-header">
<label>{% translate "Nature" %}</label>
<div class="filter-section-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" data-field="nature">{% translate "Tous" %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" data-field="nature">{% translate "Aucun" %}</button>
</div>
</div>
<div class="filter-options" data-field="nature">
{% for key, label in nature_choices %}
<div class="filter-option">
<input type="checkbox" name="nature" id="nature_{{ key }}" value="{{ key }}" {% if key in selected_natures %}checked{% endif %}>
<label for="nature_{{ key }}">{{ label }}</label>
</div>
{% endfor %}
</div>
</div>
<!-- Période & Mes inspections -->
<div class="row mt-3">
<div class="col-md-6">
<label class="form-label">{% translate "Période" %}</label>
<div class="d-flex gap-2 align-items-center">
<input type="date" name="date_from" id="date_from" class="form-control" value="{{ date_from }}" title="{% translate 'Du' %}">
<span class="text-muted small">–</span>
<input type="date" name="date_to" id="date_to" class="form-control" value="{{ date_to }}" title="{% translate 'Au' %}">
</div>
</div>
<div class="col-md-6">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" name="mine" value="1" id="mine" {% if show_mine_only %}checked{% endif %}>
<label class="form-check-label" for="mine">{% translate "Mes inspections" %}</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button>
</div>
</div>
</div>
</div>
<div class="position-relative">
<!-- Layout selection buttons -->
<div class="position-absolute top-0 end-0 mt-2" style="z-index: 10;">
<div class="btn-group" role="group" aria-label="{% translate 'Sélection de la vue' %}">
<button id="layout-btn-table" class="btn btn-light" type="button" title="{% translate 'Vue tableau uniquement' %}">
<i class="bi bi-table"></i>
</button>
<button id="layout-btn-both" class="btn btn-light text-primary" type="button" title="{% translate 'Vue partagée' %}">
<i class="bi bi-layout-split"></i>
</button>
<button id="layout-btn-map" class="btn btn-light" type="button" title="{% translate 'Vue carte uniquement' %}">
<i class="bi bi-globe-europe-africa"></i>
</button>
</div>
</div>
<!-- Widget de recherche — centré sur toute la largeur (tableau + carte) -->
<div id="inspections-search-widget"
style="position:absolute; top:4px; left:50%; transform:translateX(-50%); z-index:10; pointer-events:none; max-width:calc(100% - 360px);">
<div class="input-group" style="pointer-events:auto; flex-wrap:nowrap;">
<div style="position:relative; display:flex; width:360px; max-width:100%; min-width:60px;">
<input type="text" id="inspections-search-input" class="form-control form-control-sm"
style="width:100%; border-radius:20px 0 0 20px; border-color:#dee2e6; padding-right:1.6rem;"
placeholder="{% translate 'Cible, notes, inspecteur…' %}">
<button type="button" id="inspections-search-clear"
style="position:absolute; right:4px; top:50%; transform:translateY(-50%);
background:none; border:none; padding:0 2px; line-height:1;
color:#adb5bd; cursor:pointer; visibility:hidden;"
title="{% translate 'Effacer la recherche' %}">
<i class="bi bi-x-lg" style="font-size:0.7rem;"></i>
</button>
</div>
<span class="input-group-text bg-white px-1" style="border-left:0; border-right:0; border-color:#dee2e6; flex-shrink:0;">
<div class="btn-group btn-group-sm" role="group" aria-label="{% translate 'Mode' %}">
<input type="radio" class="btn-check" name="insp-search-mode" id="insp-search-mode-and" value="and" checked>
<label class="btn btn-outline-secondary py-0 px-2" for="insp-search-mode-and" title="{% translate 'Tous les mots doivent correspondre' %}">{% translate "AND" %}</label>
<input type="radio" class="btn-check" name="insp-search-mode" id="insp-search-mode-or" value="or">
<label class="btn btn-outline-secondary py-0 px-2" for="insp-search-mode-or" title="{% translate 'Au moins un mot doit correspondre' %}">{% translate "OR" %}</label>
</div>
</span>
<button class="btn btn-sm px-3" type="button" id="inspections-search-btn"
style="border-radius:0 20px 20px 0; border:1px solid #dee2e6; border-left:0; background:white; color:#495057; flex-shrink:0;"
title="{% translate 'Rechercher' %}">
<i class="bi bi-search"></i>
</button>
</div>
</div>
<div class="row mt-2 inspections-layout" style="height: calc(100vh - 130px);">
<div class="col-md-6" id="col-left" style="max-height: calc(100vh - 130px);">
<ul class="nav nav-tabs" id="tablesTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="inspections-table-tab" data-bs-toggle="tab" data-bs-target="#inspections-table-content" type="button" role="tab" aria-controls="inspections-table" aria-selected="true">{% translate "Inspections" %}</button>
</li>
<div class="my-1 ms-2">
<a href="{% url 'inspections:quick_inspection_map' %}" class="btn btn-light" title="{% translate 'Nouvelle inspection sur carte' %}">
<i class="bi bi-plus-circle"></i>
</a>
</div>
</ul>
<div class="tab-content my-2" id="tablesTabContent">
<div class="tab-pane fade show active" id="inspections-table-content" role="tabpanel" aria-labelledby="inspections-table-tab">
<div class="d-flex justify-content-between flex-wrap align-items-center gap-2 mt-3">
<div class="text-muted small" id="inspections-result-count">
{% translate "Aucune inspection chargée" %}
</div>
<span id="filter-limit-alert" class="alert alert-info py-1 px-2 mb-0 d-none">
<i class="bi bi-info-circle me-1"></i>
{% translate "Contenu limité à 5 000 éléments." %}
</span>
</div>
<div id="inspections-spinner" class="table-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
<div id="inspections-table-container" class="d-none">
<div id="filter-dropdown-wrapper" class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-light" data-bs-toggle="modal" data-bs-target="#filterModal">
{% translate "Filtre" %}
</button>
<a id="export-excel-btn" href="{% url 'inspections:inspections_export_excel' %}" class="btn btn-light" title="{% translate 'Exporter en Excel' %}">
<i class="bi bi-file-earmark-spreadsheet"></i>
</a>
</div>
<table id="inspections-table" class="table"
data-height="550"
data-pagination="true"
data-page-size="50"
data-show-columns="true"
data-unique-id="id"
data-sort-name="inspection_date"
data-sort-order="desc">
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6" id="col-right">
<div id="map" class="rounded-2 inspections-map">
<button id="filterzone" class="btn btn-light btn-sm inspections-map-filter" type="button">
{% translate "Filtrer sur cette zone" %}
</button>
</div>
</div>
</div>
</div>
<!-- Modal Détail d'une Inspection -->
<div class="modal fade" id="inspectionDetailModal" tabindex="-1" aria-labelledby="inspectionDetailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel">
<i class="bi bi-search text-primary me-2" id="detail-modal-icon"></i>
<span id="detail-modal-title">{% translate "Détail de l'inspection" %}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3" id="detail-modal-body">
<div class="text-center py-4" id="detail-modal-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{% translate "Chargement..." %}</span>
</div>
</div>
<div id="detail-modal-content" class="d-none">
<div id="detail-cancelled-alert" class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3 d-none">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
</div>
</div>
<div id="detail-asset-btn-container" class="mb-3 d-none">
<a href="#" id="detail-asset-link" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
<span class="fw-bold text-end ms-2" id="detail-target-display">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary" id="detail-asset-type">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold" id="detail-date">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge" id="detail-result-badge">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border" id="detail-nature">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold" id="detail-inspector">-</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
<span class="fw-semibold" id="detail-updated-at">-</span>
</li>
</ul>
<!-- Contenu spécifique de l'inspection (partial HTML) -->
<div id="detail-specific-content" class="mt-3"></div>
<!-- Remarques -->
<div id="detail-notes-card" class="card bg-light border-0 p-3 mb-3 d-none">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small" id="detail-notes-text"></div>
</div>
<!-- Obsolescence -->
<div id="detail-obsolescence-card" class="card bg-warning-subtle border-0 p-3 mb-3 d-none">
<h6 class="fw-bold text-warning-emphasis mb-1"><i class="bi bi-exclamation-triangle me-2"></i>{% translate "Obsolescence" %}</h6>
<div class="text-warning-emphasis small" id="detail-obsolescence-text"></div>
</div>
<!-- Photos et Documents -->
<div id="detail-documents-card" class="card bg-light border-0 p-3 mb-3 d-none">
<h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} <span id="detail-documents-count"></span>
</h6>
<div id="detail-carousel-container" class="carousel slide rounded overflow-hidden shadow-sm bg-black mb-3 d-none" data-bs-ride="false">
<div class="carousel-indicators" id="detail-carousel-indicators"></div>
<div class="carousel-inner" id="detail-carousel-inner" style="max-height: 300px; min-height: 180px;"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#detail-carousel-container" data-bs-slide="prev">
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#detail-carousel-container" data-bs-slide="next">
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
<div class="d-flex flex-wrap gap-2" id="detail-non-image-docs"></div>
</div>
</div>
</div>
<div class="modal-footer bg-light py-2">
<div id="detail-edit-actions" class="d-flex gap-2">
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold d-none" id="detail-cancel-btn">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
<button type="button" class="btn btn-outline-success btn-sm fw-semibold d-none" id="detail-restore-btn">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
</button>
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold d-none" id="detail-edit-btn">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
<!-- Overlay Carrousel Plein Écran pour Photos -->
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
</div>
{% include "inspections/components/edit_inspection_modal.html" %}
{% endblock content %}
{% block script %}
<script>
const inspectionsGeojsonUrl = "{% url 'inspections:inspections_geojson_get' %}";
const inspectionsIndexUrl = "{% url 'inspections:inspections_list' %}";
const inspectionsExportExcelUrl = "{% url 'inspections:inspections_export_excel' %}";
const savePreferencesUrl = "{% url 'common:save_inspection_list_preferences' %}";
const SAVED_SORT = {{ saved_sort|default:"{}"|safe }};
const subcatNoneLabel = "{% translate '(aucune sous-catégorie)' %}";
</script>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'inspections/inspections_index.js' %}"></script>
{% endblock script %}

View file

@ -0,0 +1,751 @@
{% extends base_template|default:"base.html" %}
{% load i18n static l10n short_name inspections_tags %}
{% block title %}{% translate "Inspections d'assets" %}{% endblock %}
{% block extra_css %}
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style>
.inspection-card.is-cancelled {
background-color: #f8f9fa !important;
border: 1px dashed #adb5bd !important;
opacity: 0.7;
}
.inspection-card.is-cancelled .card-title,
.inspection-card.is-cancelled h6 {
color: #6c757d !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div>
<h3 class="mb-0 fw-bold">
<i class="bi bi-search text-primary me-2"></i>{% translate "Inspections" %}
</h3>
<p class="text-muted mb-0 small">{% translate "Suivi et historique des inspections et vérifications sur le terrain" %}</p>
</div>
{% if can_inspect %}
<div>
<a href="{% if is_mobile %}{% url 'mobile:quick_inspection_map_mobile' %}{% else %}{% url 'inspections:quick_inspection_map' %}{% endif %}" class="btn btn-primary shadow-sm">
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection" %}
</a>
</div>
{% endif %}
</div>
<!-- Navigation par Onglets -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if filter_type == 'my' %}active fw-bold{% endif %}" href="?tab=my">
<i class="bi bi-person-check me-1"></i>{% translate "Mes inspections" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_type == 'all' %}active fw-bold{% endif %}" href="?tab=all">
<i class="bi bi-collection me-1"></i>{% translate "Toutes les inspections" %}
</a>
</li>
</ul>
<!-- Barre de filtres -->
<div class="card mb-3 shadow-sm border-0 bg-light">
<div class="card-body p-3">
<form method="get" class="row g-2 align-items-center">
<input type="hidden" name="tab" value="{{ filter_type }}">
<div class="col-12 col-md-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" name="q" value="{{ search_q }}" class="form-control" placeholder="{% translate 'Rechercher par note, asset, ...' %}">
</div>
</div>
<div class="col-12 col-sm-6 col-md-2">
<select name="thematic" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Thématiques --" %}</option>
{% for t in thematics %}
<option value="{{ t.code }}" {% if selected_thematic == t.code %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-2">
<select name="nature" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Natures --" %}</option>
{% for val, label in nature_choices %}
<option value="{{ val }}" {% if selected_nature == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-3">
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Tous les résultats --" %}</option>
{% for val, label in result_choices %}
<option value="{{ val }}" {% if selected_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-secondary flex-grow-1">{% translate "Filtrer" %}</button>
<a href="?tab={{ filter_type }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}"><i class="bi bi-x-circle"></i></a>
</div>
</form>
</div>
</div>
<!-- Bouton Flottant (FAB) Carte des résultats -->
<button type="button" class="btn btn-primary rounded-circle shadow-lg position-fixed bottom-0 end-0 m-4 d-flex align-items-center justify-content-center"
style="width: 56px; height: 56px; z-index: 1050; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease;"
data-bs-toggle="modal"
data-bs-target="#inspectionsMapModal"
id="btn-open-inspections-map-fab"
title="{% translate 'Carte des résultats' %}"
aria-label="{% translate 'Carte des résultats' %}">
<i class="bi bi-map fs-4"></i>
</button>
{% if inspections %}
<!-- Liste des inspections sous forme de Cards -->
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="fw-semibold text-muted small">
{% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
<span class="fst-italic text-secondary">({% translate "limité aux 100 plus récentes" %})</span>
</span>
</div>
<div class="row g-3 mb-4">
{% for insp in inspections %}
<div class="col-12">
<div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer {% if insp.is_cancelled %}is-cancelled{% endif %}"
style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;"
data-bs-toggle="modal"
data-bs-target="#inspectionDetailModal{{ insp.id }}">
<div class="card-body p-3 d-flex flex-column justify-content-between">
<div>
<!-- Entête card : Thématique/Type + Badge résultat -->
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-light text-dark border d-inline-flex align-items-center me-2">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>
{{ insp.asset_content_type.name }}
</span>
<span class="badge {{ insp.status_badge_class }}">
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
</span>
</div>
<!-- Nom de la cible tronqué à 100 caractères -->
<h6 class="fw-bold text-dark mb-1 text-break">
{{ insp.target_display|truncatechars:100 }}
</h6>
<!-- Résumé des constats si présent -->
{% if insp.details_summary and insp.details_summary != "-" %}
<p class="text-muted small mb-2 text-truncate mt-1">
<i class="bi bi-ui-checks me-1 text-secondary"></i>{{ insp.details_summary }}
</p>
{% endif %}
</div>
<!-- Ligne de pied card : Date & Inspecteur -->
<div class="d-flex justify-content-between align-items-center pt-2 border-top mt-2 text-muted extra-small">
<span><i class="bi bi-clock me-1"></i>{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
<div class="d-flex align-items-center gap-2">
{% if insp.documents.all %}
<span class="badge bg-light text-secondary border" title="{% translate 'Photos / Documents joints' %}">
<i class="bi bi-paperclip me-1"></i>{{ insp.documents.count }}
</span>
{% endif %}
{% if insp.inspector %}
<span><i class="bi bi-person me-1"></i>{{ insp.inspector|short_name|default:insp.inspector.username }}</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Modal de détail de l'inspection -->
<div class="modal fade" id="inspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="inspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel{{ insp.id }}">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>{% translate "Détail de l'inspection" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
{% if insp.is_cancelled %}
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
</div>
</div>
{% endif %}
<!-- Lien vers la fiche de l'asset -->
{% if insp.asset %}
<div class="mb-3">
<a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
{% endif %}
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge {{ insp.status_badge_class }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
<span class="fw-semibold">{{ insp.updated_at|date:"d/m/Y H:i" }}</span>
</li>
</ul>
{% get_inspection_detail_template insp as detail_template %}
{% if detail_template %}
<div class="mt-3">
{% include detail_template with insp=insp %}
</div>
{% endif %}
{% if insp.notes %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small">{{ insp.notes }}</div>
</div>
{% endif %}
{% if insp.verified_obsolescence %}
<div class="card bg-warning-subtle border-0 p-3 mb-3">
<h6 class="fw-bold text-warning-emphasis mb-1"><i class="bi bi-exclamation-triangle me-2"></i>{% translate "Obsolescence" %}</h6>
<div class="text-warning-emphasis small">{{ insp.verified_obsolescence }}</div>
</div>
{% endif %}
{% if insp.documents.exists %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-images me-2 text-primary"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
</h6>
{% with docs=insp.documents.all %}
<!-- Carrousel Photos Bootstrap -->
<div id="carouselInsp{{ insp.id }}" class="carousel slide rounded overflow-hidden shadow-sm bg-black mb-3" data-bs-ride="false">
<div class="carousel-indicators">
{% for doc in docs %}
{% if doc.is_image %}
<button type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide-to="{{ forloop.counter0 }}" {% if forloop.first %}class="active" aria-current="true"{% endif %} aria-label="Slide {{ forloop.counter }}"></button>
{% endif %}
{% endfor %}
</div>
<div class="carousel-inner" style="max-height: 300px; min-height: 180px;">
{% for doc in docs %}
{% if doc.is_image %}
<div class="carousel-item {% if forloop.first %}active{% endif %} h-100">
<div class="d-flex align-items-center justify-content-center bg-black position-relative" style="height: 260px;">
<img data-src="{{ doc.file_url }}" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E" loading="lazy" class="d-block img-fluid mh-100 mw-100 list-insp-carousel-trigger" style="object-fit: contain; max-height: 260px; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" alt="{{ doc.filename }}">
<button type="button" class="btn btn-sm btn-dark bg-opacity-75 text-white rounded-circle p-1.5 shadow list-insp-carousel-trigger position-absolute bottom-0 end-0 m-2" style="z-index: 10; cursor: pointer;" data-insp-id="{{ insp.id }}" data-img-index="{{ forloop.counter0 }}" title="Plein écran">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="prev">
<span class="carousel-control-prev-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselInsp{{ insp.id }}" data-bs-slide="next">
<span class="carousel-control-next-icon p-2 rounded-circle bg-dark bg-opacity-50" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
<!-- Documents non-images -->
<div class="flex-wrap gap-2 d-flex">
{% for doc in docs %}
{% if not doc.is_image %}
<a href="{{ doc.file_url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
<i class="bi bi-file-earmark-arrow-down text-primary"></i>
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
</a>
{% endif %}
{% endfor %}
</div>
{% endwith %}
</div>
{% endif %}
</div>
<div class="modal-footer bg-light py-2">
{% can_edit_inspection insp request.user as user_can_edit %}
{% if user_can_edit %}
{% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'restore')">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
</button>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold" onclick="handleToggleCancelInspection({{ insp.id }}, 'cancel')">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card shadow-sm border-0 p-5 text-center text-muted">
<i class="bi bi-inbox fs-1 d-block mb-3 text-secondary"></i>
<h5>{% translate "Aucune inspection trouvée" %}</h5>
<p class="small text-muted mb-0">{% translate "Aucune inspection ne correspond à vos critères de recherche." %}</p>
</div>
{% endif %}
</div>
<!-- Overlay Carrousel Plein Écran pour Photos (avec bouton fermer) -->
<div id="fullscreenPhotoOverlay" class="d-none position-fixed top-0 start-0 w-100 h-100 bg-black" style="z-index: 999999;">
<!-- Bouton Fermer -->
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3 fs-4 shadow p-2" id="btn-close-photo-overlay" style="z-index: 1000005; cursor: pointer;" aria-label="Fermer"></button>
<!-- Conteneur Carrousel -->
<div id="fullscreenPhotoCarousel" class="carousel slide w-100 h-100" data-bs-ride="false">
<div class="carousel-indicators mb-3" id="fullscreen-carousel-indicators" style="z-index: 1000001;"></div>
<div class="carousel-inner w-100 h-100" id="fullscreen-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="prev" style="z-index: 1000000;">
<span class="carousel-control-prev-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Précédent</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#fullscreenPhotoCarousel" data-bs-slide="next" style="z-index: 1000000;">
<span class="carousel-control-next-icon p-3 rounded-circle bg-dark bg-opacity-75" aria-hidden="true"></span>
<span class="visually-hidden">Suivant</span>
</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const listTriggers = document.querySelectorAll('.list-insp-carousel-trigger');
listTriggers.forEach(trig => {
trig.addEventListener('click', function(e) {
e.preventDefault();
const inspId = this.getAttribute('data-insp-id');
const startIndex = parseInt(this.getAttribute('data-img-index'), 10) || 0;
const carouselEl = document.getElementById('carouselInsp' + inspId);
if (!carouselEl) return;
const imgs = carouselEl.querySelectorAll('img.list-insp-carousel-trigger');
const imagesData = [];
imgs.forEach(img => {
imagesData.push({
url: img.getAttribute('src'),
filename: img.getAttribute('alt') || 'Photo'
});
});
const overlay = document.getElementById('fullscreenPhotoOverlay');
const fsInner = document.getElementById('fullscreen-carousel-inner');
const fsIndicators = document.getElementById('fullscreen-carousel-indicators');
if (!overlay || !fsInner) return;
fsInner.innerHTML = '';
if (fsIndicators) fsIndicators.innerHTML = '';
imagesData.forEach((doc, idx) => {
const activeClass = idx === startIndex ? 'active' : '';
const item = document.createElement('div');
item.className = `carousel-item ${activeClass} w-100 h-100`;
item.innerHTML = `
<div class="w-100 h-100 d-flex flex-column align-items-center justify-content-center p-3">
<img src="${doc.url}" class="img-fluid mh-100 mw-100 shadow-lg" style="object-fit: contain; max-height: 85vh;" alt="${doc.filename}">
<div class="text-white small mt-2 bg-dark bg-opacity-75 px-3 py-1 rounded-pill">${doc.filename}</div>
</div>
`;
fsInner.appendChild(item);
if (fsIndicators && imagesData.length > 1) {
const ind = document.createElement('button');
ind.type = 'button';
ind.setAttribute('data-bs-target', '#fullscreenPhotoCarousel');
ind.setAttribute('data-bs-slide-to', idx.toString());
if (idx === startIndex) {
ind.className = 'active';
ind.setAttribute('aria-current', 'true');
}
ind.setAttribute('aria-label', `Photo ${idx + 1}`);
fsIndicators.appendChild(ind);
}
});
overlay.classList.remove('d-none');
const btnClose = document.getElementById('btn-close-photo-overlay');
if (btnClose) {
const closeHandler = function(e) {
if (e) e.preventDefault();
overlay.classList.add('d-none');
btnClose.removeEventListener('click', closeHandler);
};
btnClose.addEventListener('click', closeHandler);
}
const escHandler = function(e) {
if (e.key === 'Escape') {
overlay.classList.add('d-none');
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
});
});
});
</script>
<!-- Modale Carte des Inspections filtrées -->
<div class="modal fade" id="inspectionsMapModal" tabindex="-1" aria-labelledby="inspectionsMapModalLabel" aria-hidden="true" style="z-index: 1070;">
<div class="modal-dialog modal-xl modal-dialog-centered mx-auto" style="width: 92vw; max-width: 1200px;">
<div class="modal-content shadow-lg border-0 rounded-3">
<div class="modal-header bg-dark text-white py-2.5">
<h5 class="modal-title fs-6 fw-bold d-flex align-items-center gap-2" id="inspectionsMapModalLabel">
<i class="bi bi-map text-info fs-5"></i>
{% translate "Carte des résultats d'inspections" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0 position-relative">
<div id="inspections-list-modal-map" style="width: 100%; height: 75vh; border-radius: 0 0 0.5rem 0.5rem;"></div>
</div>
</div>
</div>
</div>
<script src="{% static 'common/maplibre-gl.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('inspectionsMapModal');
if (!modalEl) return;
let mapModalInstance = null;
const rawInspectionsData = [
{% for insp in inspections %}
{% if insp.lon_lat %}
{
id: {{ insp.id }},
lon: {{ insp.lon_lat.0|unlocalize }},
lat: {{ insp.lon_lat.1|unlocalize }},
title: "{{ insp.target_display|escapejs }}",
date: "{{ insp.inspection_date|date:'d/m/Y H:i' }}",
date_raw: "{{ insp.inspection_date|date:'Y-m-d H:i:s' }}",
result: "{{ insp.get_result_status_display|escapejs }}",
result_code: "{{ insp.result_status|escapejs }}",
badge_class: "{{ insp.status_badge_class|escapejs }}",
inspector: "{{ insp.inspector|short_name|default:insp.inspector.username|escapejs }}",
nature: "{{ insp.get_nature_display|escapejs }}",
summary: "{{ insp.details_summary|escapejs }}"
}{% if not forloop.last %},{% endif %}
{% endif %}
{% endfor %}
];
// Regroupement des inspections par emplacement (lon, lat)
const groupedMap = {};
rawInspectionsData.forEach(item => {
const key = item.lon.toFixed(6) + '_' + item.lat.toFixed(6);
if (!groupedMap[key]) {
groupedMap[key] = {
lon: item.lon,
lat: item.lat,
title: item.title,
inspections: []
};
}
groupedMap[key].inspections.push(item);
});
const groupedList = Object.values(groupedMap);
groupedList.forEach(grp => {
grp.inspections.sort((a, b) => b.date_raw.localeCompare(a.date_raw));
grp.latest_result_code = grp.inspections[0].result_code;
grp.inspections_count = grp.inspections.length;
grp.inspections_json = JSON.stringify(grp.inspections);
});
modalEl.addEventListener('shown.bs.modal', function() {
if (!mapModalInstance) {
mapModalInstance = new maplibregl.Map({
container: 'inspections-list-modal-map',
style: window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [4.35, 50.85],
zoom: 12
});
mapModalInstance.addControl(new maplibregl.NavigationControl(), 'bottom-right');
mapModalInstance.on('load', function() {
const geojson = {
type: 'FeatureCollection',
features: groupedList.map(m => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [m.lon, m.lat]
},
properties: {
title: m.title,
latest_result_code: m.latest_result_code,
inspections_count: m.inspections_count,
inspections_json: m.inspections_json
}
}))
};
mapModalInstance.addSource('inspections-list-modal-source', {
type: 'geojson',
data: geojson
});
mapModalInstance.addLayer({
id: 'inspections-list-modal-layer',
type: 'circle',
source: 'inspections-list-modal-source',
paint: {
'circle-color': [
'match',
['get', 'latest_result_code'],
'compliant', '#198754',
'non_compliant', '#ffc107',
'damaged', '#dc3545',
'missing', '#212529',
'#0d6efd'
],
'circle-radius': 8,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
mapModalInstance.on('mouseenter', 'inspections-list-modal-layer', function() {
mapModalInstance.getCanvas().style.cursor = 'pointer';
});
mapModalInstance.on('mouseleave', 'inspections-list-modal-layer', function() {
mapModalInstance.getCanvas().style.cursor = '';
});
mapModalInstance.on('click', 'inspections-list-modal-layer', function(e) {
if (!e.features || !e.features.length) return;
const clickPoint = e.point;
const rendered = mapModalInstance.queryRenderedFeatures(clickPoint, { layers: ['inspections-list-modal-layer'] });
let combinedInspections = [];
let popupTitle = '';
rendered.forEach(f => {
try {
const insps = JSON.parse(f.properties.inspections_json || '[]');
if (!popupTitle && f.properties.title) popupTitle = f.properties.title;
combinedInspections = combinedInspections.concat(insps);
} catch (err) {}
});
// Déduplication & tri par date décroissante
const dedup = {};
combinedInspections.forEach(item => { dedup[item.id] = item; });
const sortedInspections = Object.values(dedup).sort((a, b) => b.date_raw.localeCompare(a.date_raw));
if (!sortedInspections.length) return;
const coords = e.features[0].geometry.coordinates.slice();
let contentHtml = '';
if (sortedInspections.length === 1) {
const insp = sortedInspections[0];
contentHtml = `
<div class="p-1" style="min-width: 240px;">
<div class="d-flex justify-content-between align-items-center mb-1 gap-2">
<span class="badge ${insp.badge_class}">${insp.result}</span>
<small class="text-muted">${insp.date}</small>
</div>
<h6 class="fw-bold mb-1 text-dark">${insp.title}</h6>
<div class="small text-muted mb-1"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
<p class="small text-secondary mb-2">${insp.summary}</p>
<button type="button" class="btn btn-xs btn-outline-primary w-100 py-1 open-detail-from-modal" data-insp-id="${insp.id}">
<i class="bi bi-eye me-1"></i>Détail
</button>
</div>
`;
} else {
let rowsHtml = '';
sortedInspections.forEach(insp => {
rowsHtml += `
<div class="p-2 border-bottom d-flex align-items-center justify-content-between gap-2">
<div class="min-w-0 flex-grow-1">
<div class="d-flex flex-wrap align-items-center gap-1 mb-1">
<span class="badge ${insp.badge_class} text-truncate flex-shrink-0" style="font-size: 0.7rem; max-width: 140px;">${insp.result}</span>
<span class="fw-semibold text-primary text-nowrap insp-date-modal-link open-detail-from-modal" style="cursor: pointer; font-size: 0.78rem;" data-insp-id="${insp.id}">
📅 ${insp.date}
</span>
</div>
<div class="small text-muted text-truncate" style="max-width: 220px;"><i class="bi bi-person me-1"></i>${insp.inspector}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1 flex-shrink-0 open-detail-from-modal ms-1" data-insp-id="${insp.id}" style="font-size: 0.75rem; border-radius: 0.375rem;" title="Voir l'inspection">
<i class="bi bi-eye me-1"></i>Détail
</button>
</div>
`;
});
contentHtml = `
<div class="p-1" style="min-width: 320px; max-width: 360px;">
<div class="d-flex justify-content-between align-items-center mb-2 pb-1 border-bottom">
<h6 class="fw-bold mb-0 text-dark text-truncate" style="max-width: 230px;">${popupTitle || 'Équipement'}</h6>
<span class="badge bg-secondary rounded-pill small">${sortedInspections.length} inspections</span>
</div>
<div class="overflow-auto" style="max-height: 240px;">
${rowsHtml}
</div>
</div>
`;
}
new maplibregl.Popup({ closeButton: true, maxWidth: '380px' })
.setLngLat(coords)
.setHTML(contentHtml)
.addTo(mapModalInstance);
});
if (groupedList.length > 0) {
const bounds = new maplibregl.LngLatBounds();
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
}
});
} else {
mapModalInstance.resize();
if (groupedList.length > 0) {
const bounds = new maplibregl.LngLatBounds();
groupedList.forEach(m => bounds.extend([m.lon, m.lat]));
mapModalInstance.fitBounds(bounds, { padding: 50, maxZoom: 17 });
}
}
});
document.addEventListener('click', function(e) {
const btn = e.target.closest('.open-detail-from-modal');
if (btn) {
const inspId = btn.getAttribute('data-insp-id');
const bsMapModal = bootstrap.Modal.getInstance(modalEl);
if (bsMapModal) bsMapModal.hide();
const detailModalEl = document.getElementById('inspectionDetailModal' + inspId);
if (detailModalEl) {
detailModalEl.setAttribute('data-opened-from-map', 'true');
setTimeout(() => {
const bsDetailModal = bootstrap.Modal.getInstance(detailModalEl) || new bootstrap.Modal(detailModalEl);
bsDetailModal.show();
}, 200);
}
}
});
document.addEventListener('show.bs.modal', function(e) {
const modal = e.target;
if (modal) {
modal.querySelectorAll('img[data-src]').forEach(function(img) {
const dataSrc = img.getAttribute('data-src');
if (dataSrc && (!img.src || img.src.indexOf('data:image') !== -1)) {
img.src = dataSrc;
}
});
}
});
document.addEventListener('hidden.bs.modal', function(e) {
const modalTarget = e.target;
if (modalTarget && modalTarget.getAttribute('data-opened-from-map') === 'true') {
modalTarget.removeAttribute('data-opened-from-map');
setTimeout(() => {
const bsMapModal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
bsMapModal.show();
}, 150);
}
});
window.handleToggleCancelInspection = function(inspectionId, action) {
const confirmMsg = action === 'cancel'
? "{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}"
: "{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}";
if (!confirm(confirmMsg)) return;
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 10) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring(10));
break;
}
}
}
const formData = new FormData();
formData.append('action', action);
fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
method: 'POST',
headers: {
'X-CSRFToken': cookieValue
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.error || "{% translate 'Une erreur est survenue.' %}");
}
})
.catch(err => {
console.error(err);
alert("{% translate 'Erreur de communication avec le serveur.' %}");
});
};
});
</script>
{% include "inspections/components/edit_inspection_modal.html" %}
{% endblock %}

View file

@ -397,22 +397,28 @@ class InspectionModelAndViewsTestCase(TestCase):
result_status='compliant' result_status='compliant'
) )
url = reverse('inspections:inspections_list') # Mobile list test
url_mobile = reverse('mobile:inspections_list_mobile')
# Filter water thematic resp_water_m = self.client.get(url_mobile, {'thematic': 'water', 'tab': 'all'})
resp_water = self.client.get(url, {'thematic': 'water', 'tab': 'all'}) self.assertEqual(resp_water_m.status_code, 200)
self.assertEqual(resp_water.status_code, 200) inspections_water = list(resp_water_m.context['inspections'])
inspections_water = list(resp_water.context['inspections'])
self.assertIn(insp_water, inspections_water) self.assertIn(insp_water, inspections_water)
self.assertNotIn(insp_parking, inspections_water) self.assertNotIn(insp_parking, inspections_water)
# Filter parking thematic resp_parking_m = self.client.get(url_mobile, {'thematic': 'parking', 'tab': 'all'})
resp_parking = self.client.get(url, {'thematic': 'parking', 'tab': 'all'}) self.assertEqual(resp_parking_m.status_code, 200)
self.assertEqual(resp_parking.status_code, 200) inspections_parking = list(resp_parking_m.context['inspections'])
inspections_parking = list(resp_parking.context['inspections'])
self.assertIn(insp_parking, inspections_parking) self.assertIn(insp_parking, inspections_parking)
self.assertNotIn(insp_water, inspections_parking) self.assertNotIn(insp_water, inspections_parking)
# Desktop GeoJSON test
url_geojson = reverse('inspections:inspections_geojson_get')
resp_water_g = self.client.get(url_geojson, {'thematic': 'water'})
self.assertEqual(resp_water_g.status_code, 200)
feat_ids_water = [f['properties']['id'] for f in resp_water_g.json()['features']]
self.assertIn(insp_water.id, feat_ids_water)
self.assertNotIn(insp_parking.id, feat_ids_water)
def test_toggle_cancel_inspection_api(self): def test_toggle_cancel_inspection_api(self):
self.client.login(username='inspector_user', password='password123') self.client.login(username='inspector_user', password='password123')
insp = Inspection.objects.create( insp = Inspection.objects.create(
@ -529,6 +535,87 @@ class InspectionModelAndViewsTestCase(TestCase):
self.assertEqual(self.spot.inspection_status, 'unknown') self.assertEqual(self.spot.inspection_status, 'unknown')
self.assertIsNone(self.spot.last_inspection_date) self.assertIsNone(self.spot.last_inspection_date)
def test_desktop_inspections_list_view(self):
self.client.login(username='inspector_user', password='password123')
url = reverse('inspections:inspections_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'inspections/inspections_index.html')
self.assertIn('thematic_choices', response.context)
self.assertIn('result_status_choices', response.context)
self.assertIn('saved_sort', response.context)
def test_mobile_inspections_list_view(self):
self.client.login(username='inspector_user', password='password123')
response = self.client.get(reverse('mobile:inspections_list_mobile'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'inspections/inspections_list_mobile.html')
def test_inspections_geojson_get_endpoint(self):
self.client.login(username='inspector_user', password='password123')
insp1 = Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant',
notes='Test note search query'
)
url = reverse('inspections:inspections_geojson_get')
# Test basic GET
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['type'], 'FeatureCollection')
self.assertTrue(any(f['properties']['id'] == insp1.id for f in data['features']))
# Test thematic filter
resp_water = self.client.get(url, {'thematic': 'water'})
self.assertEqual(resp_water.status_code, 200)
data_water = resp_water.json()
self.assertFalse(any(f['properties']['id'] == insp1.id for f in data_water['features']))
# Test search filter (q)
resp_q = self.client.get(url, {'q': 'search query'})
self.assertEqual(resp_q.status_code, 200)
data_q = resp_q.json()
self.assertTrue(any(f['properties']['id'] == insp1.id for f in data_q['features']))
def test_inspections_export_excel(self):
self.client.login(username='inspector_user', password='password123')
Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant',
notes='Test Excel Export Note'
)
url = reverse('inspections:inspections_export_excel')
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(
resp['Content-Type'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
self.assertTrue(resp.has_header('Content-Disposition'))
self.assertIn('attachment; filename="inspections_export_', resp['Content-Disposition'])
def test_save_inspection_list_preferences(self):
self.client.login(username='inspector_user', password='password123')
url = reverse('common:save_inspection_list_preferences')
import json
payload = {
'filters': {'thematic': ['parking'], 'mine': '1'},
'sort': {'sortName': 'inspection_date', 'sortOrder': 'asc'}
}
resp = self.client.post(url, json.dumps(payload), content_type='application/json')
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()['success'])
self.user.config.refresh_from_db()
self.assertEqual(self.user.config.inspection_list_filters, payload['filters'])
self.assertEqual(self.user.config.inspection_list_sort, payload['sort'])
class ExternalUserInspectionVisibilityTestCase(TestCase): class ExternalUserInspectionVisibilityTestCase(TestCase):
def setUp(self): def setUp(self):
@ -773,14 +860,22 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
) )
self.client.login(username='extern_user', password='password123') self.client.login(username='extern_user', password='password123')
url = reverse('inspections:inspections_list') # Test mobile
response = self.client.get(url, {'tab': 'all'}) url_mobile = reverse('mobile:inspections_list_mobile')
response = self.client.get(url_mobile, {'tab': 'all'})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
inspections = list(response.context['inspections']) inspections = list(response.context['inspections'])
self.assertIn(insp_parking, inspections) self.assertIn(insp_parking, inspections)
self.assertNotIn(insp_water, inspections) self.assertNotIn(insp_water, inspections)
# Test desktop GeoJSON
url_geojson = reverse('inspections:inspections_geojson_get')
resp_geojson = self.client.get(url_geojson)
self.assertEqual(resp_geojson.status_code, 200)
feat_ids = [f['properties']['id'] for f in resp_geojson.json()['features']]
self.assertIn(insp_parking.id, feat_ids)
self.assertNotIn(insp_water.id, feat_ids)
def test_external_user_inspection_detail_api(self): def test_external_user_inspection_detail_api(self):
insp_parking = Inspection.objects.create( insp_parking = Inspection.objects.create(
asset_content_type=self.spot_ct, asset_content_type=self.spot_ct,
@ -928,8 +1023,3 @@ class ExternalUserInspectionVisibilityTestCase(TestCase):
self.assertEqual(detail_res.status_code, 200) self.assertEqual(detail_res.status_code, 200)
data = detail_res.json() data = detail_res.json()
self.assertTrue(data['is_signpanel']) self.assertTrue(data['is_signpanel'])
self.assertEqual(data['verified_cleanliness'], 'missing')

View file

@ -5,6 +5,8 @@ app_name = 'inspections'
urlpatterns = [ urlpatterns = [
path('', views.inspections_list, name='inspections_list'), path('', views.inspections_list, name='inspections_list'),
path('geojson/', views.inspections_geojson, name='inspections_geojson_get'),
path('export/excel/', views.inspections_export_excel, name='inspections_export_excel'),
path('quick-map/', views.quick_inspection_map, name='quick_inspection_map'), path('quick-map/', views.quick_inspection_map, name='quick_inspection_map'),
path('api/create/', views.create_inspection_api, name='create_inspection_api'), path('api/create/', views.create_inspection_api, name='create_inspection_api'),
path('api/<int:inspection_id>/detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'), path('api/<int:inspection_id>/detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'),

View file

@ -1,11 +1,12 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, Http404, HttpResponseForbidden, HttpResponse from django.http import JsonResponse, Http404, HttpResponseForbidden, HttpResponse, HttpResponseRedirect
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST, require_GET
from django.db.models import Q from django.db.models import Q
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _, get_language
import json
from common.models import Thematic from common.models import Thematic
from common.utils import get_short_name from common.utils import get_short_name
@ -36,12 +37,152 @@ def get_user_viewable_thematics(user):
return None return None
def _filter_inspections_queryset(request):
"""
Construit et filtre le QuerySet des inspections selon les paramètres de requête GET.
"""
from .permissions import filter_viewable_inspections_for_user
qs = filter_viewable_inspections_for_user(request.user)
qs = qs.select_related(
'asset_content_type', 'inspector', 'mission_intervention', 'checked_intervention'
).prefetch_related('documents')
thematics = request.GET.getlist('thematic')
if thematics:
thematic_objs = Thematic.objects.filter(code__in=thematics)
categories_for_thematics = AssetCategory.objects.filter(thematic__in=thematic_objs)
cts = set(ContentType.objects.filter(asset_categories__in=categories_for_thematics).distinct())
if 'water' in thematics:
from assets.models.nature import NatureRWIZ, NatureRWIAsset
cts.add(ContentType.objects.get_for_model(NatureRWIZ))
cts.add(ContentType.objects.get_for_model(NatureRWIAsset))
if 'sign' in thematics:
from assets.models import SignPanel, SignPole
cts.add(ContentType.objects.get_for_model(SignPanel))
cts.add(ContentType.objects.get_for_model(SignPole))
if 'parking' in thematics:
from assets.models import ParkingSpot, ParkingLocation
cts.add(ContentType.objects.get_for_model(ParkingSpot))
cts.add(ContentType.objects.get_for_model(ParkingLocation))
qs = qs.filter(asset_content_type__in=cts)
categories = request.GET.getlist('category')
if categories:
child_ids = list(AssetCategory.objects.filter(parent_id__in=categories).values_list('id', flat=True))
expanded_cat_ids = list(set(categories) | set(str(c) for c in child_ids))
category_cts = set(ContentType.objects.filter(asset_categories__id__in=expanded_cat_ids).distinct())
cat_asset_q = Q(asset_content_type__in=category_cts)
for ct in category_cts:
ModelClass = ct.model_class()
if ModelClass and hasattr(ModelClass, 'category'):
match_ids = list(ModelClass.objects.filter(category_id__in=expanded_cat_ids).values_list('pk', flat=True)[:1000])
if match_ids:
cat_asset_q |= Q(asset_content_type=ct, asset_object_id__in=match_ids)
qs = qs.filter(cat_asset_q)
search_query = (request.GET.get('q') or '').strip()
search_mode = (request.GET.get('search_mode') or 'and').lower()
if search_mode not in ('and', 'or'):
search_mode = 'and'
if search_query:
words = search_query.split()
def _word_q(word):
return (
Q(notes__icontains=word) |
Q(inspector__first_name__icontains=word) |
Q(inspector__last_name__icontains=word) |
Q(inspector__username__icontains=word) |
Q(mission_intervention__title__icontains=word) |
Q(checked_intervention__title__icontains=word)
)
asset_q_conditions = Q()
for ct in ContentType.objects.filter(app_label__in=['assets', 'interventions']):
ModelClass = ct.model_class()
if not ModelClass:
continue
model_fields = [f.name for f in ModelClass._meta.get_fields()]
asset_search = Q()
for word in words:
word_asset_search = Q()
if 'code' in model_fields:
word_asset_search |= Q(code__icontains=word)
if 'name' in model_fields:
word_asset_search |= Q(name__icontains=word)
if 'name_fr' in model_fields:
word_asset_search |= Q(name_fr__icontains=word)
if 'address' in model_fields:
word_asset_search |= Q(address__icontains=word)
if word_asset_search:
asset_search |= word_asset_search
if asset_search:
matching_ids = list(ModelClass.objects.filter(asset_search).values_list('pk', flat=True)[:500])
if matching_ids:
asset_q_conditions |= Q(asset_content_type=ct, asset_object_id__in=matching_ids)
combined = Q()
if search_mode == 'and':
for w in words:
combined &= _word_q(w)
else:
for w in words:
combined |= _word_q(w)
if asset_q_conditions:
combined |= asset_q_conditions
qs = qs.filter(combined)
statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
if statuses:
qs = qs.filter(result_status__in=statuses)
natures = request.GET.getlist('nature')
if natures:
qs = qs.filter(nature__in=natures)
show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
if show_mine_only:
qs = qs.filter(inspector=request.user)
date_from = request.GET.get('date_from', '').strip()
date_to = request.GET.get('date_to', '').strip()
if date_from:
qs = qs.filter(inspection_date__date__gte=date_from)
if date_to:
qs = qs.filter(inspection_date__date__lte=date_to)
mission_id = request.GET.get('mission_id')
if mission_id:
qs = qs.filter(mission_intervention_id=mission_id)
checked_interv_id = request.GET.get('checked_interv_id')
if checked_interv_id:
qs = qs.filter(checked_intervention_id=checked_interv_id)
return qs.order_by('-inspection_date')
@login_required @login_required
def inspections_list(request): def inspections_list(request):
""" """
Vue de liste et de gestion des inspections avec filtres par défaut, recherche, thématiques et missions d'intervention. Vue de liste des inspections :
- Desktop : vue partagée Tableau interactif (Bootstrap Table) + Carte (MapLibre GL) avec filtres, recherche et export.
- Mobile : vue en cartes (inspections_list_mobile.html).
""" """
filter_type = request.GET.get('tab', 'my') # 'my' ou 'all' is_mobile = request.path.startswith('/mobile/')
thematics = get_user_viewable_thematics(request.user) or Thematic.objects.none()
insp_thematics = get_user_inspectable_thematics(request.user)
can_inspect = bool(insp_thematics and insp_thematics.exists())
if is_mobile:
filter_type = request.GET.get('tab', 'my')
thematic_code = request.GET.get('thematic', '') thematic_code = request.GET.get('thematic', '')
status_filter = request.GET.get('status', '') status_filter = request.GET.get('status', '')
nature_filter = request.GET.get('nature', '') nature_filter = request.GET.get('nature', '')
@ -67,6 +208,14 @@ def inspections_list(request):
from assets.models.nature import NatureRWIZ, NatureRWIAsset from assets.models.nature import NatureRWIZ, NatureRWIAsset
cts.add(ContentType.objects.get_for_model(NatureRWIZ)) cts.add(ContentType.objects.get_for_model(NatureRWIZ))
cts.add(ContentType.objects.get_for_model(NatureRWIAsset)) cts.add(ContentType.objects.get_for_model(NatureRWIAsset))
if thematic_code == 'sign':
from assets.models import SignPanel, SignPole
cts.add(ContentType.objects.get_for_model(SignPanel))
cts.add(ContentType.objects.get_for_model(SignPole))
if thematic_code == 'parking':
from assets.models import ParkingSpot, ParkingLocation
cts.add(ContentType.objects.get_for_model(ParkingSpot))
cts.add(ContentType.objects.get_for_model(ParkingLocation))
qs = qs.filter(asset_content_type__in=cts) qs = qs.filter(asset_content_type__in=cts)
except Thematic.DoesNotExist: except Thematic.DoesNotExist:
pass pass
@ -118,16 +267,9 @@ def inspections_list(request):
qs = qs.filter(search_filter) qs = qs.filter(search_filter)
thematics = get_user_viewable_thematics(request.user)
insp_thematics = get_user_inspectable_thematics(request.user)
can_inspect = bool(insp_thematics and insp_thematics.exists())
is_mobile = request.path.startswith('/mobile/')
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
context = { context = {
'base_template': base_template, 'base_template': "mobile/mobile_base.html",
'is_mobile': is_mobile, 'is_mobile': True,
'inspections': qs[:100], 'inspections': qs[:100],
'total_count': qs.count(), 'total_count': qs.count(),
'filter_type': filter_type, 'filter_type': filter_type,
@ -141,8 +283,70 @@ def inspections_list(request):
'can_inspect': can_inspect, 'can_inspect': can_inspect,
'result_choices': INSPECTION_RESULT_CHOICES, 'result_choices': INSPECTION_RESULT_CHOICES,
'nature_choices': INSPECTION_NATURE_CHOICES, 'nature_choices': INSPECTION_NATURE_CHOICES,
'INSPECTION_RESULT_CHOICES_CONFIG': Inspection.get_result_choices_config(),
} }
return render(request, 'inspections/inspections_list.html', context) return render(request, 'inspections/inspections_list_mobile.html', context)
# Desktop view
lang = get_language()
order_field = f'name_{lang}' if lang == 'nl' else 'name_fr'
user_config = getattr(request.user, 'config', None)
saved_filters = (user_config.inspection_list_filters or {}) if user_config else {}
saved_sort = (user_config.inspection_list_sort or {}) if user_config else {}
# Redirect on first visit with saved preferences
if not request.GET and saved_filters:
query_params = request.GET.copy()
for key, value in saved_filters.items():
if isinstance(value, list):
query_params.setlist(key, value)
elif value is not None:
query_params[key] = value
return HttpResponseRedirect(f"{request.path}?{query_params.urlencode()}")
categories = AssetCategory.objects.filter(thematic__in=thematics).order_by(order_field)
selected_thematics = request.GET.getlist('thematic')
selected_categories = request.GET.getlist('category')
selected_statuses = request.GET.getlist('status') or request.GET.getlist('result_status')
selected_natures = request.GET.getlist('nature')
search_query = request.GET.get('q', '')
show_mine_only = request.GET.get('mine') == '1' or request.GET.get('tab') == 'my'
date_from = request.GET.get('date_from', '')
date_to = request.GET.get('date_to', '')
category_choices = sorted(
[(c.id, c.get_name(), c.thematic.code if c.thematic else '', c.parent_id) for c in categories],
key=lambda t: (t[3] if t[3] is not None else t[0], 1 if t[3] is not None else 0, t[1])
)
result_choices_config = Inspection.get_result_choices_config()
result_status_choices = [
(cfg['code'], cfg['label'], cfg.get('badge_class', ''), cfg.get('color_hex', ''), cfg.get('icon', ''))
for cfg in result_choices_config
]
context = {
'thematic_choices': [(t.code, t.name, t.icon or 'bi-geo-alt') for t in thematics],
'selected_thematics': selected_thematics,
'category_choices': category_choices,
'selected_categories': selected_categories,
'result_choices': result_choices_config,
'result_status_choices': result_status_choices,
'selected_statuses': selected_statuses,
'selected_result_statuses': selected_statuses,
'nature_choices': INSPECTION_NATURE_CHOICES,
'selected_natures': selected_natures,
'search_query': search_query,
'show_mine_only': show_mine_only,
'date_from': date_from,
'date_to': date_to,
'can_inspect': can_inspect,
'saved_sort': json.dumps(saved_sort, ensure_ascii=False),
'INSPECTION_RESULT_CHOICES_CONFIG': result_choices_config,
}
return render(request, 'inspections/inspections_index.html', context)
def _get_asset_wgs84_lon_lat(obj): def _get_asset_wgs84_lon_lat(obj):
@ -517,17 +721,47 @@ def get_inspection_detail_api(request, inspection_id):
'is_image': doc.is_image, 'is_image': doc.is_image,
}) })
from django.template.loader import render_to_string
from .registry import get_inspection_detail_partial_template
from assets.permissions import _get_asset_thematic
asset = leaf.asset
th = _get_asset_thematic(asset) if asset else None
thematic_name = th.name if th else ''
thematic_icon = th.icon if th and th.icon else 'bi-geo-alt'
detail_template = get_inspection_detail_partial_template(leaf)
rendered_detail_html = ''
if detail_template:
try:
rendered_detail_html = render_to_string(detail_template, {'insp': leaf}, request=request)
except Exception:
rendered_detail_html = ''
data = { data = {
'success': True, 'success': True,
'id': leaf.id, 'id': leaf.id,
'content_type_id': leaf.asset_content_type_id, 'content_type_id': leaf.asset_content_type_id,
'object_id': leaf.asset_object_id, 'object_id': leaf.asset_object_id,
'target_display': leaf.target_display, 'target_display': leaf.target_display,
'asset_url': getattr(asset, 'get_absolute_url', lambda: '')() if asset and hasattr(asset, 'get_absolute_url') else '',
'mobile_asset_url': leaf.mobile_asset_url,
'thematic_name': thematic_name,
'thematic_icon': thematic_icon,
'asset_type_name': leaf.asset_content_type.name if leaf.asset_content_type else '',
'result_status': leaf.result_status, 'result_status': leaf.result_status,
'result_status_display': str(leaf.get_result_status_display()),
'status_badge_class': leaf.status_badge_class,
'status_icon': leaf.status_icon,
'nature': leaf.nature, 'nature': leaf.nature,
'nature_display': str(leaf.get_nature_display()),
'inspector_name': get_short_name(leaf.inspector) if leaf.inspector else '-',
'inspector_role': leaf.inspector_role, 'inspector_role': leaf.inspector_role,
'inspector_role_display': str(leaf.get_inspector_role_display()) if leaf.inspector_role else '',
'notes': leaf.notes or '', 'notes': leaf.notes or '',
'verified_obsolescence': leaf.verified_obsolescence or '', 'verified_obsolescence': leaf.verified_obsolescence or '',
'details_summary': leaf.details_summary,
'details_html': rendered_detail_html,
'apply_changes_to_asset': leaf.apply_changes_to_asset, 'apply_changes_to_asset': leaf.apply_changes_to_asset,
'is_cancelled': leaf.is_cancelled, 'is_cancelled': leaf.is_cancelled,
'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '', 'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '',
@ -730,21 +964,27 @@ def toggle_cancel_inspection_api(request, inspection_id):
@login_required @login_required
@require_POST
def inspections_geojson(request): def inspections_geojson(request):
""" """
Retourne la collection GeoJSON des inspections existantes filtrées. Retourne la collection GeoJSON des inspections existantes filtrées.
Supporte :
- GET (utilisé par la vue desktop tableau/carte)
- POST avec JSON body (utilisé par quick_inspection_map et rétro-compatibilité)
""" """
import json import json
from datetime import timedelta from datetime import timedelta
from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON from django.contrib.gis.db.models.functions import Transform, Centroid, AsGeoJSON
from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import GEOSGeometry
from assets.permissions import _get_asset_thematic
# Si c'est une requête POST avec payload JSON (format quick_inspection_map)
if request.method == 'POST' and (request.content_type == 'application/json' or request.body):
try: try:
data = json.loads(request.body) data = json.loads(request.body)
except Exception: except Exception:
data = {} data = {}
if data.get('period') or data.get('filter_geom') or 'thematic' in data:
thematic_code = data.get('thematic', '') thematic_code = data.get('thematic', '')
category_id = data.get('category') category_id = data.get('category')
if isinstance(category_id, list) and category_id: if isinstance(category_id, list) and category_id:
@ -762,7 +1002,6 @@ def inspections_geojson(request):
if mission_id: if mission_id:
qs = qs.filter(mission_intervention_id=mission_id) qs = qs.filter(mission_intervention_id=mission_id)
# Filtre par période temporelle
now = timezone.now() now = timezone.now()
if period == '1m': if period == '1m':
qs = qs.filter(inspection_date__gte=now - timedelta(days=30)) qs = qs.filter(inspection_date__gte=now - timedelta(days=30))
@ -798,6 +1037,10 @@ def inspections_geojson(request):
from assets.models import SignPanel, SignPole from assets.models import SignPanel, SignPole
ct_ids.add(ContentType.objects.get_for_model(SignPanel).id) ct_ids.add(ContentType.objects.get_for_model(SignPanel).id)
ct_ids.add(ContentType.objects.get_for_model(SignPole).id) ct_ids.add(ContentType.objects.get_for_model(SignPole).id)
elif thematic_code == 'parking':
from assets.models import ParkingSpot, ParkingLocation
ct_ids.add(ContentType.objects.get_for_model(ParkingSpot))
ct_ids.add(ContentType.objects.get_for_model(ParkingLocation))
except Thematic.DoesNotExist: except Thematic.DoesNotExist:
pass pass
@ -805,7 +1048,6 @@ def inspections_geojson(request):
ct_ids = {c for c in qs.values_list('asset_content_type_id', flat=True).distinct() if c} ct_ids = {c for c in qs.values_list('asset_content_type_id', flat=True).distinct() if c}
features = [] features = []
user_config = getattr(request.user, 'config', None) user_config = getattr(request.user, 'config', None)
is_external = (user_config is None) or (not user_config.is_intern) is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts) must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
@ -917,6 +1159,232 @@ def inspections_geojson(request):
return JsonResponse({'type': 'FeatureCollection', 'features': features[:1000]}) return JsonResponse({'type': 'FeatureCollection', 'features': features[:1000]})
# GET / standard query request (desktop index / table / map)
qs = _filter_inspections_queryset(request)[:5000]
inspections_list = list(qs)
ct_to_ids = {}
for insp in inspections_list:
ct_to_ids.setdefault(insp.asset_content_type_id, set()).add(insp.asset_object_id)
assets_by_ct_and_id = {}
for ct_id, obj_ids in ct_to_ids.items():
try:
ct = ContentType.objects.get_for_id(ct_id)
ModelClass = ct.model_class()
if ModelClass:
asset_qs = ModelClass.objects.filter(id__in=obj_ids)
if hasattr(ModelClass, 'geom'):
asset_qs = asset_qs.annotate(
wgs84_geojson=AsGeoJSON(Transform(Centroid('geom'), 4326))
)
if hasattr(ModelClass, 'category'):
asset_qs = asset_qs.select_related('category')
for asset_obj in asset_qs:
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
except Exception:
pass
features = []
for insp in inspections_list:
asset = assets_by_ct_and_id.get((insp.asset_content_type_id, insp.asset_object_id))
geometry = None
if asset:
if hasattr(asset, 'wgs84_geojson') and asset.wgs84_geojson:
try:
geometry = json.loads(asset.wgs84_geojson)
except Exception:
geometry = None
if not geometry:
lon, lat = _get_asset_wgs84_lon_lat(asset)
if lon is not None and lat is not None:
geometry = {"type": "Point", "coordinates": [lon, lat]}
th = _get_asset_thematic(asset) if asset else None
thematic_name = th.name if th else ''
thematic_code = th.code if th else ''
thematic_icon = th.icon if th and th.icon else 'bi-geo-alt'
cat_name = ''
if asset and hasattr(asset, 'category') and asset.category:
cat_name = asset.category.get_name()
elif asset and hasattr(asset, 'category_name'):
cat_name = asset.category_name
target_display = str(asset) if asset else f"Cible #{insp.asset_object_id}"
asset_url = getattr(asset, 'get_absolute_url', lambda: '')() if asset and hasattr(asset, 'get_absolute_url') else ''
features.append({
"type": "Feature",
"id": insp.id,
"geometry": geometry,
"properties": {
"id": insp.id,
"code": f"INSP#{insp.id:05d}",
"target_display": target_display,
"asset_id": insp.asset_object_id,
"content_type_id": insp.asset_content_type_id,
"asset_model": insp.asset_content_type.model if insp.asset_content_type else '',
"asset_model_name": insp.asset_content_type.name if insp.asset_content_type else '',
"asset_url": asset_url,
"thematic": thematic_name,
"thematic_code": thematic_code,
"thematic_icon": thematic_icon,
"category_name": cat_name,
"result_status": insp.result_status,
"result_status_label": str(insp.get_result_status_display()),
"result_status_badge": insp.status_badge_class,
"result_status_color": insp.status_color_hex,
"result_status_icon": insp.status_icon,
"nature": insp.nature,
"nature_label": str(insp.get_nature_display()),
"inspector": get_short_name(insp.inspector) if insp.inspector else '-',
"inspector_username": insp.inspector.username if insp.inspector else '',
"inspection_date": insp.inspection_date.isoformat() if insp.inspection_date else '',
"inspection_date_formatted": insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '',
"notes": insp.notes or '',
"details_summary": insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
"verified_obsolescence": insp.verified_obsolescence or '',
"is_cancelled": insp.is_cancelled,
"documents_count": insp.documents.count(),
"can_edit": insp.detailed_inspection.can_edit(request.user),
}
})
return JsonResponse({
"type": "FeatureCollection",
"features": features
}, safe=False)
@require_GET
@login_required
def inspections_export_excel(request):
"""
Exporte les inspections filtrées au format Excel (.xlsx).
"""
import io
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from assets.permissions import _get_asset_thematic
qs = _filter_inspections_queryset(request)[:5000]
inspections_list = list(qs)
ct_to_ids = {}
for insp in inspections_list:
ct_to_ids.setdefault(insp.asset_content_type_id, set()).add(insp.asset_object_id)
assets_by_ct_and_id = {}
for ct_id, obj_ids in ct_to_ids.items():
try:
ct = ContentType.objects.get_for_id(ct_id)
ModelClass = ct.model_class()
if ModelClass:
asset_qs = ModelClass.objects.filter(id__in=obj_ids)
if hasattr(ModelClass, 'category'):
asset_qs = asset_qs.select_related('category')
for asset_obj in asset_qs:
assets_by_ct_and_id[(ct_id, asset_obj.id)] = asset_obj
except Exception:
pass
wb = Workbook()
ws = wb.active
ws.title = str(_("Inspections"))
headers = [
str(_("ID")),
str(_("Date")),
str(_("Thématique")),
str(_("Type d'asset")),
str(_("Cible")),
str(_("Résultat du contrôle")),
str(_("Nature")),
str(_("Inspecteur")),
str(_("Obsolescence")),
str(_("Constats spécifiques")),
str(_("Remarques")),
str(_("Nb photos / docs")),
str(_("Annulée")),
]
header_font = Font(name="Arial", size=11, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style='thin', color='D3D3D3'),
right=Side(style='thin', color='D3D3D3'),
top=Side(style='thin', color='D3D3D3'),
bottom=Side(style='thin', color='D3D3D3')
)
ws.append(headers)
for col_num in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=col_num)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
ws.row_dimensions[1].height = 28
data_font = Font(name="Arial", size=10)
data_alignment = Alignment(vertical="center")
for row_num, insp in enumerate(inspections_list, start=2):
asset = assets_by_ct_and_id.get((insp.asset_content_type_id, insp.asset_object_id))
th = _get_asset_thematic(asset) if asset else None
target_display = str(asset) if asset else f"Cible #{insp.asset_object_id}"
row_data = [
f"INSP#{insp.id:05d}",
insp.inspection_date.strftime('%d/%m/%Y %H:%M') if insp.inspection_date else '',
th.name if th else '',
insp.asset_content_type.name if insp.asset_content_type else '',
target_display,
str(insp.get_result_status_display()),
str(insp.get_nature_display()),
get_short_name(insp.inspector) if insp.inspector else '-',
insp.verified_obsolescence or '',
insp.details_summary if insp.details_summary and insp.details_summary != '-' else '',
insp.notes or '',
insp.documents.count(),
str(_("Oui")) if insp.is_cancelled else str(_("Non")),
]
ws.append(row_data)
for col_num in range(1, len(headers) + 1):
cell = ws.cell(row=row_num, column=col_num)
cell.font = data_font
cell.alignment = data_alignment
cell.border = thin_border
ws.row_dimensions[row_num].height = 20
for col in ws.columns:
max_len = 0
col_letter = get_column_letter(col[0].column)
for cell in col:
val_str = str(cell.value or '')
if '\n' in val_str:
val_str = max(val_str.split('\n'), key=len)
max_len = max(max_len, len(val_str))
ws.column_dimensions[col_letter].width = max(max_len + 3, 12)
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f"inspections_export_{timezone.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response = HttpResponse(
output.getvalue(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@login_required @login_required
def get_asset_inspection_form_partial(request): def get_asset_inspection_form_partial(request):