feat: add continuous photo capture functionality for asset inspections and update mobile dashboard navigation
This commit is contained in:
parent
41619256a9
commit
adaf19ab39
16 changed files with 737 additions and 103 deletions
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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 = '×';
|
||||||
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue