feat: improve photo orientation handling, EXIF transposing, continuous camera rotation, and add photo rotation tool

This commit is contained in:
kdeterme 2026-08-17 11:07:45 +02:00
parent d5f7b533f2
commit 06b70a3129
13 changed files with 343 additions and 46 deletions

View file

@ -987,8 +987,37 @@ class Document(models.Model):
return getattr(self, "description", None) or self.filename 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): 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"): if self.file.name.endswith(".pdf"):
try: try:
pdf_path = self.file.path pdf_path = self.file.path
@ -996,23 +1025,24 @@ class Document(models.Model):
thumbnail_path = f"{self.file.path}_thumbnail.jpg" thumbnail_path = f"{self.file.path}_thumbnail.jpg"
images[0].save(thumbnail_path, "JPEG") images[0].save(thumbnail_path, "JPEG")
self.thumbnail.name = f"{self.file.name}_thumbnail.jpg" self.thumbnail.name = f"{self.file.name}_thumbnail.jpg"
self.save() super().save(update_fields=['thumbnail'])
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f"Error generating PDF thumbnail for {self.file.name}: {e}") logger.error(f"Error generating PDF thumbnail for {self.file.name}: {e}")
else: else:
try: try:
from PIL import ImageOps
with Image.open(self.file.path) as img: with Image.open(self.file.path) as img:
# Conversion en mode RGB si nécessaire (évite les erreurs avec le format JPEG) img = ImageOps.exif_transpose(img) or img
if img.mode in ("RGBA", "P"): # "P" correspond aux images en palette (GIF) if img.mode in ("RGBA", "P"):
img = img.convert("RGB") img = img.convert("RGB")
img.thumbnail((200, 200)) img.thumbnail((300, 300))
thumbnail_path = f"{self.file.path}_thumbnail.jpg" 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.thumbnail.name = f"{self.file.name}_thumbnail.jpg"
self.save() super().save(update_fields=['thumbnail'])
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1033,24 +1063,19 @@ class Document(models.Model):
if self.file: # Vérifier qu'un fichier est bien présent if self.file: # Vérifier qu'un fichier est bien présent
try: try:
from PIL import ImageOps
with Image.open(self.file.path) as img: with Image.open(self.file.path) as img:
# Vérifier et corriger l'orientation EXIF si nécessaire # Correction de l'orientation EXIF via ImageOps.exif_transpose
try: # (redresse les pixels et supprime le tag Orientation pour éviter la double rotation dans les navigateurs)
exif = img._getexif() transposed_img = ImageOps.exif_transpose(img)
if exif: if transposed_img is not None:
for tag, value in exif.items(): fmt = img.format or "JPEG"
if tag in ExifTags.TAGS and ExifTags.TAGS[tag] == "Orientation": if fmt.upper() in ("JPEG", "JPG"):
if value == 3: if transposed_img.mode in ("RGBA", "P"):
img = img.rotate(180, expand=True) transposed_img = transposed_img.convert("RGB")
elif value == 6: transposed_img.save(self.file.path, "JPEG", quality=92)
img = img.rotate(270, expand=True) else:
elif value == 8: transposed_img.save(self.file.path, format=fmt)
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)
except Exception: except Exception:
pass # Fichier non-image (PDF, etc.) pass # Fichier non-image (PDF, etc.)

View file

@ -45,6 +45,22 @@ body {
z-index: 5; 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 { #remarkPhotoCarouselModal .modal-body {
background-color: #000; background-color: #000;
} }

View file

@ -1586,6 +1586,7 @@ function bindAutocompleteForRow(card) {
const cameraFlashOverlay = document.getElementById('cameraFlashOverlay'); const cameraFlashOverlay = document.getElementById('cameraFlashOverlay');
const cameraStatus = document.getElementById('cameraStatus'); const cameraStatus = document.getElementById('cameraStatus');
const cameraCaptureBtn = document.getElementById('cameraCaptureBtn'); const cameraCaptureBtn = document.getElementById('cameraCaptureBtn');
const cameraRotateBtn = document.getElementById('cameraRotateBtn');
const cameraStopBtn = document.getElementById('cameraStopBtn'); const cameraStopBtn = document.getElementById('cameraStopBtn');
const selectFilesBtn = document.getElementById('select-files-btn'); const selectFilesBtn = document.getElementById('select-files-btn');
const filesInput = document.getElementById('id_images'); const filesInput = document.getElementById('id_images');
@ -1597,6 +1598,7 @@ function bindAutocompleteForRow(card) {
let cameraModal = null; let cameraModal = null;
let cameraStream = null; let cameraStream = null;
let photoInputIndex = 0; let photoInputIndex = 0;
let currentCameraRotation = 0; // 0, 90, 180, 270
const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
// Initialiser le modal Bootstrap // Initialiser le modal Bootstrap
@ -1604,6 +1606,15 @@ function bindAutocompleteForRow(card) {
cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl); 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() { function triggerCameraFlash() {
if (!cameraFlashOverlay) return; if (!cameraFlashOverlay) return;
cameraFlashOverlay.style.opacity = '1'; cameraFlashOverlay.style.opacity = '1';
@ -1632,6 +1643,8 @@ function bindAutocompleteForRow(card) {
} }
try { try {
currentCameraRotation = 0;
if (cameraPreview) cameraPreview.style.transform = '';
updateCameraStatus(gettext('Initialisation de la caméra…'), 'info'); updateCameraStatus(gettext('Initialisation de la caméra…'), 'info');
cameraStream = await navigator.mediaDevices.getUserMedia({ cameraStream = await navigator.mediaDevices.getUserMedia({
video: { video: {
@ -1665,7 +1678,9 @@ function bindAutocompleteForRow(card) {
} }
if (cameraPreview) { if (cameraPreview) {
cameraPreview.srcObject = null; cameraPreview.srcObject = null;
cameraPreview.style.transform = '';
} }
currentCameraRotation = 0;
updateCameraStatus('', 'info'); updateCameraStatus('', 'info');
} }
@ -1679,11 +1694,33 @@ function bindAutocompleteForRow(card) {
return; return;
} }
cameraCanvas.width = videoWidth; const rotation = currentCameraRotation % 360;
cameraCanvas.height = videoHeight;
if (rotation === 90 || rotation === 270) {
cameraCanvas.width = videoHeight;
cameraCanvas.height = videoWidth;
} else {
cameraCanvas.width = videoWidth;
cameraCanvas.height = videoHeight;
}
const ctx = cameraCanvas.getContext('2d'); 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.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight);
ctx.restore();
cameraCanvas.toBlob(function(blob) { cameraCanvas.toBlob(function(blob) {
if (!blob) { if (!blob) {
@ -1697,7 +1734,7 @@ function bindAutocompleteForRow(card) {
// Ajouter au formulaire // Ajouter au formulaire
addFileToPreview(file); addFileToPreview(file);
updateCameraStatus(gettext('Photo capturée avec succès.'), 'success'); updateCameraStatus(gettext('Photo capturée avec succès.'), 'success');
}, 'image/jpeg', 0.9); }, 'image/jpeg', 0.92);
} }
function addFileToPreview(file) { function addFileToPreview(file) {
@ -2012,6 +2049,7 @@ function updateRemarkCarouselActiveSlide(idx) {
const captionEl = document.getElementById('remark-carousel-caption'); const captionEl = document.getElementById('remark-carousel-caption');
const hdBtn = document.getElementById('remark-carousel-hd-btn'); const hdBtn = document.getElementById('remark-carousel-hd-btn');
const deleteBtn = document.getElementById('remark-carousel-delete-btn'); const deleteBtn = document.getElementById('remark-carousel-delete-btn');
const rotateBtn = document.getElementById('remark-carousel-rotate-btn');
if (counterBadge) { if (counterBadge) {
counterBadge.textContent = `${idx + 1} / ${total}`; counterBadge.textContent = `${idx + 1} / ${total}`;
@ -2022,6 +2060,17 @@ function updateRemarkCarouselActiveSlide(idx) {
if (hdBtn && photo) { if (hdBtn && photo) {
hdBtn.href = photo.fullUrl; 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 (deleteBtn) {
if (photo && photo.imageId) { if (photo && photo.imageId) {
deleteBtn.style.display = 'inline-flex'; 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 = '<span class="spinner-border spinner-border-sm" role="status"></span>';
}
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 // Support navigation au clavier (flèche gauche / droite) pour le carrousel
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('remarkPhotoCarouselModal'); const modalEl = document.getElementById('remarkPhotoCarouselModal');

View file

@ -72,6 +72,7 @@ $(document).ready(function() {
const cameraFlashOverlay = document.getElementById('cameraFlashOverlay'); const cameraFlashOverlay = document.getElementById('cameraFlashOverlay');
const cameraStatus = document.getElementById('cameraStatus'); const cameraStatus = document.getElementById('cameraStatus');
const cameraCaptureBtn = document.getElementById('cameraCaptureBtn'); const cameraCaptureBtn = document.getElementById('cameraCaptureBtn');
const cameraRotateBtn = document.getElementById('cameraRotateBtn');
const cameraStopBtn = document.getElementById('cameraStopBtn'); const cameraStopBtn = document.getElementById('cameraStopBtn');
const selectFilesBtn = document.getElementById('select-files-btn'); const selectFilesBtn = document.getElementById('select-files-btn');
const filesInput = document.getElementById('id_images'); const filesInput = document.getElementById('id_images');
@ -85,6 +86,7 @@ $(document).ready(function() {
let cameraModal = null; let cameraModal = null;
let cameraStream = null; let cameraStream = null;
let photoInputIndex = 0; let photoInputIndex = 0;
let currentCameraRotation = 0; // 0, 90, 180, 270
const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); const isCameraSupported = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
// Initialiser le modal Bootstrap // Initialiser le modal Bootstrap
@ -92,6 +94,15 @@ $(document).ready(function() {
cameraModal = bootstrap.Modal.getOrCreateInstance(cameraModalEl); 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() { function triggerCameraFlash() {
if (!cameraFlashOverlay) return; if (!cameraFlashOverlay) return;
cameraFlashOverlay.style.opacity = '1'; cameraFlashOverlay.style.opacity = '1';
@ -237,7 +248,9 @@ $(document).ready(function() {
} }
if (cameraPreview) { if (cameraPreview) {
cameraPreview.srcObject = null; cameraPreview.srcObject = null;
cameraPreview.style.transform = '';
} }
currentCameraRotation = 0;
updateCameraStatus('', 'info'); updateCameraStatus('', 'info');
} }
@ -251,11 +264,33 @@ $(document).ready(function() {
return; return;
} }
cameraCanvas.width = videoWidth; const rotation = currentCameraRotation % 360;
cameraCanvas.height = videoHeight;
if (rotation === 90 || rotation === 270) {
cameraCanvas.width = videoHeight;
cameraCanvas.height = videoWidth;
} else {
cameraCanvas.width = videoWidth;
cameraCanvas.height = videoHeight;
}
const ctx = cameraCanvas.getContext('2d'); 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.drawImage(cameraPreview, 0, 0, videoWidth, videoHeight);
ctx.restore();
cameraCanvas.toBlob(function(blob) { cameraCanvas.toBlob(function(blob) {
if (!blob) { if (!blob) {
@ -269,7 +304,7 @@ $(document).ready(function() {
// Ajouter au formulaire // Ajouter au formulaire
addFileToPreview(file); addFileToPreview(file);
updateCameraStatus(gettext('Photo capturée avec succès.'), 'success'); updateCameraStatus(gettext('Photo capturée avec succès.'), 'success');
}, 'image/jpeg', 0.9); }, 'image/jpeg', 0.92);
} }
function addFileToPreview(file) { function addFileToPreview(file) {

View file

@ -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 %}" 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)"> onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %} {% if can_change_control %}
<button type="button"
class="btn btn-sm btn-light remark-image-rotate"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Pivoter de 90°' %}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger remark-image-delete" class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}" data-image-id="{{ image.id }}"
@ -480,6 +487,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}" data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}"
onclick="openRemarkPhotoCarousel(this)"> onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %} {% if can_change_control %}
<button type="button"
class="btn btn-sm btn-light remark-image-rotate"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Pivoter de 90°' %}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger remark-image-delete" class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}" data-image-id="{{ image.id }}"
@ -688,6 +702,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})" data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})"
onclick="openRemarkPhotoCarousel(this)"> onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %} {% if can_change_control %}
<button type="button"
class="btn btn-sm btn-light remark-image-rotate"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Pivoter de 90°' %}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger remark-image-delete" class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}" data-image-id="{{ image.id }}"
@ -815,6 +836,11 @@
<div class="modal-footer border-top border-secondary py-2 px-3 d-flex justify-content-between align-items-center bg-dark flex-nowrap"> <div class="modal-footer border-top border-secondary py-2 px-3 d-flex justify-content-between align-items-center bg-dark flex-nowrap">
<div class="small text-muted text-truncate me-2" id="remark-carousel-caption"></div> <div class="small text-muted text-truncate me-2" id="remark-carousel-caption"></div>
<div class="d-flex align-items-center gap-2 flex-shrink-0"> <div class="d-flex align-items-center gap-2 flex-shrink-0">
{% if can_change_control %}
<button type="button" id="remark-carousel-rotate-btn" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Pivoter cette photo de 90°' %}">
<i class="bi bi-arrow-clockwise"></i> <span class="d-none d-sm-inline">{% translate "Pivoter" %}</span>
</button>
{% endif %}
<a id="remark-carousel-hd-btn" href="#" target="_blank" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Ouvrir en plein écran HD' %}"> <a id="remark-carousel-hd-btn" href="#" target="_blank" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Ouvrir en plein écran HD' %}">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span> <i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span>
</a> </a>

View file

@ -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 %}" 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)"> onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %} {% if can_change_control %}
<button type="button"
class="btn btn-sm btn-light remark-image-rotate"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Pivoter de 90°' %}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger remark-image-delete" class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}" data-image-id="{{ image.id }}"
@ -526,6 +533,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}" data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}"
onclick="openRemarkPhotoCarousel(this)"> onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %} {% if can_change_control %}
<button type="button"
class="btn btn-sm btn-light remark-image-rotate"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Pivoter de 90°' %}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger remark-image-delete" class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}" data-image-id="{{ image.id }}"
@ -728,6 +742,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})" data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})"
onclick="openRemarkPhotoCarousel(this)"> onclick="openRemarkPhotoCarousel(this)">
{% if can_change_control %} {% if can_change_control %}
<button type="button"
class="btn btn-sm btn-light remark-image-rotate"
data-image-id="{{ image.id }}"
data-remark-id="{{ remark.id }}"
title="{% translate 'Pivoter de 90°' %}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger remark-image-delete" class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}" data-image-id="{{ image.id }}"
@ -830,6 +851,9 @@
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn"> <button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %} <i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</button> </button>
<button type="button" class="btn btn-outline-light px-3" id="cameraRotateBtn" title="{% translate 'Pivoter la vue de 90°' %}">
<i class="bi bi-arrow-clockwise fs-5"></i>
</button>
<button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn"> <button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %} <i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button> </button>
@ -1192,6 +1216,11 @@ document.getElementById('force-sync-btn')?.addEventListener('click', async () =>
<div class="modal-footer border-top border-secondary py-2 px-3 d-flex justify-content-between align-items-center bg-dark flex-nowrap"> <div class="modal-footer border-top border-secondary py-2 px-3 d-flex justify-content-between align-items-center bg-dark flex-nowrap">
<div class="small text-muted text-truncate me-2" id="remark-carousel-caption"></div> <div class="small text-muted text-truncate me-2" id="remark-carousel-caption"></div>
<div class="d-flex align-items-center gap-2 flex-shrink-0"> <div class="d-flex align-items-center gap-2 flex-shrink-0">
{% if can_change_control %}
<button type="button" id="remark-carousel-rotate-btn" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Pivoter cette photo de 90°' %}">
<i class="bi bi-arrow-clockwise"></i> <span class="d-none d-sm-inline">{% translate "Pivoter" %}</span>
</button>
{% endif %}
<a id="remark-carousel-hd-btn" href="#" target="_blank" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Ouvrir en plein écran HD' %}"> <a id="remark-carousel-hd-btn" href="#" target="_blank" class="btn btn-sm btn-outline-light d-inline-flex align-items-center gap-1" title="{% translate 'Ouvrir en plein écran HD' %}">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span> <i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span>
</a> </a>

View file

@ -84,6 +84,9 @@
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn"> <button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %} <i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</button> </button>
<button type="button" class="btn btn-outline-light px-3" id="cameraRotateBtn" title="{% translate 'Pivoter la vue de 90°' %}">
<i class="bi bi-arrow-clockwise fs-5"></i>
</button>
<button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn"> <button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %} <i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button> </button>

View file

@ -84,6 +84,9 @@
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn"> <button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %} <i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</button> </button>
<button type="button" class="btn btn-outline-light px-3" id="cameraRotateBtn" title="{% translate 'Pivoter la vue de 90°' %}">
<i class="bi bi-arrow-clockwise fs-5"></i>
</button>
<button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn"> <button type="button" class="btn btn-primary flex-fill" id="cameraCaptureBtn">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %} <i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button> </button>

View file

@ -17,6 +17,7 @@ urlpatterns = [
path("archive_remark/<int:remark_id>/", views.archive_remark, name="archive_remark"), path("archive_remark/<int:remark_id>/", views.archive_remark, name="archive_remark"),
path("restore_remark/<int:remark_id>/", views.restore_remark, name="restore_remark"), path("restore_remark/<int:remark_id>/", views.restore_remark, name="restore_remark"),
path("remark_images/<int:image_id>/delete/", views.delete_remark_image, name="delete_remark_image"), path("remark_images/<int:image_id>/delete/", views.delete_remark_image, name="delete_remark_image"),
path("remark_images/<int:image_id>/rotate/", views.rotate_remark_image, name="rotate_remark_image"),
path("control_report/<int:control_id>/", views.control_report, name="control_report"), path("control_report/<int:control_id>/", views.control_report, name="control_report"),
path("export_pdf/<int:control_id>/", views.generate_control_pdf, name="export_pdf"), path("export_pdf/<int:control_id>/", views.generate_control_pdf, name="export_pdf"),
path("template/<int:template_id>/change/", views.change_template, name="change_template"), path("template/<int:template_id>/change/", views.change_template, name="change_template"),

View file

@ -1069,6 +1069,34 @@ def delete_remark_image(request, image_id):
return JsonResponse({'success': True}) return JsonResponse({'success': True})
@require_POST
def rotate_remark_image(request, image_id):
"""Fait pivoter une RemarkImage de 90° horaire (ou angle spécifié) et régénère sa miniature."""
image = get_object_or_404(RemarkImage, id=image_id)
remark = image.remark
if not user_can_change_control(request.user, remark.control):
return JsonResponse({"success": False, "error": _("Vous n'avez pas le droit de modifier cette remarque.")}, status=403)
try:
angle = int(request.POST.get('angle', 90))
except (ValueError, TypeError):
angle = 90
if not image.file:
return JsonResponse({"success": False, "error": _("Aucun fichier associé.")}, status=400)
success = image.rotate_image(angle=angle)
if success:
return JsonResponse({
'success': True,
'image_id': image.id,
'message': _("Image pivotée avec succès.")
})
else:
return JsonResponse({'success': False, 'error': _("Erreur lors de la rotation de l'image.")}, status=500)
@control_view_required @control_view_required
def control_report(request, control_id): def control_report(request, control_id):
""" Affiche le rapport d’un contrôle en HTML avec une carte intégrée """ """ Affiche le rapport d’un contrôle en HTML avec une carte intégrée """

View file

@ -32,7 +32,9 @@ DPI = 96
def resize_image(img_path): def resize_image(img_path):
"""Ouvre et redimensionne une image pour la table""" """Ouvre et redimensionne une image pour la table"""
try: try:
from PIL import ImageOps
with Image.open(img_path) as img: with Image.open(img_path) as img:
img = ImageOps.exif_transpose(img) or img
width_px, height_px = img.size width_px, height_px = img.size
# Calcul de l'échelle pour atteindre la hauteur cible # Calcul de l'échelle pour atteindre la hauteur cible

View file

@ -102,7 +102,9 @@ def _pdf_image_buf(path, max_px=800):
et retourne un BytesIO JPEG optimisé pour l'intégration dans le PDF. et retourne un BytesIO JPEG optimisé pour l'intégration dans le PDF.
""" """
import io import io
from PIL import ImageOps
with Image.open(path) as img: with Image.open(path) as img:
img = ImageOps.exif_transpose(img) or img
img = img.convert("RGB") img = img.convert("RGB")
if max(img.width, img.height) > max_px: if max(img.width, img.height) > max_px:
img.thumbnail((max_px, max_px), Image.LANCZOS) img.thumbnail((max_px, max_px), Image.LANCZOS)

View file

@ -633,6 +633,18 @@ class ObservationPhoto(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None is_new = self.pk is None
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.photo:
try:
from PIL import Image, ImageOps
with Image.open(self.photo.path) as img:
transposed = ImageOps.exif_transpose(img)
if transposed is not None:
if transposed.mode in ("RGBA", "P"):
transposed = transposed.convert("RGB")
transposed.save(self.photo.path, "JPEG", quality=92)
except Exception:
pass
if is_new and self.observation_id and self.photo: if is_new and self.observation_id and self.photo:
# Check if observation has an intervention # Check if observation has an intervention
obs = Observation.objects.filter(pk=self.observation_id).only('intervention_id', 'created_by_id').first() obs = Observation.objects.filter(pk=self.observation_id).only('intervention_id', 'created_by_id').first()
@ -660,26 +672,12 @@ class ObservationPhoto(models.Model):
if not self.photo or not self.photo.name: if not self.photo or not self.photo.name:
return return
try: try:
from PIL import Image, ExifTags from PIL import Image, ImageOps
from io import BytesIO from io import BytesIO
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
with Image.open(self.photo.path) as img: with Image.open(self.photo.path) as img:
# Corriger l'orientation EXIF (photos mobiles) img = ImageOps.exif_transpose(img) or img
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, Exception):
pass
# Conversion RGB si nécessaire # Conversion RGB si nécessaire
if img.mode in ("RGBA", "P"): if img.mode in ("RGBA", "P"):
img = img.convert("RGB") img = img.convert("RGB")