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 %}
+