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
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.)

View file

@ -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;
}

View file

@ -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 = '<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
document.addEventListener('DOMContentLoaded', function() {
const modalEl = document.getElementById('remarkPhotoCarouselModal');

View file

@ -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) {

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 %}"
onclick="openRemarkPhotoCarousel(this)">
{% 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"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
@ -480,6 +487,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}"
onclick="openRemarkPhotoCarousel(this)">
{% 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"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
@ -688,6 +702,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})"
onclick="openRemarkPhotoCarousel(this)">
{% 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"
class="btn btn-sm btn-danger remark-image-delete"
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="small text-muted text-truncate me-2" id="remark-carousel-caption"></div>
<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' %}">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span>
</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 %}"
onclick="openRemarkPhotoCarousel(this)">
{% 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"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
@ -526,6 +533,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }}"
onclick="openRemarkPhotoCarousel(this)">
{% 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"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
@ -728,6 +742,13 @@
data-caption="{% translate 'Remarque' %} #{{ remark.remark_number }} ({% translate 'Archivée' %})"
onclick="openRemarkPhotoCarousel(this)">
{% 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"
class="btn btn-sm btn-danger remark-image-delete"
data-image-id="{{ image.id }}"
@ -830,6 +851,9 @@
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</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">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</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="small text-muted text-truncate me-2" id="remark-carousel-caption"></div>
<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' %}">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">{% translate "Plein écran" %}</span>
</a>

View file

@ -84,6 +84,9 @@
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</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">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button>

View file

@ -84,6 +84,9 @@
<button type="button" class="btn btn-outline-light flex-fill" id="cameraStopBtn">
<i class="bi bi-x-circle"></i> {% translate "Terminer" %}
</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">
<i class="bi bi-camera-fill"></i> {% translate "Capturer" %}
</button>

View file

@ -17,6 +17,7 @@ urlpatterns = [
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("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("export_pdf/<int:control_id>/", views.generate_control_pdf, name="export_pdf"),
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})
@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
def control_report(request, control_id):
""" 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):
"""Ouvre et redimensionne une image pour la table"""
try:
from PIL import ImageOps
with Image.open(img_path) as img:
img = ImageOps.exif_transpose(img) or img
width_px, height_px = img.size
# 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.
"""
import io
from PIL import ImageOps
with Image.open(path) as img:
img = ImageOps.exif_transpose(img) or img
img = img.convert("RGB")
if max(img.width, img.height) > max_px:
img.thumbnail((max_px, max_px), Image.LANCZOS)

View file

@ -633,6 +633,18 @@ class ObservationPhoto(models.Model):
def save(self, *args, **kwargs):
is_new = self.pk is None
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:
# Check if observation has an intervention
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:
return
try:
from PIL import Image, ExifTags
from PIL import Image, ImageOps
from io import BytesIO
from django.core.files.base import ContentFile
with Image.open(self.photo.path) as img:
# Corriger l'orientation EXIF (photos mobiles)
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
img = ImageOps.exif_transpose(img) or img
# Conversion RGB si nécessaire
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")