feat: add edit inspection modal component and associated test file

This commit is contained in:
Karl Determe 2026-08-19 08:11:03 +02:00
parent b15326602b
commit 86979307ae
2 changed files with 299 additions and 5 deletions

View file

@ -169,13 +169,29 @@
</div> </div>
</div> </div>
<!-- Section Ajout de Photos/Documents complémentaires --> <!-- Section Ajout de Photos/Documents (Mode continu & Fichiers) -->
<div class="card border border-primary-subtle mb-3 bg-light"> <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 d-flex justify-content-between align-items-center"> <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-paperclip me-1 text-primary"></i>{% translate "Ajouter des photos / documents" %}</span> <span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers complémentaires" %}</span>
</div> </div>
<div class="card-body p-3"> <div class="card-body p-3">
<input type="file" name="photos" id="edit_insp_files_input" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" multiple class="form-control form-control-sm"> <div class="d-grid gap-2">
<button type="button" id="edit_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="edit_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="edit_insp_camera_input" accept="image/*" capture="environment" class="d-none">
<input type="file" id="edit_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="edit_insp_photos_preview_container" class="row g-2 mt-2"></div>
</div> </div>
</div> </div>
@ -195,6 +211,38 @@
</div> </div>
</div> </div>
<!-- Modal caméra Canvas (mode continu pour modification d'inspection) -->
<div class="modal fade" id="editInspCameraModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="editInspCameraModalLabel" aria-hidden="true" style="z-index: 1085;">
<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="editInspCameraModalLabel">
<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="editInspCameraModalCloseBtn"></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="editInspCameraPreview" playsinline autoplay style="max-width: 100%; max-height: 100%; object-fit: contain;"></video>
<div id="editInspCameraFlashOverlay" 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="editInspCameraCanvas" class="d-none"></canvas>
</div>
</div>
<div id="editInspCameraStatus" 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="editInspCameraStopBtn">
<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="editInspCameraCaptureBtn">
<i class="bi bi-camera-fill me-1"></i>{% translate "Capturer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const editModalEl = document.getElementById('editInspectionModal'); const editModalEl = document.getElementById('editInspectionModal');
@ -207,6 +255,204 @@ document.addEventListener('DOMContentLoaded', function() {
const spinner = document.getElementById('submitEditInspectionSpinner'); const spinner = document.getElementById('submitEditInspectionSpinner');
const icon = document.getElementById('submitEditInspectionIcon'); const icon = document.getElementById('submitEditInspectionIcon');
let accumulatedEditInspectionFiles = [];
let editInspCameraStream = null;
function initEditInspectionPhotoHandler() {
const btnCamera = document.getElementById('edit_insp_btn_continuous_camera');
const btnGallery = document.getElementById('edit_insp_btn_gallery_files');
const inputCamera = document.getElementById('edit_insp_camera_input');
const inputGallery = document.getElementById('edit_insp_gallery_input');
const cameraModalEl = document.getElementById('editInspCameraModal');
const cameraPreview = document.getElementById('editInspCameraPreview');
const cameraCanvas = document.getElementById('editInspCameraCanvas');
const cameraFlashOverlay = document.getElementById('editInspCameraFlashOverlay');
const cameraStatus = document.getElementById('editInspCameraStatus');
const cameraCaptureBtn = document.getElementById('editInspCameraCaptureBtn');
const cameraStopBtn = document.getElementById('editInspCameraStopBtn');
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("{% filter escapejs %}{% translate 'Initialisation de la caméra...' %}{% endfilter %}", false);
editInspCameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false
});
if ('srcObject' in cameraPreview) {
cameraPreview.srcObject = editInspCameraStream;
} else {
cameraPreview.src = URL.createObjectURL(editInspCameraStream);
}
cameraPreview.play();
updateCameraStatus("{% filter escapejs %}{% translate 'Caméra prête. Appuyez sur Capturer pour enregistrer une photo.' %}{% endfilter %}", false);
return true;
} catch (err) {
console.warn('Erreur caméra edit inspection:', err);
stopCameraStream();
return false;
}
}
function stopCameraStream() {
if (editInspCameraStream) {
editInspCameraStream.getTracks().forEach(t => t.stop());
editInspCameraStream = 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("{% filter escapejs %}{% translate 'La caméra n’est pas encore prête.' %}{% endfilter %}", 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' });
accumulatedEditInspectionFiles.push(file);
renderEditPhotoPreviews();
updateCameraStatus("{% filter escapejs %}{% translate 'Photo capturée avec succès !' %}{% endfilter %}", 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 => {
accumulatedEditInspectionFiles.push(file);
});
renderEditPhotoPreviews();
}
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 renderEditPhotoPreviews() {
const previewContainer = document.getElementById('edit_insp_photos_preview_container');
if (!previewContainer) return;
previewContainer.innerHTML = '';
accumulatedEditInspectionFiles.forEach((file, index) => {
const col = document.createElement('div');
col.className = 'col-4 col-sm-3 position-relative mt-2';
const card = document.createElement('div');
card.className = 'border rounded overflow-hidden shadow-sm bg-white text-center p-1 position-relative';
card.style.height = '75px';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
card.appendChild(img);
} else {
const icon = document.createElement('div');
icon.className = 'd-flex flex-column align-items-center justify-content-center h-100 text-muted';
icon.innerHTML = '<i class="bi bi-file-earmark fs-4 text-primary"></i><span class="extra-small text-truncate w-100 px-1">' + file.name + '</span>';
card.appendChild(icon);
}
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-danger btn-sm rounded-circle position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
removeBtn.style.width = '20px';
removeBtn.style.height = '20px';
removeBtn.style.fontSize = '12px';
removeBtn.style.transform = 'translate(30%, -30%)';
removeBtn.innerHTML = '&times;';
removeBtn.onclick = function(ev) {
ev.stopPropagation();
accumulatedEditInspectionFiles.splice(index, 1);
renderEditPhotoPreviews();
};
card.appendChild(removeBtn);
col.appendChild(card);
previewContainer.appendChild(col);
});
}
initEditInspectionPhotoHandler();
window.openEditInspectionModal = function(inspectionId) { window.openEditInspectionModal = function(inspectionId) {
if (!inspectionId) return; if (!inspectionId) return;
@ -221,6 +467,15 @@ document.addEventListener('DOMContentLoaded', function() {
alertDiv.classList.add('d-none'); alertDiv.classList.add('d-none');
successDiv.classList.add('d-none'); successDiv.classList.add('d-none');
// Réinitialiser les photos accumulées
accumulatedEditInspectionFiles = [];
const editPreviewContainer = document.getElementById('edit_insp_photos_preview_container');
if (editPreviewContainer) editPreviewContainer.innerHTML = '';
const editInputCam = document.getElementById('edit_insp_camera_input');
if (editInputCam) editInputCam.value = '';
const editInputGal = document.getElementById('edit_insp_gallery_input');
if (editInputGal) editInputGal.value = '';
// Dynamic fetch inspection details // Dynamic fetch inspection details
fetch(`/inspections/api/${inspectionId}/detail/`) fetch(`/inspections/api/${inspectionId}/detail/`)
@ -390,6 +645,11 @@ document.addEventListener('DOMContentLoaded', function() {
const formData = new FormData(editForm); const formData = new FormData(editForm);
// Ajout des fichiers accumulés en mode continu
accumulatedEditInspectionFiles.forEach(file => {
formData.append('photos', file);
});
fetch(`/inspections/api/${inspId}/update/`, { fetch(`/inspections/api/${inspId}/update/`, {
method: 'POST', method: 'POST',
body: formData, body: formData,

View file

@ -262,6 +262,40 @@ class InspectionModelAndViewsTestCase(TestCase):
self.assertEqual(insp.nature, 'detailed') self.assertEqual(insp.nature, 'detailed')
self.assertGreaterEqual(insp.updated_at, initial_updated_at) self.assertGreaterEqual(insp.updated_at, initial_updated_at)
def test_update_inspection_api_with_photos(self):
from django.core.files.uploadedfile import SimpleUploadedFile
from inspections.models import InspectionDocument
insp = Inspection.objects.create(
asset_content_type=self.spot_ct,
asset_object_id=self.spot.id,
inspector=self.user,
result_status='compliant',
notes="Inspection sans photo initiale"
)
self.assertEqual(insp.documents.count(), 0)
photo1 = SimpleUploadedFile("photo1.jpg", b"fake_jpeg_content_1", content_type="image/jpeg")
photo2 = SimpleUploadedFile("photo2.jpg", b"fake_jpeg_content_2", content_type="image/jpeg")
self.client.login(username='inspector_user', password='password123')
url = reverse('inspections:update_inspection_api', kwargs={'inspection_id': insp.id})
response = self.client.post(url, {
'result_status': 'non_compliant',
'notes': 'Ajout de photos lors de la modification',
'photos': [photo1, photo2]
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data['success'])
insp.refresh_from_db()
self.assertEqual(insp.documents.count(), 2)
doc_names = [d.filename for d in insp.documents.all()]
self.assertTrue(any("photo1" in name for name in doc_names))
self.assertTrue(any("photo2" in name for name in doc_names))
self.assertTrue(all(d.is_image for d in insp.documents.all()))
def test_update_inspection_api_forbidden(self): def test_update_inspection_api_forbidden(self):
other_user = User.objects.create_user( other_user = User.objects.create_user(
username='other_user_stranger', username='other_user_stranger',