diff --git a/streetup/assets/migrations/0012_assetinspection_inspector_role_and_more.py b/streetup/assets/migrations/0012_assetinspection_inspector_role_and_more.py new file mode 100644 index 0000000..49a2d42 --- /dev/null +++ b/streetup/assets/migrations/0012_assetinspection_inspector_role_and_more.py @@ -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'), + ), + ] diff --git a/streetup/assets/models/core.py b/streetup/assets/models/core.py index 67b33b5..ddbe470 100644 --- a/streetup/assets/models/core.py +++ b/streetup/assets/models/core.py @@ -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()}" diff --git a/streetup/assets/models/inspection.py b/streetup/assets/models/inspection.py index 5fbe2d3..58fbdfb 100644 --- a/streetup/assets/models/inspection.py +++ b/streetup/assets/models/inspection.py @@ -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 '' diff --git a/streetup/assets/static/assets/inspection_map.js b/streetup/assets/static/assets/inspection_map.js new file mode 100644 index 0000000..3b9fb1a --- /dev/null +++ b/streetup/assets/static/assets/inspection_map.js @@ -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 || ''; + } +})(); diff --git a/streetup/assets/templates/assets/components/inspection_form_modal.html b/streetup/assets/templates/assets/components/inspection_form_modal.html new file mode 100644 index 0000000..29155d5 --- /dev/null +++ b/streetup/assets/templates/assets/components/inspection_form_modal.html @@ -0,0 +1,154 @@ +{% load i18n %} + + + + + + diff --git a/streetup/assets/templates/assets/inspections_list.html b/streetup/assets/templates/assets/inspections_list.html new file mode 100644 index 0000000..ac88198 --- /dev/null +++ b/streetup/assets/templates/assets/inspections_list.html @@ -0,0 +1,146 @@ +{% extends base_template|default:"base.html" %} +{% load i18n %} + +{% block title %}{% translate "Inspections d'assets" %}{% endblock %} + +{% block content %} +
+
+
+

+ {% translate "Inspections d'assets" %} +

+

{% translate "Suivi et historique des contrôles et vérifications sur le terrain" %}

+
+
+ + {% translate "Nouvelle inspection (Carte GPS)" %} + +
+
+ + + + + +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+ + +
+
+ + {% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %} + +
+
+ {% if inspections %} +
+ + + + + + + + + + + + + {% for insp in inspections %} + + + + + + + + + {% endfor %} + +
{% translate "Date" %}{% translate "Cible" %}{% translate "Inspecteur" %}{% translate "Résultat" %}{% translate "Nature" %}{% translate "Remarques" %}
+ {{ insp.inspection_date|date:"d/m/Y H:i" }} + + {% if insp.asset and insp.asset.get_absolute_url %} + + {{ insp.target_display }} + + {% else %} + {{ insp.target_display }} + {% endif %} +
{{ insp.asset_content_type.name }} +
+ {% if insp.inspector %} + {{ insp.inspector.get_full_name|default:insp.inspector.username }} + {% else %} + - + {% endif %} + + + {{ insp.get_result_status_display }} + + + + {{ insp.get_nature_display }} + + + {{ insp.notes|default:"-" }} +
+
+ {% else %} +
+ + {% translate "Aucune inspection ne correspond à vos critères." %} +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/streetup/assets/templates/assets/quick_inspection_map.html b/streetup/assets/templates/assets/quick_inspection_map.html new file mode 100644 index 0000000..3ef8f1b --- /dev/null +++ b/streetup/assets/templates/assets/quick_inspection_map.html @@ -0,0 +1,266 @@ +{% extends base_template|default:"base.html" %} +{% load i18n static %} + +{% block title %}{% translate "Inspection Rapide Carte GPS" %}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ +
+
+
+
+ + {% translate "Inspection Rapide" %} + {% translate "GPS actif" %} +
+ +
+ + + + + +
+
+
+ + + + + + + +
+ + +
+ + +
+ +
+
+ + + + + + + + +{% include "assets/components/inspection_form_modal.html" %} + +{% endblock %} + +{% block footer %}{% endblock %} + +{% block extra_js %} + + + + +{% endblock %} diff --git a/streetup/assets/tests_inspections_views.py b/streetup/assets/tests_inspections_views.py new file mode 100644 index 0000000..9952553 --- /dev/null +++ b/streetup/assets/tests_inspections_views.py @@ -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') diff --git a/streetup/assets/urls.py b/streetup/assets/urls.py index 779376e..96d6abb 100644 --- a/streetup/assets/urls.py +++ b/streetup/assets/urls.py @@ -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"), diff --git a/streetup/assets/views/inspections.py b/streetup/assets/views/inspections.py new file mode 100644 index 0000000..82fa8ee --- /dev/null +++ b/streetup/assets/views/inspections.py @@ -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) diff --git a/streetup/mobile/templates/mobile/mobile_base.html b/streetup/mobile/templates/mobile/mobile_base.html index 40c9c85..64536ff 100644 --- a/streetup/mobile/templates/mobile/mobile_base.html +++ b/streetup/mobile/templates/mobile/mobile_base.html @@ -15,6 +15,8 @@ + +