diff --git a/streetup/assets/migrations/0013_alter_parkingspotinspection_verified_marking_quality_and_more.py b/streetup/assets/migrations/0013_alter_parkingspotinspection_verified_marking_quality_and_more.py new file mode 100644 index 0000000..a862d28 --- /dev/null +++ b/streetup/assets/migrations/0013_alter_parkingspotinspection_verified_marking_quality_and_more.py @@ -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", + }, + ), + ] diff --git a/streetup/assets/migrations/0014_alter_inspectiondocument_file.py b/streetup/assets/migrations/0014_alter_inspectiondocument_file.py new file mode 100644 index 0000000..2a04126 --- /dev/null +++ b/streetup/assets/migrations/0014_alter_inspectiondocument_file.py @@ -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'), + ), + ] diff --git a/streetup/assets/models/inspection.py b/streetup/assets/models/inspection.py index 58fbdfb..7cdd0c0 100644 --- a/streetup/assets/models/inspection.py +++ b/streetup/assets/models/inspection.py @@ -4,6 +4,9 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericForeignKey from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from common.storages import PrivateMediaStorage + +private_storage = PrivateMediaStorage() INSPECTION_RESULT_CHOICES = ( ('compliant', _('Conforme')), @@ -144,6 +147,82 @@ class AssetInspection(models.Model): return str(self.asset) 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): 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 '' @@ -175,8 +254,15 @@ class ParkingSpotInspection(AssetInspection): blank=True, 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( max_length=50, + choices=MARKING_QUALITY_CHOICES, null=True, blank=True, verbose_name=_("Qualité du marquage constatée") @@ -189,25 +275,46 @@ class ParkingSpotInspection(AssetInspection): def save(self, *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: - target_asset.standard_stand_count = self.verified_standard_stand_count - modified = True - if self.verified_cargo_stand_count is not None: - target_asset.cargo_stand_count = self.verified_cargo_stand_count - 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 +def inspection_document_upload_path(instance, filename): + now = timezone.now() + year = now.strftime("%Y") + month = now.strftime("%m") + return f"inspections/documents/{year}/{month}/{filename}" - 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})" diff --git a/streetup/assets/permissions.py b/streetup/assets/permissions.py index 1619b2b..65bfcb5 100644 --- a/streetup/assets/permissions.py +++ b/streetup/assets/permissions.py @@ -268,6 +268,10 @@ def _get_asset_thematic(asset): if hasattr(asset, 'category') and asset.category: return asset.category.thematic + # Try direct thematic attribute + if hasattr(asset, 'thematic') and asset.thematic: + return asset.thematic + # Try from parent building category if hasattr(asset, 'building') and asset.building and hasattr(asset.building, 'category') and asset.building.category: return asset.building.category.thematic @@ -294,6 +298,9 @@ def _get_asset_thematic(asset): 'its': 'its', 'building': 'publicbuildings', 'publicbuilding': 'publicbuildings', + 'parking': 'parking', + 'parkingspot': 'parking', + 'parkinglocation': 'parking', } for prefix, thematic_code in thematic_mappings.items(): diff --git a/streetup/assets/static/assets/inspection_map.js b/streetup/assets/static/assets/inspection_map.js index 9a796c4..cc658e1 100644 --- a/streetup/assets/static/assets/inspection_map.js +++ b/streetup/assets/static/assets/inspection_map.js @@ -500,7 +500,11 @@ const modalEl = document.getElementById('quickInspectionModal'); 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_target_type').textContent = props.asset_type_display || props.model_name || 'Asset'; @@ -512,18 +516,223 @@ 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; + document.getElementById('insp_std_stands').value = ''; + document.getElementById('insp_cargo_stands').value = ''; + const markingSel = document.getElementById('insp_marking_quality'); + if (markingSel) markingSel.value = ''; } else { 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); 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 = '' + file.name + ''; + 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 = '×'; + removeBtn.onclick = function(ev) { + ev.stopPropagation(); + accumulatedInspectionFiles.splice(index, 1); + renderPhotoPreviews(); + }; + + card.appendChild(removeBtn); + col.appendChild(card); + previewContainer.appendChild(col); + }); + } + } + function initFormHandler() { + initInspectionPhotoHandler(); + const form = document.getElementById('createInspectionForm') || document.getElementById('quickInspectionForm'); if (!form) return; @@ -542,6 +751,11 @@ const formData = new FormData(form); + // Ajout des fichiers accumulés en mode continu + accumulatedInspectionFiles.forEach(file => { + formData.append('photos', file); + }); + fetch(form.action, { method: 'POST', body: formData, diff --git a/streetup/assets/templates/assets/components/inspection_form_modal.html b/streetup/assets/templates/assets/components/inspection_form_modal.html index 29155d5..77990d5 100644 --- a/streetup/assets/templates/assets/components/inspection_form_modal.html +++ b/streetup/assets/templates/assets/components/inspection_form_modal.html @@ -11,7 +11,7 @@ -