feat: improve photo orientation handling, EXIF transposing, continuous camera rotation, and add photo rotation tool
This commit is contained in:
parent
d5f7b533f2
commit
06b70a3129
13 changed files with 343 additions and 46 deletions
|
|
@ -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.)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue