From 06b70a3129f75bfc20fe00d28fc497523ddade70 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 17 Aug 2026 11:07:45 +0200 Subject: [PATCH] feat: improve photo orientation handling, EXIF transposing, continuous camera rotation, and add photo rotation tool --- loko/common/models.py | 73 ++++++---- .../static/controls/controls_detail.css | 16 +++ .../static/controls/controls_detail.js | 135 +++++++++++++++++- loko/controls/static/controls/remarks_form.js | 41 +++++- .../controls/controls_detail_content.html | 26 ++++ .../controls/controls_detail_mobile.html | 29 ++++ .../templates/controls/remarks_form.html | 3 + .../controls/remarks_form_mobile.html | 3 + loko/controls/urls.py | 1 + loko/controls/views.py | 28 ++++ loko/controls/views_reports_pdf.py | 2 + .../views/views_pdf_reporting.py | 2 + loko/observations/models.py | 30 ++-- 13 files changed, 343 insertions(+), 46 deletions(-) diff --git a/loko/common/models.py b/loko/common/models.py index ea4dbf5..37f34ef 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -987,8 +987,37 @@ class Document(models.Model): return getattr(self, "description", None) or self.filename + def rotate_image(self, angle=90): + """Fait pivoter l'image de `angle` degrés dans le sens horaire (90, 180, 270) et régénère la miniature.""" + if not self.file: + return False + try: + from PIL import Image, ImageOps + file_path = self.file.path + with Image.open(file_path) as img: + img = ImageOps.exif_transpose(img) or img + pil_angle = (360 - (angle % 360)) % 360 + if pil_angle != 0: + img = img.rotate(pil_angle, expand=True) + fmt = img.format or "JPEG" + if fmt.upper() in ("JPEG", "JPG"): + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + img.save(file_path, "JPEG", quality=92) + else: + img.save(file_path, format=fmt) + + self.generate_thumbnail() + return True + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Error rotating image {self.file.name}: {e}") + return False + def generate_thumbnail(self): - """Générer une vignette pour les fichiers PDF.""" + """Générer une vignette pour les fichiers PDF ou images.""" + if not self.file: + return if self.file.name.endswith(".pdf"): try: pdf_path = self.file.path @@ -996,23 +1025,24 @@ class Document(models.Model): thumbnail_path = f"{self.file.path}_thumbnail.jpg" images[0].save(thumbnail_path, "JPEG") self.thumbnail.name = f"{self.file.name}_thumbnail.jpg" - self.save() + super().save(update_fields=['thumbnail']) except Exception as e: import logging logger = logging.getLogger(__name__) logger.error(f"Error generating PDF thumbnail for {self.file.name}: {e}") else: try: + from PIL import ImageOps with Image.open(self.file.path) as img: - # Conversion en mode RGB si nécessaire (évite les erreurs avec le format JPEG) - if img.mode in ("RGBA", "P"): # "P" correspond aux images en palette (GIF) + img = ImageOps.exif_transpose(img) or img + if img.mode in ("RGBA", "P"): img = img.convert("RGB") - img.thumbnail((200, 200)) + img.thumbnail((300, 300)) thumbnail_path = f"{self.file.path}_thumbnail.jpg" - img.save(thumbnail_path, "JPEG") + img.save(thumbnail_path, "JPEG", quality=85) self.thumbnail.name = f"{self.file.name}_thumbnail.jpg" - self.save() + super().save(update_fields=['thumbnail']) except Exception as e: import logging logger = logging.getLogger(__name__) @@ -1033,24 +1063,19 @@ class Document(models.Model): if self.file: # Vérifier qu'un fichier est bien présent try: + from PIL import ImageOps with Image.open(self.file.path) as img: - # Vérifier et corriger l'orientation EXIF si nécessaire - try: - exif = img._getexif() - if exif: - for tag, value in exif.items(): - if tag in ExifTags.TAGS and ExifTags.TAGS[tag] == "Orientation": - if value == 3: - img = img.rotate(180, expand=True) - elif value == 6: - img = img.rotate(270, expand=True) - elif value == 8: - img = img.rotate(90, expand=True) - except AttributeError: - pass # Pas d'EXIF, on ignore l'orientation - - # Sauvegarde de l'image corrigée en écrasant l'originale - img.save(self.file.path, format=img.format) + # Correction de l'orientation EXIF via ImageOps.exif_transpose + # (redresse les pixels et supprime le tag Orientation pour éviter la double rotation dans les navigateurs) + transposed_img = ImageOps.exif_transpose(img) + if transposed_img is not None: + fmt = img.format or "JPEG" + if fmt.upper() in ("JPEG", "JPG"): + if transposed_img.mode in ("RGBA", "P"): + transposed_img = transposed_img.convert("RGB") + transposed_img.save(self.file.path, "JPEG", quality=92) + else: + transposed_img.save(self.file.path, format=fmt) except Exception: pass # Fichier non-image (PDF, etc.) diff --git a/loko/controls/static/controls/controls_detail.css b/loko/controls/static/controls/controls_detail.css index 17b0ff4..7dfbaa9 100644 --- a/loko/controls/static/controls/controls_detail.css +++ b/loko/controls/static/controls/controls_detail.css @@ -45,6 +45,22 @@ body { z-index: 5; } +.remark-image-rotate { + position: absolute; + top: 4px; + left: 4px; + padding: 0.125rem 0.35rem; + z-index: 5; + background-color: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(0, 0, 0, 0.2); + color: #333; +} + +.remark-image-rotate:hover { + background-color: #fff; + color: #000; +} + #remarkPhotoCarouselModal .modal-body { background-color: #000; } diff --git a/loko/controls/static/controls/controls_detail.js b/loko/controls/static/controls/controls_detail.js index 3ec6319..57e01ee 100644 --- a/loko/controls/static/controls/controls_detail.js +++ b/loko/controls/static/controls/controls_detail.js @@ -1586,6 +1586,7 @@ function bindAutocompleteForRow(card) { const cameraFlashOverlay = document.getElementById('cameraFlashOverlay'); const cameraStatus = document.getElementById('cameraStatus'); const cameraCaptureBtn = document.getElementById('cameraCaptureBtn'); + const cameraRotateBtn = document.getElementById('cameraRotateBtn'); const cameraStopBtn = document.getElementById('cameraStopBtn'); const selectFilesBtn = document.getElementById('select-files-btn'); const filesInput = document.getElementById('id_images'); @@ -1597,6 +1598,7 @@ function bindAutocompleteForRow(card) { let cameraModal = null; let cameraStream = null; let photoInputIndex = 0; + let currentCameraRotation = 0; // 0, 90, 180, 270 const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); // Initialiser le modal Bootstrap @@ -1604,6 +1606,15 @@ function bindAutocompleteForRow(card) { cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl); } + if (cameraRotateBtn) { + cameraRotateBtn.addEventListener('click', function() { + currentCameraRotation = (currentCameraRotation + 90) % 360; + if (cameraPreview) { + cameraPreview.style.transform = currentCameraRotation ? `rotate(${currentCameraRotation}deg)` : ''; + } + }); + } + function triggerCameraFlash() { if (!cameraFlashOverlay) return; cameraFlashOverlay.style.opacity = '1'; @@ -1632,6 +1643,8 @@ function bindAutocompleteForRow(card) { } try { + currentCameraRotation = 0; + if (cameraPreview) cameraPreview.style.transform = ''; updateCameraStatus(gettext('Initialisation de la caméra…'), 'info'); cameraStream = await navigator.mediaDevices.getUserMedia({ video: { @@ -1665,7 +1678,9 @@ function bindAutocompleteForRow(card) { } if (cameraPreview) { cameraPreview.srcObject = null; + cameraPreview.style.transform = ''; } + currentCameraRotation = 0; updateCameraStatus('', 'info'); } @@ -1679,11 +1694,33 @@ function bindAutocompleteForRow(card) { return; } - cameraCanvas.width = videoWidth; - cameraCanvas.height = videoHeight; + const rotation = currentCameraRotation % 360; + + if (rotation === 90 || rotation === 270) { + cameraCanvas.width = videoHeight; + cameraCanvas.height = videoWidth; + } else { + cameraCanvas.width = videoWidth; + cameraCanvas.height = videoHeight; + } const ctx = cameraCanvas.getContext('2d'); + ctx.clearRect(0, 0, cameraCanvas.width, cameraCanvas.height); + ctx.save(); + + if (rotation === 90) { + ctx.translate(videoHeight, 0); + ctx.rotate(90 * Math.PI / 180); + } else if (rotation === 180) { + ctx.translate(videoWidth, videoHeight); + ctx.rotate(180 * Math.PI / 180); + } else if (rotation === 270) { + ctx.translate(0, videoWidth); + ctx.rotate(270 * Math.PI / 180); + } + ctx.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight); + ctx.restore(); cameraCanvas.toBlob(function(blob) { if (!blob) { @@ -1697,7 +1734,7 @@ function bindAutocompleteForRow(card) { // Ajouter au formulaire addFileToPreview(file); updateCameraStatus(gettext('Photo capturée avec succès.'), 'success'); - }, 'image/jpeg', 0.9); + }, 'image/jpeg', 0.92); } function addFileToPreview(file) { @@ -2012,6 +2049,7 @@ function updateRemarkCarouselActiveSlide(idx) { const captionEl = document.getElementById('remark-carousel-caption'); const hdBtn = document.getElementById('remark-carousel-hd-btn'); const deleteBtn = document.getElementById('remark-carousel-delete-btn'); + const rotateBtn = document.getElementById('remark-carousel-rotate-btn'); if (counterBadge) { counterBadge.textContent = `${idx + 1} / ${total}`; @@ -2022,6 +2060,17 @@ function updateRemarkCarouselActiveSlide(idx) { if (hdBtn && photo) { hdBtn.href = photo.fullUrl; } + if (rotateBtn) { + if (photo && photo.imageId) { + rotateBtn.style.display = 'inline-flex'; + rotateBtn.dataset.imageId = photo.imageId; + rotateBtn.onclick = function () { + rotateRemarkImage(photo.imageId, rotateBtn); + }; + } else { + rotateBtn.style.display = 'none'; + } + } if (deleteBtn) { if (photo && photo.imageId) { deleteBtn.style.display = 'inline-flex'; @@ -2045,6 +2094,86 @@ function updateRemarkCarouselActiveSlide(idx) { } } +async function rotateRemarkImage(imageId, triggerBtn) { + if (!imageId) return; + + let originalHtml = ''; + if (triggerBtn) { + triggerBtn.disabled = true; + originalHtml = triggerBtn.innerHTML; + triggerBtn.innerHTML = ''; + } + + try { + const formData = new FormData(); + formData.append('angle', '90'); + const csrf = (typeof getCookie === 'function' ? getCookie('csrftoken') : '') || (typeof csrftoken !== 'undefined' ? csrftoken : ''); + const response = await fetch(`/controls/remark_images/${imageId}/rotate/`, { + method: 'POST', + headers: { + 'X-CSRFToken': csrf, + }, + body: formData + }); + const data = await response.json(); + if (data.success) { + const timestamp = Date.now(); + + // Rafraîchir l'image active dans le carrousel si ouvert + const activeCarouselImg = document.querySelector('#remark-carousel-inner .carousel-item.active img'); + if (activeCarouselImg) { + const cleanUrl = activeCarouselImg.src.split('?')[0]; + activeCarouselImg.src = `${cleanUrl}?t=${timestamp}`; + } + + // Rafraîchir toutes les vignettes et attributs data correspondants sur la page + document.querySelectorAll(`img[data-image-id="${imageId}"]`).forEach(img => { + const cleanThumb = img.src.split('?')[0]; + img.src = `${cleanThumb}?t=${timestamp}`; + if (img.dataset.fullUrl) { + const cleanFull = img.dataset.fullUrl.split('?')[0]; + img.dataset.fullUrl = `${cleanFull}?t=${timestamp}`; + } + if (img.dataset.thumbUrl) { + const cleanThumbData = img.dataset.thumbUrl.split('?')[0]; + img.dataset.thumbUrl = `${cleanThumbData}?t=${timestamp}`; + } + }); + + // Mettre à jour l'état local du carrousel + if (currentRemarkCarouselPhotos && currentRemarkCarouselPhotos[currentRemarkCarouselIndex]) { + const curr = currentRemarkCarouselPhotos[currentRemarkCarouselIndex]; + if (curr.imageId == imageId) { + curr.fullUrl = curr.fullUrl.split('?')[0] + `?t=${timestamp}`; + curr.thumbUrl = curr.thumbUrl.split('?')[0] + `?t=${timestamp}`; + if (hdBtn) hdBtn.href = curr.fullUrl; + } + } + } else { + alert(data.error || gettext("Erreur lors de la rotation.")); + } + } catch (e) { + console.error("Rotation error:", e); + alert(gettext("Erreur réseau lors de la rotation.")); + } finally { + if (triggerBtn) { + triggerBtn.disabled = false; + triggerBtn.innerHTML = originalHtml; + } + } +} + +// Délégation d'événement pour les boutons de rotation sur les vignettes de remarques +document.addEventListener('click', function(e) { + const rotateBtn = e.target.closest('.remark-image-rotate'); + if (rotateBtn) { + e.preventDefault(); + e.stopPropagation(); + const imageId = rotateBtn.dataset.imageId; + rotateRemarkImage(imageId, rotateBtn); + } +}); + // Support navigation au clavier (flèche gauche / droite) pour le carrousel document.addEventListener('DOMContentLoaded', function() { const modalEl = document.getElementById('remarkPhotoCarouselModal'); diff --git a/loko/controls/static/controls/remarks_form.js b/loko/controls/static/controls/remarks_form.js index b72aa0f..505b70f 100644 --- a/loko/controls/static/controls/remarks_form.js +++ b/loko/controls/static/controls/remarks_form.js @@ -72,6 +72,7 @@ $(document).ready(function() { const cameraFlashOverlay = document.getElementById('cameraFlashOverlay'); const cameraStatus = document.getElementById('cameraStatus'); const cameraCaptureBtn = document.getElementById('cameraCaptureBtn'); + const cameraRotateBtn = document.getElementById('cameraRotateBtn'); const cameraStopBtn = document.getElementById('cameraStopBtn'); const selectFilesBtn = document.getElementById('select-files-btn'); const filesInput = document.getElementById('id_images'); @@ -85,6 +86,7 @@ $(document).ready(function() { let cameraModal = null; let cameraStream = null; let photoInputIndex = 0; + let currentCameraRotation = 0; // 0, 90, 180, 270 const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); // Initialiser le modal Bootstrap @@ -92,6 +94,15 @@ $(document).ready(function() { cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl); } + if (cameraRotateBtn) { + cameraRotateBtn.addEventListener('click', function() { + currentCameraRotation = (currentCameraRotation + 90) % 360; + if (cameraPreview) { + cameraPreview.style.transform = currentCameraRotation ? `rotate(${currentCameraRotation}deg)` : ''; + } + }); + } + function triggerCameraFlash() { if (!cameraFlashOverlay) return; cameraFlashOverlay.style.opacity = '1'; @@ -237,7 +248,9 @@ $(document).ready(function() { } if (cameraPreview) { cameraPreview.srcObject = null; + cameraPreview.style.transform = ''; } + currentCameraRotation = 0; updateCameraStatus('', 'info'); } @@ -251,11 +264,33 @@ $(document).ready(function() { return; } - cameraCanvas.width = videoWidth; - cameraCanvas.height = videoHeight; + const rotation = currentCameraRotation % 360; + + if (rotation === 90 || rotation === 270) { + cameraCanvas.width = videoHeight; + cameraCanvas.height = videoWidth; + } else { + cameraCanvas.width = videoWidth; + cameraCanvas.height = videoHeight; + } const ctx = cameraCanvas.getContext('2d'); + ctx.clearRect(0, 0, cameraCanvas.width, cameraCanvas.height); + ctx.save(); + + if (rotation === 90) { + ctx.translate(videoHeight, 0); + ctx.rotate(90 * Math.PI / 180); + } else if (rotation === 180) { + ctx.translate(videoWidth, videoHeight); + ctx.rotate(180 * Math.PI / 180); + } else if (rotation === 270) { + ctx.translate(0, videoWidth); + ctx.rotate(270 * Math.PI / 180); + } + ctx.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight); + ctx.restore(); cameraCanvas.toBlob(function(blob) { if (!blob) { @@ -269,7 +304,7 @@ $(document).ready(function() { // Ajouter au formulaire addFileToPreview(file); updateCameraStatus(gettext('Photo capturée avec succès.'), 'success'); - }, 'image/jpeg', 0.9); + }, 'image/jpeg', 0.92); } function addFileToPreview(file) { diff --git a/loko/controls/templates/controls/controls_detail_content.html b/loko/controls/templates/controls/controls_detail_content.html index 3f8edaf..eedef42 100644 --- a/loko/controls/templates/controls/controls_detail_content.html +++ b/loko/controls/templates/controls/controls_detail_content.html @@ -436,6 +436,13 @@ data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}{% if entry.created_at %} — {{ entry.created_at|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% endif %}" onclick="openRemarkPhotoCarousel(this)"> {% if can_change_control %} + + {% endif %} {% translate "Plein écran" %} diff --git a/loko/controls/templates/controls/controls_detail_mobile.html b/loko/controls/templates/controls/controls_detail_mobile.html index 3ded54a..e6b3051 100644 --- a/loko/controls/templates/controls/controls_detail_mobile.html +++ b/loko/controls/templates/controls/controls_detail_mobile.html @@ -482,6 +482,13 @@ data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}{% if entry.created_at %} — {{ entry.created_at|timezone:'Europe/Paris'|date:'d/m/Y H:i' }}{% endif %}" onclick="openRemarkPhotoCarousel(this)"> {% if can_change_control %} + + @@ -1192,6 +1216,11 @@ document.getElementById('force-sync-btn')?.addEventListener('click', async () =>