feat: implement quick asset inspection modal and associated API endpoints for status reporting and automated intervention creation
This commit is contained in:
parent
6b42824d4e
commit
d725da4c57
13 changed files with 1549 additions and 11 deletions
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
|
|
|
|||
589
streetup/assets/static/assets/inspection_map.js
Normal file
589
streetup/assets/static/assets/inspection_map.js
Normal 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 || '';
|
||||
}
|
||||
})();
|
||||
|
|
@ -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>
|
||||
146
streetup/assets/templates/assets/inspections_list.html
Normal file
146
streetup/assets/templates/assets/inspections_list.html
Normal 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 %}
|
||||
266
streetup/assets/templates/assets/quick_inspection_map.html
Normal file
266
streetup/assets/templates/assets/quick_inspection_map.html
Normal 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 %}
|
||||
93
streetup/assets/tests_inspections_views.py
Normal file
93
streetup/assets/tests_inspections_views.py
Normal 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')
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
199
streetup/assets/views/inspections.py
Normal file
199
streetup/assets/views/inspections.py
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue