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.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})"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 = '<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() {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||
</div>
|
||||
|
||||
<form id="quickInspectionForm" action="{% url 'assets:create_inspection_api' %}" method="post">
|
||||
<form id="quickInspectionForm" action="{% url 'assets:create_inspection_api' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="content_type_id" id="insp_content_type_id">
|
||||
<input type="hidden" name="object_id" id="insp_object_id">
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
<div class="mb-1">
|
||||
<label class="form-label small mb-1">{% translate "Qualité marquage" %}</label>
|
||||
<select name="verified_marking_quality" id="insp_marking_quality" class="form-select form-select-sm">
|
||||
<option value="">{% translate "-- Inchangée --" %}</option>
|
||||
<option value="">{% translate "-- Sélectionner --" %}</option>
|
||||
<option value="good">{% translate "Bon état" %}</option>
|
||||
<option value="medium">{% translate "Moyen" %}</option>
|
||||
<option value="damaged">{% translate "Dégradé" %}</option>
|
||||
|
|
@ -116,10 +116,36 @@
|
|||
</div>
|
||||
|
||||
<!-- Remarques -->
|
||||
<div class="mb-2">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
|
||||
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="modal-footer bg-light">
|
||||
|
|
@ -135,6 +161,38 @@
|
|||
</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) -->
|
||||
<div class="modal fade" id="targetChooserModal" tabindex="-1" aria-labelledby="targetChooserModalLabel" aria-hidden="true">
|
||||
<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' %}">
|
||||
<i class="bi bi-arrow-left fs-4"></i>
|
||||
</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 %}
|
||||
<a href="/" class="nav-link" style="color: white;">
|
||||
<i class="bi bi-house fs-3" title="Home"></i>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
</div>
|
||||
{% if can_inspect %}
|
||||
<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)" %}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -41,23 +41,32 @@
|
|||
<form method="get" class="row g-2 align-items-center">
|
||||
<input type="hidden" name="tab" value="{{ filter_type }}">
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="q" value="{{ search_q }}" class="form-control" placeholder="{% translate 'Rechercher par note, inspecteur...' %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="col-12 col-sm-6 col-md-2">
|
||||
<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 %}
|
||||
<option value="{{ t.code }}" {% if selected_thematic == t.code %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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()">
|
||||
<option value="">{% translate "-- Tous les résultats --" %}</option>
|
||||
{% for val, label in result_choices %}
|
||||
|
|
@ -66,7 +75,7 @@
|
|||
</select>
|
||||
</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>
|
||||
<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>
|
||||
|
|
@ -74,75 +83,190 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des inspections -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold text-muted small">
|
||||
{% blocktranslate count counter=total_count %}{{ counter }} inspection trouvée{% plural %}{{ counter }} inspections trouvées{% endblocktranslate %}
|
||||
</span>
|
||||
<!-- Liste des inspections sous forme de Cards -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="fw-semibold text-muted small">
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
{% if inspections %}
|
||||
<div class="row g-3 mb-4">
|
||||
{% for insp in inspections %}
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0 h-100 inspection-card cursor-pointer"
|
||||
style="cursor: pointer; transition: transform 0.15s ease-in-out, box-shadow 0.15s ease-in-out;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#inspectionDetailModal{{ insp.id }}">
|
||||
<div class="card-body p-3 d-flex flex-column justify-content-between">
|
||||
<div>
|
||||
<!-- Entête card : Thématique/Type + Badge résultat -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="badge bg-light text-dark border d-inline-flex align-items-center me-2">
|
||||
<i class="bi {{ insp.thematic_icon }} me-2 text-primary"></i>
|
||||
{{ insp.asset_content_type.name }}
|
||||
</span>
|
||||
<span class="badge {{ insp.status_badge_class }}">
|
||||
{{ insp.get_result_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nom de la cible tronqué à 100 caractères -->
|
||||
<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>
|
||||
{% endif %}
|
||||
{% if insp.inspector %}
|
||||
<span><i class="bi bi-person me-1"></i>{{ insp.inspector.get_full_name|default:insp.inspector.username }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if inspections %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3" style="width: 15%;">{% translate "Date" %}</th>
|
||||
<th style="width: 25%;">{% translate "Cible" %}</th>
|
||||
<th style="width: 15%;">{% translate "Inspecteur" %}</th>
|
||||
<th style="width: 15%;">{% translate "Résultat" %}</th>
|
||||
<th style="width: 15%;">{% translate "Nature" %}</th>
|
||||
<th class="pe-3" style="width: 15%;">{% translate "Remarques" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for insp in inspections %}
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<span class="fw-bold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if insp.asset and insp.asset.get_absolute_url %}
|
||||
<a href="{{ insp.asset.get_absolute_url }}" class="fw-semibold text-decoration-none">
|
||||
{{ insp.target_display }}
|
||||
|
||||
<!-- 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 %}
|
||||
<span class="fw-semibold">{{ insp.target_display }}</span>
|
||||
<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 %}
|
||||
<br><small class="text-muted">{{ insp.asset_content_type.name }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if insp.inspector %}
|
||||
<small><i class="bi bi-person me-1"></i>{{ insp.inspector.get_full_name|default:insp.inspector.username }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted small">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {{ insp.status_badge_class }}">
|
||||
{{ insp.get_result_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">
|
||||
{{ insp.get_nature_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="pe-3 text-truncate" style="max-width: 200px;" title="{{ insp.notes }}">
|
||||
<small class="text-muted">{{ insp.notes|default:"-" }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-muted">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2 text-secondary"></i>
|
||||
{% translate "Aucune inspection ne correspond à vos critères." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,9 @@
|
|||
{% block extra_js %}
|
||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||
<script src="{% static 'common/address_search_control.js' %}"></script>
|
||||
<script>window.AUTOCOMPLETE_STREET_URL = "{% url 'common:autocomplete_street' %}";</script>
|
||||
<script>
|
||||
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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -121,3 +121,25 @@ class InspectionViewsTestCase(TestCase):
|
|||
# 2. Tentative d'accès direct à quick_inspection_map -> HTTP 403 Forbidden
|
||||
resp_map = self.client.get(reverse('assets:quick_inspection_map'))
|
||||
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:
|
||||
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 {
|
||||
'base_template': base_template,
|
||||
'is_mobile': is_mobile,
|
||||
'allowed_update_fields': allowed_update_fields,
|
||||
'allowed_actions': allowed_actions,
|
||||
'status_choices': status_choices,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ def inspections_list(request):
|
|||
nature_filter = request.GET.get('nature', '')
|
||||
search_q = request.GET.get('q', '').strip()
|
||||
|
||||
qs = AssetInspection.objects.all().select_related('asset_content_type', 'inspector').order_by('-inspection_date')
|
||||
qs = AssetInspection.objects.all().select_related('asset_content_type', 'inspector').prefetch_related('documents').order_by('-inspection_date')
|
||||
|
||||
if filter_type == 'my':
|
||||
qs = qs.filter(inspector=request.user)
|
||||
|
|
@ -101,11 +101,15 @@ def quick_inspection_map(request):
|
|||
is_mobile = request.path.startswith('/mobile/')
|
||||
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 = {
|
||||
'base_template': base_template,
|
||||
'is_mobile': is_mobile,
|
||||
'thematics': thematics,
|
||||
'categories': categories,
|
||||
'parking_spot_ct_id': parking_spot_ct_id,
|
||||
}
|
||||
return render(request, 'assets/quick_inspection_map.html', context)
|
||||
|
||||
|
|
@ -173,17 +177,28 @@ def create_inspection_api(request):
|
|||
nature=nature,
|
||||
inspector_role=inspector_role,
|
||||
verified_obsolescence=verified_obsolescence,
|
||||
notes=notes,
|
||||
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
|
||||
if request.POST.get('create_intervention') == 'true' and result_status in ('non_compliant', 'damaged', 'missing'):
|
||||
thematic = None
|
||||
if hasattr(target_obj, 'category') and target_obj.category:
|
||||
thematic = target_obj.category.thematic
|
||||
elif hasattr(target_obj, 'thematic'):
|
||||
thematic = target_obj.thematic
|
||||
from assets.permissions import _get_asset_thematic
|
||||
thematic = _get_asset_thematic(target_obj)
|
||||
if not thematic:
|
||||
if is_parking_spot:
|
||||
thematic = Thematic.objects.filter(code='parking').first()
|
||||
else:
|
||||
thematic = Thematic.objects.first()
|
||||
|
||||
from interventions.models import Intervention, InterventionAsset
|
||||
intervention = Intervention.objects.create(
|
||||
|
|
|
|||
|
|
@ -154,9 +154,13 @@ def parking_assets_geojson(request):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
spot_ct_id = ContentType.objects.get_for_model(ParkingSpot).id
|
||||
|
||||
properties = {
|
||||
"id": spot.id,
|
||||
"code": spot.code,
|
||||
"content_type_id": spot_ct_id,
|
||||
"model_name": "parkingspot",
|
||||
"name_fr": spot.get_name(),
|
||||
"name_nl": spot.name_nl or spot.name_fr or "",
|
||||
"lon": lon,
|
||||
|
|
|
|||
|
|
@ -376,12 +376,15 @@ def publiclighting_assets_geojson(request):
|
|||
|
||||
def obj_to_feature(obj, asset_type=None):
|
||||
geometry, lon, lat = build_geometry_and_coords(obj)
|
||||
ct_id = ContentType.objects.get_for_model(obj).id
|
||||
return {
|
||||
"type": "Feature",
|
||||
"id": obj.id,
|
||||
"geometry": geometry,
|
||||
"properties": {
|
||||
"id": obj.id,
|
||||
"code": obj.code,
|
||||
"content_type_id": ct_id,
|
||||
"name": obj.get_name(),
|
||||
"category": obj.category.get_name() if obj.category 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):
|
||||
geometry, lon, lat = build_geometry_and_coords(obj)
|
||||
ct_id = ContentType.objects.get_for_model(obj).id
|
||||
properties = {
|
||||
"id": obj.id,
|
||||
"code": obj.code,
|
||||
"content_type_id": ct_id,
|
||||
"name": obj.get_name(),
|
||||
"category": obj.category.get_name() if obj.category else None,
|
||||
"model": obj.model.get_name() if obj.model else None,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,18 @@
|
|||
</div>
|
||||
{% 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 can_edit_green_surfaces %}
|
||||
<div class="col-12">
|
||||
|
|
|
|||
Loading…
Reference in a new issue