feat: add edit inspection modal component and associated test file
This commit is contained in:
parent
b15326602b
commit
86979307ae
2 changed files with 299 additions and 5 deletions
|
|
@ -169,13 +169,29 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Ajout de Photos/Documents complémentaires -->
|
||||
<div class="card border border-primary-subtle mb-3 bg-light">
|
||||
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold text-dark small"><i class="bi bi-paperclip me-1 text-primary"></i>{% translate "Ajouter des photos / documents" %}</span>
|
||||
<!-- Section Ajout de Photos/Documents (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 complémentaires" %}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -195,6 +211,38 @@
|
|||
</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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editModalEl = document.getElementById('editInspectionModal');
|
||||
|
|
@ -207,6 +255,204 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const spinner = document.getElementById('submitEditInspectionSpinner');
|
||||
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 = '×';
|
||||
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) {
|
||||
if (!inspectionId) return;
|
||||
|
||||
|
|
@ -222,6 +468,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
alertDiv.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
|
||||
fetch(`/inspections/api/${inspectionId}/detail/`)
|
||||
.then(res => res.json())
|
||||
|
|
@ -390,6 +645,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
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/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
|
|
|||
|
|
@ -262,6 +262,40 @@ class InspectionModelAndViewsTestCase(TestCase):
|
|||
self.assertEqual(insp.nature, 'detailed')
|
||||
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):
|
||||
other_user = User.objects.create_user(
|
||||
username='other_user_stranger',
|
||||
|
|
|
|||
Loading…
Reference in a new issue