feat: implement quick asset inspection modal and associated API endpoints for status reporting and automated intervention creation

This commit is contained in:
kdeterme 2026-08-07 14:51:19 +02:00
parent 6b42824d4e
commit d725da4c57
13 changed files with 1549 additions and 11 deletions

View file

@ -0,0 +1,35 @@
# Generated by Django 6.0.7 on 2026-08-07 10:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0011_artworkfountain_funding_program_and_more'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.AddField(
model_name='assetinspection',
name='inspector_role',
field=models.CharField(choices=[('observer', 'Observer / Agent de terrain'), ('provider', 'Prestataire technique externe'), ('manager', 'Gestionnaire / Contrôleur interne')], default='observer', max_length=50, verbose_name="Rôle de l'inspecteur"),
),
migrations.AddField(
model_name='assetinspection',
name='nature',
field=models.CharField(choices=[('visual', 'Inspection visuelle'), ('technical', 'Inspection technique'), ('post_intervention', 'Suivi / Qualité post-intervention')], default='visual', max_length=50, verbose_name="Nature de l'inspection"),
),
migrations.AlterField(
model_name='assetinspection',
name='asset_content_type',
field=models.ForeignKey(limit_choices_to={'app_label__in': ['assets', 'interventions']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset / Cible"),
),
migrations.AlterField(
model_name='assetinspection',
name='asset_object_id',
field=models.PositiveIntegerField(verbose_name='ID de la cible'),
),
]

View file

@ -182,6 +182,10 @@ class AssetCategory(models.Model):
lang = get_language()
return getattr(self, f"name_{lang}", self.name_fr) # Fallback to name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.get_name()}"

View file

@ -13,21 +13,45 @@ INSPECTION_RESULT_CHOICES = (
('missing', _('Absent / Retiré')),
)
INSPECTION_NATURE_CHOICES = (
('visual', _('Inspection visuelle')),
('technical', _('Inspection technique')),
('post_intervention', _('Suivi / Qualité post-intervention')),
)
INSPECTOR_ROLE_CHOICES = (
('observer', _('Observer / Agent de terrain')),
('provider', _('Prestataire technique externe')),
('manager', _('Gestionnaire / Contrôleur interne')),
)
class AssetInspection(models.Model):
"""
Modèle d'enregistrement générique d'une vérification/inspection sur le terrain d'un asset.
Contient uniquement les données communes à tous les assets.
Modèle d'enregistrement générique d'une vérification/inspection sur le terrain d'une cible (Asset, Localisation, Intervention).
"""
asset_content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name=_("Type d'asset"),
limit_choices_to={'app_label': 'assets'}
verbose_name=_("Type d'asset / Cible"),
limit_choices_to={'app_label__in': ['assets', 'interventions']}
)
asset_object_id = models.PositiveIntegerField(verbose_name=_("ID de l'asset"))
asset_object_id = models.PositiveIntegerField(verbose_name=_("ID de la cible"))
asset = GenericForeignKey('asset_content_type', 'asset_object_id')
nature = models.CharField(
max_length=50,
choices=INSPECTION_NATURE_CHOICES,
default='visual',
verbose_name=_("Nature de l'inspection")
)
inspector_role = models.CharField(
max_length=50,
choices=INSPECTOR_ROLE_CHOICES,
default='observer',
verbose_name=_("Rôle de l'inspecteur")
)
inspector = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@ -103,6 +127,23 @@ class AssetInspection(models.Model):
if modified:
target_asset.save()
@property
def status_badge_class(self):
mapping = {
'compliant': 'bg-success',
'non_compliant': 'bg-warning text-dark',
'damaged': 'bg-danger',
'missing': 'bg-dark',
'updated': 'bg-info text-dark',
}
return mapping.get(self.result_status, 'bg-secondary')
@property
def target_display(self):
if self.asset:
return str(self.asset)
return f"Cible #{self.asset_object_id}"
def __str__(self):
asset_str = str(self.asset) if self.asset else f"Asset #{self.asset_object_id}"
date_str = self.inspection_date.strftime('%d/%m/%Y') if self.inspection_date else ''

View file

@ -0,0 +1,589 @@
/**
* Inspection Map JS - Module de carte d'inspection rapide optimisé MapLibre GL, GPS & Mobile
*/
(function() {
let map = null;
let userMarker = null;
let loadedAssets = [];
const STORAGE_THEMATIC_KEY = 'streetup_insp_thematic';
const STORAGE_CATEGORY_KEY = 'streetup_insp_category';
document.addEventListener('DOMContentLoaded', function() {
initMap();
initFilterStorage();
initFormHandler();
setupGeolocation();
});
function initMap() {
const mapContainer = document.getElementById('inspection-map');
if (!mapContainer || typeof maplibregl === 'undefined') return;
if (mapContainer.dataset.mapInitialized === 'true') return;
mapContainer.dataset.mapInitialized = 'true';
// Centrage et style depuis les settings globaux (window.MAP_STYLE_LIGHT / window.DEFAULT_MAP_CENTER)
const defaultCenter = window.DEFAULT_MAP_CENTER || [4.3517, 50.8503];
const defaultZoom = window.DEFAULT_MAP_ZOOM || 15;
const styleUrl = window.MAP_STYLE_LIGHT || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
map = new maplibregl.Map({
container: 'inspection-map',
style: styleUrl,
center: defaultCenter,
zoom: defaultZoom,
minzoom: 0,
maxzoom: 24
});
map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-right');
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
beforeLayer: 'assets-polygons',
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS',
},
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023',
},
},
],
}],
});
map.addControl(layerSwitcher, 'top-right');
}
if (typeof AddressSearchControl !== 'undefined') {
const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/';
const searchCtrl = new AddressSearchControl({
autocompleteUrl: autocompleteUrl,
placeholder: 'Rechercher une adresse...'
});
map.addControl(searchCtrl, 'top-left');
}
map.on('load', function() {
// Source GeoJSON pour les assets
map.addSource('assets-source', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
// Couche Polygones (ex: ParkingSpot polygon)
map.addLayer({
id: 'assets-polygons',
type: 'fill',
source: 'assets-source',
filter: ['==', '$type', 'Polygon'],
paint: {
'fill-color': '#0d6efd',
'fill-opacity': 0.35,
'fill-outline-color': '#0d6efd'
}
});
// Couche Lignes pour le contour des polygones
map.addLayer({
id: 'assets-lines',
type: 'line',
source: 'assets-source',
filter: ['==', '$type', 'Polygon'],
paint: {
'line-color': '#0d6efd',
'line-width': 2
}
});
// Couche Points (ex: Poteaux, Armoires)
map.addLayer({
id: 'assets-points',
type: 'circle',
source: 'assets-source',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 8,
'circle-color': '#0d6efd',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// Clic sur un asset
map.on('click', ['assets-polygons', 'assets-points'], function(e) {
if (e.features && e.features.length > 0) {
const feature = e.features[0];
handleAssetClick(feature);
}
});
// Survol curseur
map.on('mouseenter', ['assets-polygons', 'assets-points'], function() {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', ['assets-polygons', 'assets-points'], function() {
map.getCanvas().style.cursor = '';
});
// Charger les assets au premier affichage
loadAssets();
setupOverlayResizeObserver();
});
map.on('moveend', onMapMoveEnd);
setTimeout(function() {
if (map) map.resize();
adjustTopControlsMargin();
}, 300);
window.addEventListener('resize', function() {
if (map) map.resize();
adjustTopControlsMargin();
});
}
function adjustTopControlsMargin() {
const overlay = document.querySelector('.map-controls-overlay');
if (!overlay) return;
const overlayHeight = overlay.offsetHeight || 0;
const overlayTop = overlay.offsetTop || 6;
const marginValue = (overlayTop + overlayHeight + 8) + 'px';
const leftCtrl = document.querySelector('#inspection-map .maplibregl-ctrl-top-left');
const rightCtrl = document.querySelector('#inspection-map .maplibregl-ctrl-top-right');
if (leftCtrl) leftCtrl.style.marginTop = marginValue;
if (rightCtrl) rightCtrl.style.marginTop = marginValue;
}
function setupOverlayResizeObserver() {
const overlay = document.querySelector('.map-controls-overlay');
if (!overlay) return;
adjustTopControlsMargin();
if (window.ResizeObserver) {
const observer = new ResizeObserver(function() {
adjustTopControlsMargin();
});
observer.observe(overlay);
} else {
window.addEventListener('resize', adjustTopControlsMargin);
}
}
function getMapBbox() {
if (!map || typeof map.getBounds !== 'function') return null;
const b = map.getBounds();
const sw = b.getSouthWest(), ne = b.getNorthEast();
return {
type: 'Polygon',
coordinates: [[
[sw.lng, sw.lat], [ne.lng, sw.lat],
[ne.lng, ne.lat], [sw.lng, ne.lat],
[sw.lng, sw.lat],
]],
};
}
let moveendTimeout = null;
function onMapMoveEnd() {
if (moveendTimeout) clearTimeout(moveendTimeout);
moveendTimeout = setTimeout(function() {
loadAssets();
}, 400);
}
function setupGeolocation() {
const gpsBtn = document.getElementById('btn-gps-locate');
if (gpsBtn) {
gpsBtn.addEventListener('click', locateUser);
}
locateUser();
}
function locateUser() {
if (!navigator.geolocation) return;
const statusText = document.getElementById('gps-status-text');
if (statusText) statusText.textContent = 'Géolocalisation...';
navigator.geolocation.getCurrentPosition(
function(position) {
const lng = position.coords.longitude;
const lat = position.coords.latitude;
if (typeof maplibregl !== 'undefined' && maplibregl.Marker && map) {
if (userMarker) {
userMarker.setLngLat([lng, lat]);
} else {
const el = document.createElement('div');
el.className = 'user-gps-dot';
userMarker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(map);
}
map.flyTo({ center: [lng, lat], zoom: 17 });
}
if (statusText) statusText.textContent = 'Position actualisée';
},
function(err) {
console.warn('GPS error:', err.message);
if (statusText) statusText.textContent = 'GPS indisponible';
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
);
}
function updateUIElements(thematicCode, categoryId) {
const thematicSelect = document.getElementById('filter-thematic');
if (thematicSelect) {
thematicSelect.value = thematicCode || '';
}
const thematicTiles = document.querySelectorAll('.thematic-tile');
let selectedIcon = 'bi-folder2';
let selectedThematicName = 'Thématique';
thematicTiles.forEach(tile => {
const code = tile.getAttribute('data-code');
const icon = tile.getAttribute('data-icon');
const name = tile.getAttribute('data-name');
const check = tile.querySelector('.thematic-check');
if (code === thematicCode) {
tile.classList.add('active');
if (check) check.classList.remove('d-none');
selectedIcon = icon || selectedIcon;
selectedThematicName = name || selectedThematicName;
} else {
tile.classList.remove('active');
if (check) check.classList.add('d-none');
}
});
const iconDisp = document.getElementById('thematic-icon-display');
const nameDisp = document.getElementById('thematic-name-display');
if (iconDisp) iconDisp.className = `bi ${selectedIcon} text-primary`;
if (nameDisp) nameDisp.textContent = selectedThematicName;
const categorySelect = document.getElementById('filter-category');
if (categorySelect) {
categorySelect.value = categoryId || '';
}
const categoryItems = document.querySelectorAll('.category-item');
let selectedCategoryName = 'Toutes les catégories';
categoryItems.forEach(item => {
const id = item.getAttribute('data-id');
const itemThematic = item.getAttribute('data-thematic');
const name = item.getAttribute('data-name');
const check = item.querySelector('.category-check');
if (!id) {
item.classList.remove('d-none');
} else if (!thematicCode || itemThematic === thematicCode) {
item.classList.remove('d-none');
} else {
item.classList.add('d-none');
}
if ((id || '') === (categoryId || '')) {
item.classList.add('active');
if (check) check.classList.remove('d-none');
selectedCategoryName = name || selectedCategoryName;
} else {
item.classList.remove('active');
if (check) check.classList.add('d-none');
}
});
const catNameDisp = document.getElementById('category-name-display');
if (catNameDisp) catNameDisp.textContent = selectedCategoryName;
}
function initFilterStorage() {
const savedThematic = localStorage.getItem(STORAGE_THEMATIC_KEY) || 'parking';
const savedCategory = localStorage.getItem(STORAGE_CATEGORY_KEY) || '';
updateUIElements(savedThematic, savedCategory);
const thematicTiles = document.querySelectorAll('.thematic-tile');
thematicTiles.forEach(tile => {
tile.addEventListener('click', function() {
const code = this.getAttribute('data-code');
localStorage.setItem(STORAGE_THEMATIC_KEY, code);
localStorage.removeItem(STORAGE_CATEGORY_KEY);
updateUIElements(code, '');
loadAssets();
});
});
const categoryItems = document.querySelectorAll('.category-item');
categoryItems.forEach(item => {
item.addEventListener('click', function() {
const id = this.getAttribute('data-id');
const thematicSelect = document.getElementById('filter-thematic');
const currentThematic = thematicSelect ? thematicSelect.value : '';
localStorage.setItem(STORAGE_CATEGORY_KEY, id || '');
updateUIElements(currentThematic, id || '');
loadAssets();
});
});
const thematicSelect = document.getElementById('filter-thematic');
if (thematicSelect) {
thematicSelect.addEventListener('change', function() {
localStorage.setItem(STORAGE_THEMATIC_KEY, thematicSelect.value);
updateUIElements(thematicSelect.value, '');
localStorage.removeItem(STORAGE_CATEGORY_KEY);
loadAssets();
});
}
const categorySelect = document.getElementById('filter-category');
if (categorySelect) {
categorySelect.addEventListener('change', function() {
const thematic = thematicSelect ? thematicSelect.value : '';
localStorage.setItem(STORAGE_CATEGORY_KEY, categorySelect.value);
updateUIElements(thematic, categorySelect.value);
loadAssets();
});
}
}
const MIN_ZOOM_FOR_GEOJSON = 16;
function loadAssets() {
if (!map) return;
const warningAlert = document.getElementById('map-warning-alert');
const currentZoom = (typeof map.getZoom === 'function') ? map.getZoom() : 0;
if (currentZoom < MIN_ZOOM_FOR_GEOJSON) {
if (warningAlert) warningAlert.classList.remove('d-none');
const source = map.getSource('assets-source');
if (source) {
source.setData({ type: 'FeatureCollection', features: [] });
}
loadedAssets = [];
return;
}
const thematicSelect = document.getElementById('filter-thematic');
const thematic = thematicSelect ? thematicSelect.value : '';
const categorySelect = document.getElementById('filter-category');
const categoryId = categorySelect ? categorySelect.value : '';
let fetchUrl = '/assets/parking/assets/geojson/';
if (thematic === 'parking') {
fetchUrl = '/assets/parking/assets/geojson/';
} else if (thematic === 'trafficlights') {
fetchUrl = '/assets/trafficlights/assets/geojson/';
} else if (thematic === 'publiclighting') {
fetchUrl = '/assets/publiclighting/assets/geojson/';
}
const payload = {};
if (categoryId) {
payload.category = [categoryId];
}
const bbox = getMapBbox();
if (bbox) {
payload.filter_geom = { type: 'Feature', geometry: bbox };
}
fetch(fetchUrl, {
method: 'POST',
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
}
})
.then(res => {
if (res.status === 413) {
if (warningAlert) warningAlert.classList.remove('d-none');
return null;
}
if (!res.ok) {
if (warningAlert) warningAlert.classList.remove('d-none');
return null;
}
return res.json();
})
.then(data => {
if (!data) return;
if (data.error) {
if (warningAlert) warningAlert.classList.remove('d-none');
return;
}
if (warningAlert) warningAlert.classList.add('d-none');
if (data.features) {
loadedAssets = data.features;
const source = map.getSource('assets-source');
if (source) {
source.setData(data);
}
}
})
.catch(err => {
console.error('Error loading asset GeoJSON:', err);
if (warningAlert) warningAlert.classList.remove('d-none');
});
}
function fitAllAssets() {
if (!loadedAssets || loadedAssets.length === 0 || !map) return;
const bounds = new maplibregl.LngLatBounds();
loadedAssets.forEach(f => {
if (f.geometry) {
if (f.geometry.type === 'Point') {
bounds.extend(f.geometry.coordinates);
} else if (f.geometry.type === 'Polygon') {
f.geometry.coordinates[0].forEach(coord => bounds.extend(coord));
} else if (f.geometry.type === 'MultiPolygon') {
f.geometry.coordinates.forEach(poly => poly[0].forEach(coord => bounds.extend(coord)));
}
}
});
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40 });
}
}
function handleAssetClick(feature) {
const props = feature.properties || {};
openInspectionModal(props);
}
window.openInspectionModal = function(props) {
const modalEl = document.getElementById('quickInspectionModal');
if (!modalEl) return;
document.getElementById('insp_content_type_id').value = props.content_type_id || props.ct_id || 1;
document.getElementById('insp_object_id').value = props.id || props.pk;
document.getElementById('insp_target_type').textContent = props.asset_type_display || props.model_name || 'Asset';
document.getElementById('insp_target_code').textContent = props.code || ('#' + (props.id || props.pk));
document.getElementById('insp_target_name').textContent = props.name || props.name_fr || '-';
document.getElementById('insp_target_status').textContent = props.status_display || props.status || '-';
const parkingFields = document.getElementById('parkingSpotSpecificFields');
if (parkingFields) {
if (props.model_name === 'parkingspot' || props.spot_type) {
parkingFields.classList.remove('d-none');
document.getElementById('insp_std_stands').value = props.standard_stand_count || 0;
document.getElementById('insp_cargo_stands').value = props.cargo_stand_count || 0;
} else {
parkingFields.classList.add('d-none');
}
}
const modal = new bootstrap.Modal(modalEl);
modal.show();
};
function initFormHandler() {
const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm');
if (!form) return;
form.addEventListener('submit', function(e) {
e.preventDefault();
const alertDiv = document.getElementById('quickInspectionAlert');
const submitBtn = document.getElementById('submitQuickInspectionBtn');
const spinner = document.getElementById('submitQuickInspectionSpinner');
const icon = document.getElementById('submitQuickInspectionIcon');
alertDiv.classList.add('d-none');
alertDiv.textContent = '';
submitBtn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
if (icon) icon.classList.add('d-none');
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
const modalEl = document.getElementById('quickInspectionModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
let msg = data.message || 'Inspection enregistrée !';
if (data.created_intervention_code) {
msg += ' Intervention ' + data.created_intervention_code + ' créée.';
}
alert(msg);
loadAssets();
} else {
alertDiv.textContent = data.error || 'Erreur lors de la validation.';
alertDiv.classList.remove('d-none');
}
})
.catch(err => {
alertDiv.textContent = 'Erreur réseau.';
alertDiv.classList.remove('d-none');
})
.finally(() => {
submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
});
});
}
function getCsrfToken() {
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
return cookieValue || '';
}
})();

View file

@ -0,0 +1,154 @@
{% load i18n %}
<!-- Modale de Saisie Rapide d'Inspection -->
<div class="modal fade" id="quickInspectionModal" tabindex="-1" aria-labelledby="quickInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="quickInspectionModalLabel">
<i class="bi bi-search text-primary me-2"></i>{% translate "Nouvelle inspection" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="quickInspectionForm" action="{% url 'assets:create_inspection_api' %}" method="post">
{% csrf_token %}
<input type="hidden" name="content_type_id" id="insp_content_type_id">
<input type="hidden" name="object_id" id="insp_object_id">
<input type="hidden" name="nature" value="visual">
<input type="hidden" name="inspector_role" value="observer">
<div class="modal-body">
<!-- Target Card Preview -->
<div class="card mb-3 bg-light border-0">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div>
<span class="badge bg-secondary mb-1" id="insp_target_type">Asset</span>
<h6 class="mb-0 fw-bold" id="insp_target_code">#12345</h6>
<small class="text-muted" id="insp_target_name">-</small>
</div>
<div class="text-end">
<span class="small text-muted d-block">{% translate "Statut actuel" %}</span>
<span class="badge bg-info text-dark" id="insp_target_status">-</span>
</div>
</div>
</div>
<div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div>
<!-- Résultat de contrôle avec boutons colorés -->
<div class="mb-3">
<label class="form-label fw-semibold d-block">{% translate "Résultat du contrôle" %} <span class="text-danger">*</span></label>
<div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}">
<div class="row g-2">
<div class="col-6">
<input type="radio" class="btn-check" name="result_status" id="res_compliant" value="compliant" checked>
<label class="btn btn-outline-success w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_compliant">
<i class="bi bi-check-circle-fill"></i>
<span>{% translate "Conforme" %}</span>
</label>
</div>
<div class="col-6">
<input type="radio" class="btn-check" name="result_status" id="res_non_compliant" value="non_compliant">
<label class="btn btn-outline-warning text-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_non_compliant">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{% translate "Non conforme" %}</span>
</label>
</div>
</div>
<div class="row g-2">
<div class="col-4">
<input type="radio" class="btn-check" name="result_status" id="res_damaged" value="damaged">
<label class="btn btn-outline-danger w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_damaged">
<i class="bi bi-x-circle-fill"></i>
<span>{% translate "Endommagé" %}</span>
</label>
</div>
<div class="col-4">
<input type="radio" class="btn-check" name="result_status" id="res_missing" value="missing">
<label class="btn btn-outline-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_missing">
<i class="bi bi-dash-circle-fill"></i>
<span>{% translate "Absent" %}</span>
</label>
</div>
<div class="col-4">
<input type="radio" class="btn-check" name="result_status" id="res_updated" value="updated">
<label class="btn btn-outline-info text-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_updated">
<i class="bi bi-pencil-square"></i>
<span>{% translate "Corrigé" %}</span>
</label>
</div>
</div>
</div>
</div>
<!-- Champs spécifiques ParkingSpot (masqués par défaut) -->
<div id="parkingSpotSpecificFields" class="d-none border rounded p-2 mb-3 bg-white">
<span class="small fw-bold text-secondary mb-2 d-block"><i class="bi bi-p-square me-1"></i>{% translate "Contrôles spécifiques stationnement" %}</span>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small mb-1">{% translate "Arceaux classiques" %}</label>
<input type="number" name="verified_standard_stand_count" id="insp_std_stands" class="form-control form-control-sm" min="0">
</div>
<div class="col-6">
<label class="form-label small mb-1">{% translate "Arceaux cargo" %}</label>
<input type="number" name="verified_cargo_stand_count" id="insp_cargo_stands" class="form-control form-control-sm" min="0">
</div>
</div>
<div class="mb-1">
<label class="form-label small mb-1">{% translate "Qualité marquage" %}</label>
<select name="verified_marking_quality" id="insp_marking_quality" class="form-select form-select-sm">
<option value="">{% translate "-- Inchangée --" %}</option>
<option value="good">{% translate "Bon état" %}</option>
<option value="medium">{% translate "Moyen" %}</option>
<option value="damaged">{% translate "Dégradé" %}</option>
<option value="missing">{% translate "Absent / Effacé" %}</option>
</select>
</div>
</div>
<!-- Option Création automatique d'une intervention -->
<div class="form-check form-switch mb-3 bg-light p-2 rounded ps-4" id="createInterventionSwitchContainer">
<input class="form-check-input ms-0 me-2" type="checkbox" name="create_intervention" value="true" id="createInterventionSwitch">
<label class="form-check-label small fw-semibold" for="createInterventionSwitch">
{% translate "Créer automatiquement une intervention en cas d'anomalie / dégât" %}
</label>
</div>
<!-- Remarques -->
<div class="mb-2">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="submitQuickInspectionBtn">
<span class="spinner-border spinner-border-sm me-1 d-none" id="submitQuickInspectionSpinner"></span>
<i class="bi bi-check-lg me-1" id="submitQuickInspectionIcon"></i>
{% translate "Valider l'inspection" %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modale de Choix d'Asset (Zone dense / Sélection multiple) -->
<div class="modal fade" id="targetChooserModal" tabindex="-1" aria-labelledby="targetChooserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="targetChooserModalLabel">
<i class="bi bi-geo-fill text-primary me-2"></i>{% translate "Choisir un équipement à inspecter" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-2">
<p class="text-muted small px-2 mb-2">{% translate "Plusieurs équipements ont été détectés à cet emplacement. Veuillez sélectionner celui à inspecter :" %}</p>
<div class="list-group" id="targetChooserList"></div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,146 @@
{% extends base_template|default:"base.html" %}
{% load i18n %}
{% block title %}{% translate "Inspections d'assets" %}{% 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 d'assets" %}
</h3>
<p class="text-muted mb-0 small">{% translate "Suivi et historique des contrôles et vérifications sur le terrain" %}</p>
</div>
<div>
<a href="{% url 'assets:quick_inspection_map' %}" class="btn btn-primary shadow-sm">
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection (Carte GPS)" %}
</a>
</div>
</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-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, inspecteur...' %}">
</div>
</div>
<div class="col-md-3">
<select name="thematic" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Toutes les 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-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-md-3 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>
<!-- Liste des inspections -->
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold text-muted small">
{% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
</span>
</div>
<div class="card-body p-0">
{% if inspections %}
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-3" style="width: 15%;">{% translate "Date" %}</th>
<th style="width: 25%;">{% translate "Cible" %}</th>
<th style="width: 15%;">{% translate "Inspecteur" %}</th>
<th style="width: 15%;">{% translate "Résultat" %}</th>
<th style="width: 15%;">{% translate "Nature" %}</th>
<th class="pe-3" style="width: 15%;">{% translate "Remarques" %}</th>
</tr>
</thead>
<tbody>
{% for insp in inspections %}
<tr>
<td class="ps-3">
<span class="fw-bold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
</td>
<td>
{% if insp.asset and insp.asset.get_absolute_url %}
<a href="{{ insp.asset.get_absolute_url }}" class="fw-semibold text-decoration-none">
{{ insp.target_display }}
</a>
{% else %}
<span class="fw-semibold">{{ insp.target_display }}</span>
{% endif %}
<br><small class="text-muted">{{ insp.asset_content_type.name }}</small>
</td>
<td>
{% if insp.inspector %}
<small><i class="bi bi-person me-1"></i>{{ insp.inspector.get_full_name|default:insp.inspector.username }}</small>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {{ insp.status_badge_class }}">
{{ insp.get_result_status_display }}
</span>
</td>
<td>
<span class="badge bg-light text-dark border">
{{ insp.get_nature_display }}
</span>
</td>
<td class="pe-3 text-truncate" style="max-width: 200px;" title="{{ insp.notes }}">
<small class="text-muted">{{ insp.notes|default:"-" }}</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2 text-secondary"></i>
{% translate "Aucune inspection ne correspond à vos critères." %}
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,266 @@
{% extends base_template|default:"base.html" %}
{% load i18n static %}
{% block title %}{% translate "Inspection Rapide Carte GPS" %}{% endblock %}
{% block extra_head %}
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style>
body {
padding-top: 54px !important;
padding-bottom: 0 !important;
}
main.container, main {
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
width: 100% !important;
}
footer {
display: none !important;
}
#inspection-map-wrapper {
position: relative;
height: calc(100vh - 54px);
min-height: 500px;
width: 100%;
overflow: hidden;
}
#inspection-map {
min-height: 500px;
height: 100%;
width: 100%;
z-index: 1;
}
.map-controls-overlay {
position: absolute;
top: 6px;
left: 8px;
right: 8px;
z-index: 1000;
pointer-events: none;
}
.map-controls-overlay > * {
pointer-events: auto;
}
/* Transition fluide pour le repositionnement dynamique des contrôles MapLibre */
#inspection-map .maplibregl-ctrl-top-left,
#inspection-map .maplibregl-ctrl-top-right {
transition: margin-top 0.15s ease-out;
}
.map-floating-actions {
position: absolute;
bottom: 24px;
right: 16px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
}
.user-gps-dot {
width: 16px;
height: 16px;
background-color: #0d6efd;
border: 2px solid #ffffff;
border-radius: 50%;
box-shadow: 0 0 10px rgba(13, 110, 253, 0.8);
}
/* Bottom Sheet Modal Styling for Mobile */
.modal-bottom-sheet .modal-dialog {
margin: 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-width: 100%;
}
.modal-bottom-sheet .modal-content {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 1.25rem;
border-top-right-radius: 1.25rem;
border: none;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-bottom-sheet .modal-body {
max-height: 70vh;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.thematic-tile {
border: 2px solid #e9ecef;
border-radius: 0.85rem;
padding: 0.75rem 1rem;
transition: all 0.2s ease;
cursor: pointer;
background: #ffffff;
}
.thematic-tile.active {
border-color: #0d6efd;
background-color: rgba(13, 110, 253, 0.06);
}
.thematic-tile-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.filter-btn-pill {
background-color: #ffffff;
border: 1px solid #dee2e6;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
border-radius: 50rem;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
transition: all 0.15s ease;
}
.filter-btn-pill:active {
transform: scale(0.97);
}
</style>
{% endblock %}
{% block content %}
<div id="inspection-map-wrapper">
<!-- Overlay des filtres mémorisés (Boutons tactiles mobiles) -->
<div class="map-controls-overlay">
<div class="card shadow-sm border-0 bg-white opacity-95 rounded-4">
<div class="card-body p-2 d-flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="d-flex align-items-center gap-1.5">
<i class="bi bi-geo-alt-fill text-primary fs-6"></i>
<span class="fw-bold small me-1">{% translate "Inspection Rapide" %}</span>
<span class="badge bg-light text-muted border small" id="gps-status-text">{% translate "GPS actif" %}</span>
</div>
<div class="d-flex align-items-center gap-2">
<!-- Bouton Déclencheur Thématique -->
<button id="btn-select-thematic" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#thematicChooserModal">
<i id="thematic-icon-display" class="bi bi-collection text-primary"></i>
<span id="thematic-name-display" class="fw-semibold">{% translate "Thématique" %}</span>
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
</button>
<!-- Bouton Déclencheur Catégorie -->
<button id="btn-select-category" type="button" class="btn filter-btn-pill d-flex align-items-center gap-1.5" data-bs-toggle="modal" data-bs-target="#categoryChooserModal">
<i class="bi bi-tag-fill text-secondary"></i>
<span id="category-name-display" class="fw-semibold">{% translate "Catégorie" %}</span>
<i class="bi bi-chevron-down text-muted ms-1 small"></i>
</button>
</div>
</div>
</div>
<!-- Sélecteurs cachés synchronisés avec le JS d'inspection -->
<select id="filter-thematic" class="d-none">
<option value="">{% translate "-- Thématique --" %}</option>
{% for t in thematics %}
<option value="{{ t.code }}" data-icon="{{ t.icon|default:'bi-folder2' }}" data-name="{{ t.name }}" {% if t.code == 'parking' %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
<select id="filter-category" class="d-none">
<option value="">{% translate "-- Catégorie --" %}</option>
{% for c in categories %}
<option value="{{ c.id }}" data-thematic="{{ c.thematic.code }}" data-name="{{ c.get_name }}">{{ c.get_name }}</option>
{% endfor %}
</select>
<div id="map-warning-alert" class="alert alert-warning alert-dismissible fade show d-none shadow-sm py-2 px-3 mt-2 mb-0 small" role="alert">
<i class="bi bi-exclamation-triangle-fill me-1 text-warning"></i>
<span id="map-warning-text">{% translate "Veuillez zoomer sur la carte (zoom 16+) pour afficher les équipements." %}</span>
<button type="button" class="btn-close py-2" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<!-- Carte MapLibre -->
<div id="inspection-map"></div>
<!-- Actions Flottantes (Bouton GPS) -->
<div class="map-floating-actions">
<button id="btn-gps-locate" class="btn btn-white btn-lg shadow rounded-circle border" title="{% translate 'Ma position GPS' %}">
<i class="bi bi-crosshair text-primary fs-5"></i>
</button>
</div>
</div>
<!-- Modal Choix de Thématique (Style Mobile Bottom Sheet) -->
<div class="modal fade modal-bottom-sheet" id="thematicChooserModal" tabindex="-1" aria-labelledby="thematicChooserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content shadow-lg">
<div class="modal-header border-0 pb-1">
<h6 class="modal-title fw-bold" id="thematicChooserModalLabel">
<i class="bi bi-layers-fill text-primary me-1"></i>{% translate "Choisir une thématique" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body p-3">
<div class="row g-2" id="thematic-tiles-container">
{% for t in thematics %}
<div class="col-12 col-sm-6">
<div class="thematic-tile d-flex align-items-center gap-3 {% if t.code == 'parking' %}active{% endif %}" data-code="{{ t.code }}" data-icon="{{ t.icon|default:'bi-folder2' }}" data-name="{{ t.name }}" data-bs-dismiss="modal">
<div class="thematic-tile-icon" style="background-color: {{ t.color|default:'#0d6efd' }}15; color: {{ t.color|default:'#0d6efd' }};">
<i class="bi {{ t.icon|default:'bi-folder2' }}"></i>
</div>
<div class="flex-grow-1">
<h6 class="mb-0 fw-bold small text-dark">{{ t.name }}</h6>
<small class="text-muted" style="font-size: 0.75rem;">{{ t.description|default:""|truncatechars:45 }}</small>
</div>
<i class="bi bi-check-circle-fill text-primary fs-5 thematic-check d-none"></i>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- Modal Choix de Catégorie (Style Mobile Bottom Sheet) -->
<div class="modal fade modal-bottom-sheet" id="categoryChooserModal" tabindex="-1" aria-labelledby="categoryChooserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content shadow-lg">
<div class="modal-header border-0 pb-1">
<h6 class="modal-title fw-bold" id="categoryChooserModalLabel">
<i class="bi bi-tags-fill text-primary me-1"></i>{% translate "Filtrer par catégorie" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
<div class="modal-body p-3">
<div class="list-group list-group-flush" id="category-list-container">
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 category-item active" data-id="" data-name="{% translate 'Toutes les catégories' %}" data-bs-dismiss="modal">
<span class="fw-semibold small">{% translate "Toutes les catégories" %}</span>
<i class="bi bi-check-lg text-primary category-check"></i>
</button>
{% for c in categories %}
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-2.5 px-3 rounded-3 mb-1 border-0 category-item" data-id="{{ c.id }}" data-thematic="{{ c.thematic.code }}" data-name="{{ c.get_name }}" data-bs-dismiss="modal">
<span class="fw-semibold small text-dark">{{ c.get_name }}</span>
<i class="bi bi-check-lg text-primary category-check d-none"></i>
</button>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- Inclure la Modale d'inspection -->
{% include "assets/components/inspection_form_modal.html" %}
{% endblock %}
{% block footer %}{% endblock %}
{% block extra_js %}
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'common/address_search_control.js' %}"></script>
<script>window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";</script>
<script src="{% static 'assets/inspection_map.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,93 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from assets.models.parking import ParkingSpot
from assets.models.inspection import AssetInspection, ParkingSpotInspection
from interventions.models import Intervention
User = get_user_model()
class InspectionViewsTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='test_inspector',
password='password123',
first_name='Jean',
last_name='Dupont'
)
self.client = Client()
self.client.login(username='test_inspector', password='password123')
self.spot = ParkingSpot.objects.create(
code='PK-001',
name_fr='Emplacement Vélo Test',
spot_type='bicycle',
standard_stand_count=5
)
self.spot_ct = ContentType.objects.get_for_model(ParkingSpot)
def test_inspections_list_view(self):
insp = AssetInspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant',
nature='visual'
)
response = self.client.get(reverse('assets:inspections_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'PK-001')
self.assertContains(response, 'Mes inspections')
def test_quick_inspection_map_view(self):
response = self.client.get(reverse('assets:quick_inspection_map'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Inspection Rapide')
def test_quick_inspection_map_mobile_view(self):
response = self.client.get(reverse('mobile:quick_inspection_map_mobile'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Inspection Rapide')
def test_create_inspection_api_compliant(self):
response = self.client.post(reverse('assets:create_inspection_api'), {
'content_type_id': self.spot_ct.id,
'object_id': self.spot.id,
'result_status': 'compliant',
'nature': 'visual',
'inspector_role': 'observer',
'notes': 'Tout est parfait',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.spot.refresh_from_db()
self.assertEqual(self.spot.inspection_status, 'compliant')
self.assertTrue(self.spot.is_compliant)
def test_create_inspection_api_damaged_creates_intervention(self):
response = self.client.post(reverse('assets:create_inspection_api'), {
'content_type_id': self.spot_ct.id,
'object_id': self.spot.id,
'result_status': 'damaged',
'nature': 'visual',
'notes': 'Arceau tordu',
'create_intervention': 'true',
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
self.assertIsNotNone(data['created_intervention_code'])
self.spot.refresh_from_db()
self.assertEqual(self.spot.inspection_status, 'damaged')
self.assertFalse(self.spot.is_compliant)
interv = Intervention.objects.filter(code=data['created_intervention_code']).first()
self.assertIsNotNone(interv)
self.assertEqual(interv.status, 'in_preparation')

View file

@ -1,10 +1,13 @@
from django.urls import path
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking, inspections
app_name = "assets"
urlpatterns = [
path("", core.unified_assets_index, name="index"),
path("inspections/", inspections.inspections_list, name="inspections_list"),
path("inspections/new/", inspections.quick_inspection_map, name="quick_inspection_map"),
path("inspections/create/", inspections.create_inspection_api, name="create_inspection_api"),
path("parking/", parking.parking_index, name="parking_index"),
path("parking/assets/", parking.parking_assets, name="parking_assets"),
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),

View file

@ -0,0 +1,199 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse, Http404, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.translation import gettext as _
from common.models import Thematic
from assets.models import AssetCategory
from assets.models.inspection import (
AssetInspection, ParkingSpotInspection,
INSPECTION_RESULT_CHOICES, INSPECTION_NATURE_CHOICES, INSPECTOR_ROLE_CHOICES
)
@login_required
def inspections_list(request):
"""
Vue de liste et de gestion des inspections avec filtre par défaut 'Mes inspections'.
"""
filter_type = request.GET.get('tab', 'my') # 'my' ou 'all'
thematic_code = request.GET.get('thematic', '')
status_filter = request.GET.get('status', '')
nature_filter = request.GET.get('nature', '')
search_q = request.GET.get('q', '').strip()
qs = AssetInspection.objects.all().select_related('asset_content_type', 'inspector').order_by('-inspection_date')
if filter_type == 'my':
qs = qs.filter(inspector=request.user)
if thematic_code:
try:
thematic = Thematic.objects.get(code=thematic_code)
categories = AssetCategory.objects.filter(thematic=thematic)
cts = ContentType.objects.filter(assetcategory__in=categories).distinct()
qs = qs.filter(asset_content_type__in=cts)
except Thematic.DoesNotExist:
pass
if status_filter:
qs = qs.filter(result_status=status_filter)
if nature_filter:
qs = qs.filter(nature=nature_filter)
if search_q:
qs = qs.filter(
Q(notes__icontains=search_q) |
Q(inspector__first_name__icontains=search_q) |
Q(inspector__last_name__icontains=search_q) |
Q(inspector__username__icontains=search_q)
)
thematics = Thematic.objects.all().order_by('name_fr')
is_mobile = request.path.startswith('/mobile/')
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
context = {
'base_template': base_template,
'is_mobile': is_mobile,
'inspections': qs[:100],
'total_count': qs.count(),
'filter_type': filter_type,
'selected_thematic': thematic_code,
'selected_status': status_filter,
'selected_nature': nature_filter,
'search_q': search_q,
'thematics': thematics,
'result_choices': INSPECTION_RESULT_CHOICES,
'nature_choices': INSPECTION_NATURE_CHOICES,
}
return render(request, 'assets/inspections_list.html', context)
@login_required
def quick_inspection_map(request):
"""
Vue cartographique d'inspection rapide optimisée mobile & GPS.
"""
thematics = Thematic.objects.all().order_by('name_fr')
categories = AssetCategory.objects.select_related('thematic').all().order_by('name_fr')
is_mobile = request.path.startswith('/mobile/')
base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
context = {
'base_template': base_template,
'is_mobile': is_mobile,
'thematics': thematics,
'categories': categories,
}
return render(request, 'assets/quick_inspection_map.html', context)
@login_required
@require_POST
def create_inspection_api(request):
"""
API endpoint pour l'enregistrement d'une nouvelle inspection.
"""
content_type_id = request.POST.get('content_type_id')
object_id = request.POST.get('object_id')
result_status = request.POST.get('result_status', 'compliant')
nature = request.POST.get('nature', 'visual')
inspector_role = request.POST.get('inspector_role', 'observer')
notes = request.POST.get('notes', '').strip()
verified_obsolescence = request.POST.get('verified_obsolescence', '').strip() or None
apply_changes = request.POST.get('apply_changes_to_asset', 'true') == 'true'
if not content_type_id or not object_id:
return JsonResponse({'success': False, 'error': _("Cible d'inspection non spécifiée.")}, status=400)
try:
content_type = ContentType.objects.get(pk=content_type_id)
target_obj = content_type.get_object_for_this_type(pk=object_id)
except Exception as e:
return JsonResponse({'success': False, 'error': _("Cible introuvable : %s") % str(e)}, status=404)
is_parking_spot = (content_type.model == 'parkingspot')
try:
if is_parking_spot:
inspection = ParkingSpotInspection(
asset_content_type=content_type,
asset_object_id=target_obj.pk,
inspector=request.user,
inspection_date=timezone.now(),
result_status=result_status,
nature=nature,
inspector_role=inspector_role,
verified_obsolescence=verified_obsolescence,
notes=notes,
apply_changes_to_asset=apply_changes,
)
if 'verified_standard_stand_count' in request.POST and request.POST['verified_standard_stand_count'] != '':
inspection.verified_standard_stand_count = int(request.POST['verified_standard_stand_count'])
if 'verified_cargo_stand_count' in request.POST and request.POST['verified_cargo_stand_count'] != '':
inspection.verified_cargo_stand_count = int(request.POST['verified_cargo_stand_count'])
if 'verified_stand_type' in request.POST and request.POST['verified_stand_type'] != '':
inspection.verified_stand_type = request.POST['verified_stand_type']
if 'verified_has_scooter_drop_zone' in request.POST:
inspection.verified_has_scooter_drop_zone = (request.POST['verified_has_scooter_drop_zone'] == 'true')
if 'verified_marking_quality' in request.POST and request.POST['verified_marking_quality'] != '':
inspection.verified_marking_quality = request.POST['verified_marking_quality']
inspection.save()
else:
inspection = AssetInspection.objects.create(
asset_content_type=content_type,
asset_object_id=target_obj.pk,
inspector=request.user,
inspection_date=timezone.now(),
result_status=result_status,
nature=nature,
inspector_role=inspector_role,
verified_obsolescence=verified_obsolescence,
notes=notes,
apply_changes_to_asset=apply_changes,
)
created_intervention_code = None
if request.POST.get('create_intervention') == 'true' and result_status in ('non_compliant', 'damaged', 'missing'):
thematic = None
if hasattr(target_obj, 'category') and target_obj.category:
thematic = target_obj.category.thematic
elif hasattr(target_obj, 'thematic'):
thematic = target_obj.thematic
from interventions.models import Intervention, InterventionAsset
intervention = Intervention.objects.create(
title=_("Intervention suite à inspection non conforme (%s)") % target_obj,
thematic=thematic,
maintain_type='corrective',
priority='2' if result_status in ('damaged', 'missing') else '3',
init_description=notes or _("Signalement automatique lors de l'inspection du %s.") % timezone.now().strftime('%d/%m/%Y'),
status='in_preparation',
created_by=request.user,
geom=getattr(target_obj, 'geom', None),
)
InterventionAsset.objects.create(
intervention=intervention,
content_type=content_type,
object_id=target_obj.pk
)
created_intervention_code = intervention.code
return JsonResponse({
'success': True,
'inspection_id': inspection.id,
'created_intervention_code': created_intervention_code,
'message': _("Inspection enregistrée avec succès.")
})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)}, status=500)

View file

@ -15,6 +15,8 @@
<link href="{% static 'common/bootstrap.min.css' %}" rel="stylesheet">
<!-- Bootstrap Icons 1.11.3 (local) -->
<link href="{% static 'common/bootstrap-icons.min.css' %}" rel="stylesheet">
<!-- MapLibre GL (local) -->
<link href="{% static 'common/maplibre-gl.css' %}" rel="stylesheet">
<style>
:root {
--brand-color: {{ organization_primary_color|default:'#2f3e8b' }};
@ -374,8 +376,13 @@ if ('serviceWorker' in navigator) {
</script>
<!-- MapLibre GL 5.7.0 (local) -->
<script src="{% static 'common/maplibre-gl.js' %}"></script>
{% block extra_scripts %}
{% endblock extra_scripts %}
{% block extra_js %}
{% endblock extra_js %}
</body>
</html>

View file

@ -20,12 +20,10 @@
</a>
{% endif %}
{% if is_inspector %}
<button class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between" type="button" data-bs-toggle="modal" data-bs-target="#newInspectionModal">
<a href="{% url 'mobile:quick_inspection_map_mobile' %}" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">
<span><i class="bi bi-search me-2"></i>{% translate "Nouvelle inspection" %}</span>
<i class="bi bi-plus-lg"></i>
</button>
{% endif %}
<i class="bi bi-geo-alt-fill"></i>
</a>
{% if can_add_structures_repair %}
<a href="{% url 'mobile:add_repair_intervention_mobile' %}" class="btn btn-outline-primary py-2.5 rounded-3 fw-semibold text-start d-flex align-items-center justify-content-between">

View file

@ -6,6 +6,7 @@ from controls.views import controls_index, controls_detail, change_remark, chang
from projects.views import projects_index, projects_detail
from observations.views import observations_index_mobile, observation_detail, observation_add
from assets.views.core import mobile_asset_detail
from assets.views import inspections
from notifications.views import inbox_mobile
app_name = "mobile"
@ -36,6 +37,8 @@ urlpatterns = [
path("nature/green-surfaces-custom-draw/", views.green_surfaces_edit_custom_draw, name='green_surfaces_edit_custom_draw'),
path("nature/green-surfaces-standalone/", views.green_surfaces_edit_standalone, name='green_surfaces_edit_standalone'),
path('assets/<str:thematic_code>/<str:asset_model>/<int:asset_id>/', mobile_asset_detail, name='mobile_asset_detail'),
path('inspections/', inspections.inspections_list, name='inspections_list_mobile'),
path('inspections/new/', inspections.quick_inspection_map, name='quick_inspection_map_mobile'),
# Stock mobile
path("stock/", views.stock_index_mobile, name='stock_index_mobile'),
path("stock/products/", views.stock_products_mobile, name='stock_products_mobile'),