feat: add continuous photo capture functionality for asset inspections and update mobile dashboard navigation

This commit is contained in:
kdeterme 2026-08-07 17:53:00 +02:00
parent 41619256a9
commit adaf19ab39
16 changed files with 737 additions and 103 deletions

View file

@ -0,0 +1,33 @@
# Generated by Django 6.0.7 on 2026-08-07 15:40
import assets.models.inspection
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0012_assetinspection_inspector_role_and_more'),
]
operations = [
migrations.AlterField(
model_name='parkingspotinspection',
name='verified_marking_quality',
field=models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('damaged', 'Dégradé'), ('missing', 'Absent / Effacé')], max_length=50, null=True, verbose_name='Qualité du marquage constatée'),
),
migrations.CreateModel(
name='InspectionDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to=assets.models.inspection.inspection_document_upload_path, verbose_name='Fichier / Photo / Document')),
('created_at', models.DateTimeField(auto_now_add=True)),
('inspection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.assetinspection', verbose_name='Inspection')),
],
options={
'verbose_name': "Document / Photo d'inspection",
'verbose_name_plural': "Documents / Photos d'inspection",
},
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 6.0.7 on 2026-08-07 15:46
import assets.models.inspection
import common.storages
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0013_alter_parkingspotinspection_verified_marking_quality_and_more'),
]
operations = [
migrations.AlterField(
model_name='inspectiondocument',
name='file',
field=models.FileField(storage=common.storages.PrivateMediaStorage(), upload_to=assets.models.inspection.inspection_document_upload_path, verbose_name='Fichier / Photo / Document'),
),
]

View file

@ -4,6 +4,9 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
INSPECTION_RESULT_CHOICES = ( INSPECTION_RESULT_CHOICES = (
('compliant', _('Conforme')), ('compliant', _('Conforme')),
@ -144,6 +147,82 @@ class AssetInspection(models.Model):
return str(self.asset) return str(self.asset)
return f"Cible #{self.asset_object_id}" return f"Cible #{self.asset_object_id}"
@property
def thematic(self):
from assets.permissions import _get_asset_thematic
if self.asset:
return _get_asset_thematic(self.asset)
return None
@property
def thematic_icon(self):
th = self.thematic
if th and hasattr(th, 'icon') and th.icon:
return th.icon
return 'bi-geo-alt'
@property
def detailed_inspection(self):
"""Downcast à la sous-classe spécialisée si disponible (ex: ParkingSpotInspection)."""
if hasattr(self, 'parkingspotinspection'):
return self.parkingspotinspection
return self
@property
def details_summary(self):
"""Résumé textuel concis des constatations spécifiques de l'inspection."""
leaf = self.detailed_inspection
parts = []
if hasattr(leaf, 'verified_standard_stand_count') and leaf.verified_standard_stand_count is not None:
parts.append(str(_("%(count)d arceaux classiques") % {'count': leaf.verified_standard_stand_count}))
if hasattr(leaf, 'verified_cargo_stand_count') and leaf.verified_cargo_stand_count is not None:
parts.append(str(_("%(count)d arceaux cargo") % {'count': leaf.verified_cargo_stand_count}))
if hasattr(leaf, 'verified_has_scooter_drop_zone') and leaf.verified_has_scooter_drop_zone is not None:
parts.append(str(_("Drop-zone trottinette") if leaf.verified_has_scooter_drop_zone else _("Sans drop-zone")))
if hasattr(leaf, 'verified_marking_quality') and leaf.verified_marking_quality:
marking_labels = {
'good': _("Marquage bon état"),
'medium': _("Marquage moyen"),
'damaged': _("Marquage dégradé"),
'missing': _("Marquage absent / effacé"),
'bad': _("Marquage mauvais"),
}
val = marking_labels.get(leaf.verified_marking_quality, leaf.verified_marking_quality)
parts.append(str(val))
if leaf.verified_obsolescence:
parts.append(str(_("Obsolescence : %s") % leaf.verified_obsolescence))
return ", ".join(parts) if parts else "-"
def get_asset_url(self, is_mobile=False):
"""Retourne l'URL de détail de l'asset cible (route mobile /mobile/... si is_mobile est True)."""
if not self.asset:
return None
if is_mobile:
from django.urls import reverse
from assets.permissions import _get_asset_thematic
th = _get_asset_thematic(self.asset)
thematic_code = th.code if th else 'parking'
asset_model = self.asset.__class__.__name__.lower()
try:
return reverse('mobile:mobile_asset_detail', kwargs={
'thematic_code': thematic_code,
'asset_model': asset_model,
'asset_id': self.asset.id
})
except Exception:
pass
if hasattr(self.asset, 'get_absolute_url'):
return self.asset.get_absolute_url()
return None
@property
def mobile_asset_url(self):
"""Propriété raccourcie pour l'URL mobile de l'asset."""
return self.get_asset_url(is_mobile=True)
def __str__(self): def __str__(self):
asset_str = str(self.asset) if self.asset else f"Asset #{self.asset_object_id}" 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 '' date_str = self.inspection_date.strftime('%d/%m/%Y') if self.inspection_date else ''
@ -175,8 +254,15 @@ class ParkingSpotInspection(AssetInspection):
blank=True, blank=True,
verbose_name=_("Drop zone trottinette constatée") verbose_name=_("Drop zone trottinette constatée")
) )
MARKING_QUALITY_CHOICES = (
('good', _("Bon état")),
('medium', _("Moyen")),
('damaged', _("Dégradé")),
('missing', _("Absent / Effacé")),
)
verified_marking_quality = models.CharField( verified_marking_quality = models.CharField(
max_length=50, max_length=50,
choices=MARKING_QUALITY_CHOICES,
null=True, null=True,
blank=True, blank=True,
verbose_name=_("Qualité du marquage constatée") verbose_name=_("Qualité du marquage constatée")
@ -189,25 +275,46 @@ class ParkingSpotInspection(AssetInspection):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.apply_changes_to_asset and self.asset and self.asset.__class__.__name__ == 'ParkingSpot':
target_asset = self.asset
modified = False
if self.verified_standard_stand_count is not None: def inspection_document_upload_path(instance, filename):
target_asset.standard_stand_count = self.verified_standard_stand_count now = timezone.now()
modified = True year = now.strftime("%Y")
if self.verified_cargo_stand_count is not None: month = now.strftime("%m")
target_asset.cargo_stand_count = self.verified_cargo_stand_count return f"inspections/documents/{year}/{month}/{filename}"
modified = True
if self.verified_stand_type is not None:
target_asset.stand_type = self.verified_stand_type
modified = True
if self.verified_has_scooter_drop_zone is not None:
target_asset.has_scooter_drop_zone = self.verified_has_scooter_drop_zone
modified = True
if self.verified_marking_quality is not None:
target_asset.marking_quality = self.verified_marking_quality
modified = True
if modified:
target_asset.save() class InspectionDocument(models.Model):
"""
Document / Photo / Pièce jointe associée à une inspection d'asset.
"""
inspection = models.ForeignKey(
AssetInspection,
related_name='documents',
on_delete=models.CASCADE,
verbose_name=_("Inspection")
)
file = models.FileField(
upload_to=inspection_document_upload_path,
storage=private_storage,
verbose_name=_("Fichier / Photo / Document")
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = _("Document / Photo d'inspection")
verbose_name_plural = _("Documents / Photos d'inspection")
@property
def is_image(self):
if not self.file or not self.file.name:
return False
ext = self.file.name.split('.')[-1].lower()
return ext in ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'bmp']
@property
def filename(self):
import os
return os.path.basename(self.file.name) if self.file else ""
def __str__(self):
return f"Document {self.filename} (Inspection #{self.inspection_id})"

View file

@ -268,6 +268,10 @@ def _get_asset_thematic(asset):
if hasattr(asset, 'category') and asset.category: if hasattr(asset, 'category') and asset.category:
return asset.category.thematic return asset.category.thematic
# Try direct thematic attribute
if hasattr(asset, 'thematic') and asset.thematic:
return asset.thematic
# Try from parent building category # Try from parent building category
if hasattr(asset, 'building') and asset.building and hasattr(asset.building, 'category') and asset.building.category: if hasattr(asset, 'building') and asset.building and hasattr(asset.building, 'category') and asset.building.category:
return asset.building.category.thematic return asset.building.category.thematic
@ -294,6 +298,9 @@ def _get_asset_thematic(asset):
'its': 'its', 'its': 'its',
'building': 'publicbuildings', 'building': 'publicbuildings',
'publicbuilding': 'publicbuildings', 'publicbuilding': 'publicbuildings',
'parking': 'parking',
'parkingspot': 'parking',
'parkinglocation': 'parking',
} }
for prefix, thematic_code in thematic_mappings.items(): for prefix, thematic_code in thematic_mappings.items():

View file

@ -500,7 +500,11 @@
const modalEl = document.getElementById('quickInspectionModal'); const modalEl = document.getElementById('quickInspectionModal');
if (!modalEl) return; if (!modalEl) return;
document.getElementById('insp_content_type_id').value = props.content_type_id || props.ct_id || 1; let ctId = props.content_type_id || props.ct_id;
if (!ctId && (props.model_name === 'parkingspot' || props.spot_type || props.asset_type === 'parkingspot' || props.thematic === 'parking')) {
ctId = window.PARKING_SPOT_CT_ID;
}
document.getElementById('insp_content_type_id').value = ctId || '';
document.getElementById('insp_object_id').value = props.id || props.pk; 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_type').textContent = props.asset_type_display || props.model_name || 'Asset';
@ -512,18 +516,223 @@
if (parkingFields) { if (parkingFields) {
if (props.model_name === 'parkingspot' || props.spot_type) { if (props.model_name === 'parkingspot' || props.spot_type) {
parkingFields.classList.remove('d-none'); parkingFields.classList.remove('d-none');
document.getElementById('insp_std_stands').value = props.standard_stand_count || 0; document.getElementById('insp_std_stands').value = '';
document.getElementById('insp_cargo_stands').value = props.cargo_stand_count || 0; document.getElementById('insp_cargo_stands').value = '';
const markingSel = document.getElementById('insp_marking_quality');
if (markingSel) markingSel.value = '';
} else { } else {
parkingFields.classList.add('d-none'); parkingFields.classList.add('d-none');
} }
} }
// Réinitialisation de l'accumulateur de photos/fichiers
accumulatedInspectionFiles = [];
const previewContainer = document.getElementById('insp_photos_preview_container');
if (previewContainer) previewContainer.innerHTML = '';
const modal = new bootstrap.Modal(modalEl); const modal = new bootstrap.Modal(modalEl);
modal.show(); modal.show();
}; };
let accumulatedInspectionFiles = [];
let inspCameraStream = null;
function initInspectionPhotoHandler() {
const btnCamera = document.getElementById('insp_btn_continuous_camera');
const btnGallery = document.getElementById('insp_btn_gallery_files');
const inputCamera = document.getElementById('insp_camera_input');
const inputGallery = document.getElementById('insp_gallery_input');
const cameraModalEl = document.getElementById('inspCameraModal');
const cameraPreview = document.getElementById('inspCameraPreview');
const cameraCanvas = document.getElementById('inspCameraCanvas');
const cameraFlashOverlay = document.getElementById('inspCameraFlashOverlay');
const cameraStatus = document.getElementById('inspCameraStatus');
const cameraCaptureBtn = document.getElementById('inspCameraCaptureBtn');
const cameraStopBtn = document.getElementById('inspCameraStopBtn');
let cameraModal = null;
if (cameraModalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) {
cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl);
}
if (!btnCamera || !btnGallery || !inputCamera || !inputGallery) return;
function updateCameraStatus(msg, isError) {
if (!cameraStatus) return;
cameraStatus.textContent = msg || '';
cameraStatus.className = isError ? 'small text-center text-danger my-2 flex-shrink-0' : 'small text-center text-info my-2 flex-shrink-0';
}
function triggerFlash() {
if (!cameraFlashOverlay) return;
cameraFlashOverlay.style.opacity = '1';
setTimeout(() => { cameraFlashOverlay.style.opacity = '0'; }, 150);
}
async function startCameraStream() {
if (!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)) {
return false;
}
try {
updateCameraStatus('Initialisation de la caméra...', false);
inspCameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false
});
if ('srcObject' in cameraPreview) {
cameraPreview.srcObject = inspCameraStream;
} else {
cameraPreview.src = URL.createObjectURL(inspCameraStream);
}
cameraPreview.play();
updateCameraStatus('Caméra prête. Appuyez sur Capturer pour enregistrer une photo.', false);
return true;
} catch (err) {
console.warn('Erreur caméra canvas:', err);
stopCameraStream();
return false;
}
}
function stopCameraStream() {
if (inspCameraStream) {
inspCameraStream.getTracks().forEach(t => t.stop());
inspCameraStream = null;
}
if (cameraPreview) cameraPreview.srcObject = null;
updateCameraStatus('', false);
}
function captureFrame() {
if (!cameraPreview || !cameraCanvas) return;
const w = cameraPreview.videoWidth;
const h = cameraPreview.videoHeight;
if (!w || !h) {
updateCameraStatus('La caméra n\'est pas encore prête.', true);
return;
}
cameraCanvas.width = w;
cameraCanvas.height = h;
const ctx = cameraCanvas.getContext('2d');
ctx.drawImage(cameraPreview, 0, 0, w, h);
triggerFlash();
cameraCanvas.toBlob(function(blob) {
if (!blob) return;
const filename = `inspection_photo_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
accumulatedInspectionFiles.push(file);
renderPhotoPreviews();
updateCameraStatus('Photo capturée avec succès !', false);
}, 'image/jpeg', 0.9);
}
btnCamera.addEventListener('click', async function() {
if (cameraModal) {
const ok = await startCameraStream();
if (ok) {
cameraModal.show();
return;
}
}
// Fallback si le flux caméra (getUserMedia) n'est pas supporté
inputCamera.value = '';
inputCamera.click();
});
if (cameraCaptureBtn) {
cameraCaptureBtn.addEventListener('click', captureFrame);
}
if (cameraStopBtn) {
cameraStopBtn.addEventListener('click', function() {
if (cameraModal) cameraModal.hide();
});
}
if (cameraModalEl) {
cameraModalEl.addEventListener('hidden.bs.modal', function() {
stopCameraStream();
});
}
btnGallery.addEventListener('click', function() {
inputGallery.value = '';
inputGallery.click();
});
function handleFileSelection(files) {
Array.from(files).forEach(file => {
accumulatedInspectionFiles.push(file);
});
renderPhotoPreviews();
}
inputCamera.addEventListener('change', function(e) {
if (e.target.files && e.target.files.length > 0) {
handleFileSelection(e.target.files);
}
});
inputGallery.addEventListener('change', function(e) {
if (e.target.files && e.target.files.length > 0) {
handleFileSelection(e.target.files);
}
});
function renderPhotoPreviews() {
const previewContainer = document.getElementById('insp_photos_preview_container');
if (!previewContainer) return;
previewContainer.innerHTML = '';
accumulatedInspectionFiles.forEach((file, index) => {
const col = document.createElement('div');
col.className = 'col-4 col-sm-3 position-relative mt-2';
const card = document.createElement('div');
card.className = 'border rounded overflow-hidden shadow-sm bg-white text-center p-1 position-relative';
card.style.height = '75px';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
card.appendChild(img);
} else {
const icon = document.createElement('div');
icon.className = 'd-flex flex-column align-items-center justify-content-center h-100 text-muted';
icon.innerHTML = '<i class="bi bi-file-earmark fs-4 text-primary"></i><span class="extra-small text-truncate w-100 px-1">' + file.name + '</span>';
card.appendChild(icon);
}
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-sm rounded-circle position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
removeBtn.style.width = '20px';
removeBtn.style.height = '20px';
removeBtn.style.fontSize = '12px';
removeBtn.style.transform = 'translate(30%, -30%)';
removeBtn.innerHTML = '&times;';
removeBtn.onclick = function(ev) {
ev.stopPropagation();
accumulatedInspectionFiles.splice(index, 1);
renderPhotoPreviews();
};
card.appendChild(removeBtn);
col.appendChild(card);
previewContainer.appendChild(col);
});
}
}
function initFormHandler() { function initFormHandler() {
initInspectionPhotoHandler();
const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm'); const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm');
if (!form) return; if (!form) return;
@ -542,6 +751,11 @@
const formData = new FormData(form); const formData = new FormData(form);
// Ajout des fichiers accumulés en mode continu
accumulatedInspectionFiles.forEach(file => {
formData.append('photos', file);
});
fetch(form.action, { fetch(form.action, {
method: 'POST', method: 'POST',
body: formData, body: formData,

View file

@ -11,7 +11,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div> </div>
<form id="quickInspectionForm" action="{% url 'assets:create_inspection_api' %}" method="post"> <form id="quickInspectionForm" action="{% url 'assets:create_inspection_api' %}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="content_type_id" id="insp_content_type_id"> <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="object_id" id="insp_object_id">
@ -98,7 +98,7 @@
<div class="mb-1"> <div class="mb-1">
<label class="form-label small mb-1">{% translate "Qualité marquage" %}</label> <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"> <select name="verified_marking_quality" id="insp_marking_quality" class="form-select form-select-sm">
<option value="">{% translate "-- Inchangée --" %}</option> <option value="">{% translate "-- Sélectionner --" %}</option>
<option value="good">{% translate "Bon état" %}</option> <option value="good">{% translate "Bon état" %}</option>
<option value="medium">{% translate "Moyen" %}</option> <option value="medium">{% translate "Moyen" %}</option>
<option value="damaged">{% translate "Dégradé" %}</option> <option value="damaged">{% translate "Dégradé" %}</option>
@ -116,10 +116,36 @@
</div> </div>
<!-- Remarques --> <!-- Remarques -->
<div class="mb-2"> <div class="mb-3">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label> <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> <textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div> </div>
<!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div>
<div class="card-body p-3">
<div class="d-grid gap-2">
<button type="button" id="insp_btn_continuous_camera" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-camera me-1"></i>
<span>{% translate "Prendre des photos (mode continu)" %}</span>
</button>
<button type="button" id="insp_btn_gallery_files" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-folder2-open me-1"></i>
<span>{% translate "Photos, vidéos, fichiers" %}</span>
</button>
</div>
<!-- Inputs masqués -->
<input type="file" id="insp_camera_input" accept="image/*" capture="environment" class="d-none">
<input type="file" id="insp_gallery_input" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" multiple class="d-none">
<!-- Grille de prévisualisation des photos/fichiers accumulés -->
<div id="insp_photos_preview_container" class="row g-2 mt-2"></div>
</div>
</div>
</div> </div>
<div class="modal-footer bg-light"> <div class="modal-footer bg-light">
@ -135,6 +161,38 @@
</div> </div>
</div> </div>
<!-- Modal caméra Canvas (mode continu pour inspection) -->
<div class="modal fade" id="inspCameraModal" tabindex="-1" aria-labelledby="inspCameraModalLabel" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-fullscreen-sm-down d-flex m-0 h-100 mx-auto">
<div class="modal-content bg-dark text-white d-flex flex-column">
<div class="modal-header border-0 flex-shrink-0 py-2">
<h5 class="modal-title fw-bold" id="inspCameraModalLabel">
<i class="bi bi-camera-video me-1"></i>{% translate "Caméra (Mode continu)" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}" id="inspCameraModalCloseBtn"></button>
</div>
<div class="modal-body d-flex flex-column p-2 flex-grow-1" style="overflow: hidden; min-height: 0;">
<div class="flex-grow-1 d-flex align-items-center justify-content-center" style="min-height: 0; overflow: hidden;">
<div class="position-relative w-100 h-100 d-flex align-items-center justify-content-center" style="border-radius: 0.5rem; overflow: hidden;">
<video id="inspCameraPreview" playsinline autoplay style="max-width: 100%; max-height: 100%; object-fit: contain;"></video>
<div id="inspCameraFlashOverlay" style="position: absolute; inset: 0; background: rgba(255, 255, 255, 0.9); opacity: 0; pointer-events: none; transition: opacity 120ms ease-out; z-index: 2;"></div>
<canvas id="inspCameraCanvas" class="d-none"></canvas>
</div>
</div>
<div id="inspCameraStatus" class="small text-center text-muted my-2 flex-shrink-0"></div>
<div class="d-flex justify-content-between align-items-center gap-2 mb-2 flex-shrink-0">
<button type="button" class="btn btn-outline-light flex-fill py-2" id="inspCameraStopBtn">
<i class="bi bi-x-circle me-1"></i>{% translate "Terminer" %}
</button>
<button type="button" class="btn btn-primary flex-fill py-2 fw-semibold" id="inspCameraCaptureBtn">
<i class="bi bi-camera-fill me-1"></i>{% translate "Capturer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modale de Choix d'Asset (Zone dense / Sélection multiple) --> <!-- 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 fade" id="targetChooserModal" tabindex="-1" aria-labelledby="targetChooserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">

View file

@ -73,6 +73,10 @@
<a href="{{ back_url }}" class="nav-link d-flex align-items-center gap-1" style="color: white;" title="{% translate 'Retour' %}"> <a href="{{ back_url }}" class="nav-link d-flex align-items-center gap-1" style="color: white;" title="{% translate 'Retour' %}">
<i class="bi bi-arrow-left fs-4"></i> <i class="bi bi-arrow-left fs-4"></i>
</a> </a>
{% elif mobile_view or is_mobile %}
<a href="{% url 'mobile:mobile_index' %}" class="nav-link" style="color: white;">
<i class="bi bi-house fs-3" title="Home"></i>
</a>
{% else %} {% else %}
<a href="/" class="nav-link" style="color: white;"> <a href="/" class="nav-link" style="color: white;">
<i class="bi bi-house fs-3" title="Home"></i> <i class="bi bi-house fs-3" title="Home"></i>

View file

@ -14,7 +14,7 @@
</div> </div>
{% if can_inspect %} {% if can_inspect %}
<div> <div>
<a href="{% url 'assets:quick_inspection_map' %}" class="btn btn-primary shadow-sm"> <a href="{% if is_mobile %}{% url 'mobile:quick_inspection_map_mobile' %}{% else %}{% url 'assets:quick_inspection_map' %}{% endif %}" class="btn btn-primary shadow-sm">
<i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection (Carte GPS)" %} <i class="bi bi-geo-alt-fill me-1"></i>{% translate "Nouvelle inspection (Carte GPS)" %}
</a> </a>
</div> </div>
@ -41,23 +41,32 @@
<form method="get" class="row g-2 align-items-center"> <form method="get" class="row g-2 align-items-center">
<input type="hidden" name="tab" value="{{ filter_type }}"> <input type="hidden" name="tab" value="{{ filter_type }}">
<div class="col-md-3"> <div class="col-12 col-md-3">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span> <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...' %}"> <input type="text" name="q" value="{{ search_q }}" class="form-control" placeholder="{% translate 'Rechercher par note, inspecteur...' %}">
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-12 col-sm-6 col-md-2">
<select name="thematic" class="form-select form-select-sm" onchange="this.form.submit()"> <select name="thematic" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Toutes les thématiques --" %}</option> <option value="">{% translate "-- Thématiques --" %}</option>
{% for t in thematics %} {% for t in thematics %}
<option value="{{ t.code }}" {% if selected_thematic == t.code %}selected{% endif %}>{{ t.name }}</option> <option value="{{ t.code }}" {% if selected_thematic == t.code %}selected{% endif %}>{{ t.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-12 col-sm-6 col-md-2">
<select name="nature" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Natures --" %}</option>
{% for val, label in nature_choices %}
<option value="{{ val }}" {% if selected_nature == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-sm-6 col-md-3">
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()"> <select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">{% translate "-- Tous les résultats --" %}</option> <option value="">{% translate "-- Tous les résultats --" %}</option>
{% for val, label in result_choices %} {% for val, label in result_choices %}
@ -66,7 +75,7 @@
</select> </select>
</div> </div>
<div class="col-md-3 d-flex gap-2"> <div class="col-12 col-sm-6 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-sm btn-secondary flex-grow-1">{% translate "Filtrer" %}</button> <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> <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> </div>
@ -74,75 +83,190 @@
</div> </div>
</div> </div>
<!-- Liste des inspections --> <!-- Liste des inspections sous forme de Cards -->
<div class="card shadow-sm border-0"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold text-muted small"> <span class="fw-semibold text-muted small">
{% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %} {% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
<span class="fst-italic text-secondary">({% translate "limité aux 100 plus récentes" %})</span>
</span> </span>
</div> </div>
<div class="card-body p-0">
{% if inspections %} {% if inspections %}
<div class="table-responsive"> <div class="row g-3 mb-4">
<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 %} {% for insp in inspections %}
<tr> <div class="col-12">
<td class="ps-3"> <div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer"
<span class="fw-bold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span> style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;"
</td> data-bs-toggle="modal"
<td> data-bs-target="#inspectionDetailModal{{ insp.id }}">
{% if insp.asset and insp.asset.get_absolute_url %} <div class="card-body p-3 d-flex flex-column justify-content-between">
<a href="{{ insp.asset.get_absolute_url }}" class="fw-semibold text-decoration-none"> <div>
{{ insp.target_display }} <!-- Entête card : Thématique/Type + Badge résultat -->
</a> <div class="d-flex justify-content-between align-items-center mb-2">
{% else %} <span class="badge bg-light text-dark border d-inline-flex align-items-center me-2">
<span class="fw-semibold">{{ insp.target_display }}</span> <i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>
{% endif %} {{ insp.asset_content_type.name }}
<br><small class="text-muted">{{ insp.asset_content_type.name }}</small> </span>
</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 }}"> <span class="badge {{ insp.status_badge_class }}">
{{ insp.get_result_status_display }} {{ insp.get_result_status_display }}
</span> </span>
</td> </div>
<td>
<span class="badge bg-light text-dark border"> <!-- Nom de la cible tronqué à 100 caractères -->
{{ insp.get_nature_display }} <h6 class="fw-bold text-dark mb-1 text-break">
{{ insp.target_display|truncatechars:100 }}
</h6>
<!-- Résumé des constats si présent -->
{% if insp.details_summary and insp.details_summary != "-" %}
<p class="text-muted small mb-2 text-truncate mt-1">
<i class="bi bi-ui-checks me-1 text-secondary"></i>{{ insp.details_summary }}
</p>
{% endif %}
</div>
<!-- Ligne de pied card : Date & Inspecteur -->
<div class="d-flex justify-content-between align-items-center pt-2 border-top mt-2 text-muted extra-small">
<span><i class="bi bi-clock me-1"></i>{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
<div class="d-flex align-items-center gap-2">
{% if insp.documents.all %}
<span class="badge bg-light text-secondary border" title="{% translate 'Photos / Documents joints' %}">
<i class="bi bi-paperclip me-1"></i>{{ insp.documents.count }}
</span> </span>
</td> {% endif %}
<td class="pe-3 text-truncate" style="max-width: 200px;" title="{{ insp.notes }}"> {% if insp.inspector %}
<small class="text-muted">{{ insp.notes|default:"-" }}</small> <span><i class="bi bi-person me-1"></i>{{ insp.inspector.get_full_name|default:insp.inspector.username }}</span>
</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 %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Modal de détail de l'inspection -->
<div class="modal fade" id="inspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="inspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="inspectionDetailModalLabel{{ insp.id }}">
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>{% translate "Détail de l'inspection" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
<!-- Lien vers la fiche de l'asset -->
{% if insp.asset %}
<div class="mb-3">
<a href="{% if is_mobile %}{{ insp.mobile_asset_url }}{% else %}{{ insp.asset.get_absolute_url }}{% endif %}" class="btn btn-primary w-100 fw-semibold">
<i class="bi bi-box-arrow-up-right me-2"></i>{% translate "Consulter la fiche de l'asset" %}
</a>
</div>
{% endif %}
<ul class="list-group list-group-flush mb-3">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Cible" %}</span>
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-layers me-1"></i>{% translate "Type d'asset" %}</span>
<span class="badge bg-secondary">{{ insp.asset_content_type.name }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge {{ insp.status_badge_class }}">{{ insp.get_result_status_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
<span class="fw-semibold">{{ insp.inspector.get_full_name|default:insp.inspector.username|default:"-" }}</span>
</li>
{% if insp.inspector_role %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-person-badge me-1"></i>{% translate "Rôle inspecteur" %}</span>
<span class="fw-semibold">{{ insp.get_inspector_role_display }}</span>
</li>
{% endif %}
{% with leaf=insp.detailed_inspection %}
{% if leaf.verified_standard_stand_count is not None %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-bicycle me-1 text-primary"></i>{% translate "Arceaux classiques constatés" %}</span>
<span class="badge bg-primary rounded-pill fs-6">{{ leaf.verified_standard_stand_count }}</span>
</li>
{% endif %}
{% if leaf.verified_cargo_stand_count is not None %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-box-seam me-1 text-info"></i>{% translate "Arceaux cargo constatés" %}</span>
<span class="badge bg-info text-dark rounded-pill fs-6">{{ leaf.verified_cargo_stand_count }}</span>
</li>
{% endif %}
{% if leaf.verified_marking_quality %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-paint-bucket me-1"></i>{% translate "Qualité du marquage" %}</span>
<span class="fw-semibold">{{ leaf.get_verified_marking_quality_display|default:leaf.verified_marking_quality }}</span>
</li>
{% endif %}
{% endwith %}
</ul>
{% if insp.notes %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2"><i class="bi bi-journal-text me-2"></i>{% translate "Remarques" %}</h6>
<div class="text-dark small">{{ insp.notes }}</div>
</div>
{% endif %}
{% if insp.verified_obsolescence %}
<div class="card bg-warning-subtle border-0 p-3 mb-3">
<h6 class="fw-bold text-warning-emphasis mb-1"><i class="bi bi-exclamation-triangle me-2"></i>{% translate "Obsolescence" %}</h6>
<div class="text-warning-emphasis small">{{ insp.verified_obsolescence }}</div>
</div>
{% endif %}
{% if insp.documents.all %}
<div class="card bg-light border-0 p-3 mb-3">
<h6 class="fw-bold text-secondary mb-2">
<i class="bi bi-paperclip me-2"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }})
</h6>
<div class="d-flex flex-wrap gap-2">
{% for doc in insp.documents.all %}
{% if doc.is_image %}
<a href="{{ doc.file.url }}" target="_blank" class="d-inline-block border rounded overflow-hidden shadow-sm position-relative" style="width: 80px; height: 80px;">
<img src="{{ doc.file.url }}" alt="{{ doc.filename }}" style="width: 100%; height: 100%; object-fit: cover;">
</a>
{% else %}
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1">
<i class="bi bi-file-earmark-arrow-down text-primary"></i>
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="modal-footer bg-light py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card shadow-sm border-0 p-5 text-center text-muted">
<i class="bi bi-inbox fs-1 d-block mb-3 text-secondary"></i>
<h5>{% translate "Aucune inspection trouvée" %}</h5>
<p class="small text-muted mb-0">{% translate "Aucune inspection ne correspond à vos critères de recherche." %}</p>
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}

View file

@ -261,6 +261,9 @@
{% block extra_js %} {% block extra_js %}
<script src="{% static 'common/layer-switcher-control.js' %}"></script> <script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'common/address_search_control.js' %}"></script> <script src="{% static 'common/address_search_control.js' %}"></script>
<script>window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";</script> <script>
window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";
window.PARKING_SPOT_CT_ID = {{ parking_spot_ct_id|default:"null" }};
</script>
<script src="{% static 'assets/inspection_map.js' %}"></script> <script src="{% static 'assets/inspection_map.js' %}"></script>
{% endblock %} {% endblock %}

View file

@ -121,3 +121,25 @@ class InspectionViewsTestCase(TestCase):
# 2. Tentative d'accès direct à quick_inspection_map -> HTTP 403 Forbidden # 2. Tentative d'accès direct à quick_inspection_map -> HTTP 403 Forbidden
resp_map = self.client.get(reverse('assets:quick_inspection_map')) resp_map = self.client.get(reverse('assets:quick_inspection_map'))
self.assertEqual(resp_map.status_code, 403) self.assertEqual(resp_map.status_code, 403)
def test_create_inspection_api_with_photo(self):
from django.core.files.uploadedfile import SimpleUploadedFile
test_photo = SimpleUploadedFile("test_inspection.jpg", b"fake_image_bytes", content_type="image/jpeg")
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',
'notes': 'Inspection avec photo',
'photos': [test_photo],
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
inspection = AssetInspection.objects.get(id=data['inspection_id'])
self.assertEqual(inspection.documents.count(), 1)
doc = inspection.documents.first()
self.assertTrue(doc.is_image)
self.assertTrue(doc.filename.startswith('test_inspection'))

View file

@ -184,7 +184,12 @@ def get_asset_edit_context(request, asset, content_type):
except Exception: except Exception:
pass pass
is_mobile = request.path.startswith('/mobile/') or request.GET.get('v') == 'mobile' or request.GET.get('mobile') == '1'
base_template = 'mobile/mobile_base.html' if is_mobile else 'base.html'
return { return {
'base_template': base_template,
'is_mobile': is_mobile,
'allowed_update_fields': allowed_update_fields, 'allowed_update_fields': allowed_update_fields,
'allowed_actions': allowed_actions, 'allowed_actions': allowed_actions,
'status_choices': status_choices, 'status_choices': status_choices,

View file

@ -35,7 +35,7 @@ def inspections_list(request):
nature_filter = request.GET.get('nature', '') nature_filter = request.GET.get('nature', '')
search_q = request.GET.get('q', '').strip() search_q = request.GET.get('q', '').strip()
qs = AssetInspection.objects.all().select_related('asset_content_type', 'inspector').order_by('-inspection_date') qs = AssetInspection.objects.all().select_related('asset_content_type', 'inspector').prefetch_related('documents').order_by('-inspection_date')
if filter_type == 'my': if filter_type == 'my':
qs = qs.filter(inspector=request.user) qs = qs.filter(inspector=request.user)
@ -101,11 +101,15 @@ def quick_inspection_map(request):
is_mobile = request.path.startswith('/mobile/') is_mobile = request.path.startswith('/mobile/')
base_template = "mobile/mobile_base.html" if is_mobile else "base.html" base_template = "mobile/mobile_base.html" if is_mobile else "base.html"
from assets.models import ParkingSpot
parking_spot_ct_id = ContentType.objects.get_for_model(ParkingSpot).id
context = { context = {
'base_template': base_template, 'base_template': base_template,
'is_mobile': is_mobile, 'is_mobile': is_mobile,
'thematics': thematics, 'thematics': thematics,
'categories': categories, 'categories': categories,
'parking_spot_ct_id': parking_spot_ct_id,
} }
return render(request, 'assets/quick_inspection_map.html', context) return render(request, 'assets/quick_inspection_map.html', context)
@ -173,17 +177,28 @@ def create_inspection_api(request):
nature=nature, nature=nature,
inspector_role=inspector_role, inspector_role=inspector_role,
verified_obsolescence=verified_obsolescence, verified_obsolescence=verified_obsolescence,
notes=notes,
apply_changes_to_asset=apply_changes, apply_changes_to_asset=apply_changes,
) )
# Enregistrement des photos et documents joints
uploaded_files = request.FILES.getlist('photos') + request.FILES.getlist('documents') + request.FILES.getlist('files')
if uploaded_files:
from assets.models.inspection import InspectionDocument
for f in uploaded_files:
InspectionDocument.objects.create(
inspection=inspection,
file=f
)
created_intervention_code = None created_intervention_code = None
if request.POST.get('create_intervention') == 'true' and result_status in ('non_compliant', 'damaged', 'missing'): if request.POST.get('create_intervention') == 'true' and result_status in ('non_compliant', 'damaged', 'missing'):
thematic = None from assets.permissions import _get_asset_thematic
if hasattr(target_obj, 'category') and target_obj.category: thematic = _get_asset_thematic(target_obj)
thematic = target_obj.category.thematic if not thematic:
elif hasattr(target_obj, 'thematic'): if is_parking_spot:
thematic = target_obj.thematic thematic = Thematic.objects.filter(code='parking').first()
else:
thematic = Thematic.objects.first()
from interventions.models import Intervention, InterventionAsset from interventions.models import Intervention, InterventionAsset
intervention = Intervention.objects.create( intervention = Intervention.objects.create(

View file

@ -154,9 +154,13 @@ def parking_assets_geojson(request):
except Exception: except Exception:
pass pass
spot_ct_id = ContentType.objects.get_for_model(ParkingSpot).id
properties = { properties = {
"id": spot.id, "id": spot.id,
"code": spot.code, "code": spot.code,
"content_type_id": spot_ct_id,
"model_name": "parkingspot",
"name_fr": spot.get_name(), "name_fr": spot.get_name(),
"name_nl": spot.name_nl or spot.name_fr or "", "name_nl": spot.name_nl or spot.name_fr or "",
"lon": lon, "lon": lon,

View file

@ -376,12 +376,15 @@ def publiclighting_assets_geojson(request):
def obj_to_feature(obj, asset_type=None): def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj) geometry, lon, lat = build_geometry_and_coords(obj)
ct_id = ContentType.objects.get_for_model(obj).id
return { return {
"type": "Feature", "type": "Feature",
"id": obj.id, "id": obj.id,
"geometry": geometry, "geometry": geometry,
"properties": { "properties": {
"id": obj.id,
"code": obj.code, "code": obj.code,
"content_type_id": ct_id,
"name": obj.get_name(), "name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None, "category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None, "model": obj.model.get_name() if obj.model else None,

View file

@ -967,8 +967,11 @@ def trafficlights_assets_geojson(request):
def obj_to_feature(obj, asset_type=None): def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj) geometry, lon, lat = build_geometry_and_coords(obj)
ct_id = ContentType.objects.get_for_model(obj).id
properties = { properties = {
"id": obj.id,
"code": obj.code, "code": obj.code,
"content_type_id": ct_id,
"name": obj.get_name(), "name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None, "category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None, "model": obj.model.get_name() if obj.model else None,

View file

@ -92,6 +92,18 @@
</div> </div>
{% endif %} {% endif %}
{% if can_inspect %}
<div class="col-12">
<a class="card-link d-block" href="{% url 'mobile:inspections_list_mobile' %}">
<div class="icon-wrapper">
<i class="bi bi-search"></i>
</div>
<h2 class="fw-semibold">{% translate "Inspections d'assets" %}</h2>
<p>{% translate "Consulter la liste et l'historique des vérifications d'assets" %}</p>
</a>
</div>
{% endif %}
<!-- If user has permission to edit green surfaces --> <!-- If user has permission to edit green surfaces -->
{% if can_edit_green_surfaces %} {% if can_edit_green_surfaces %}
<div class="col-12"> <div class="col-12">