Compare commits

..

No commits in common. "master" and "v1.1.1" have entirely different histories.

13 changed files with 42 additions and 371 deletions

View file

@ -209,7 +209,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/ # https://docs.djangoproject.com/en/2.2/topics/i18n/
TIME_ZONE = "Europe/Paris" TIME_ZONE = "UTC"
USE_TZ = True USE_TZ = True

View file

@ -45,20 +45,14 @@ class MediaAdmin(admin.ModelAdmin):
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).prefetch_related("owner", "galleries") return super().get_queryset(request).prefetch_related("owner", "galleries")
def get_date_source(self, obj):
return obj.get_date_source_display() or "-"
get_owner.admin_order_field = "owner" get_owner.admin_order_field = "owner"
get_owner.short_description = _("owner") get_owner.short_description = _("owner")
get_galleries.short_description = _("Gallery") get_galleries.short_description = _("Gallery")
get_date_source.admin_order_field = "date_source"
get_date_source.short_description = _("date source")
class PhotoAdmin(MediaAdmin): class PhotoAdmin(MediaAdmin):
list_display = ("title", "date_taken", "get_date_source", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries") list_display = ("title", "date_taken", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries")
# The capture date is editable so that a camera set to the wrong time can be readonly_fields = ("date_taken",)
# corrected; an edited date is then marked as manual and never overwritten.
model = Photo model = Photo
@ -70,7 +64,7 @@ class TagAdmin(admin.ModelAdmin):
class VideoAdmin(MediaAdmin): class VideoAdmin(MediaAdmin):
list_display = ("title", "date_taken", "get_date_source", "date_added", "is_public", "get_owner", "get_galleries") list_display = ("title", "date_added", "is_public", "get_owner", "get_galleries")
model = Video model = Video

View file

@ -43,9 +43,6 @@ class MultipleFileField(forms.FileField):
class UploadForm(forms.Form): class UploadForm(forms.Form):
file_field = MultipleFileField(label="") file_field = MultipleFileField(label="")
# File.lastModified, in milliseconds, sent by upload.js. An upload is the last
# chance to catch it: the date of the file itself does not survive HTTP.
file_modified = forms.IntegerField(required=False, widget=forms.HiddenInput)
gallery = forms.ModelChoiceField( gallery = forms.ModelChoiceField(
Gallery.objects.all(), Gallery.objects.all(),
label=_("Gallery"), label=_("Gallery"),

View file

@ -1,77 +0,0 @@
# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.management.base import BaseCommand
from photologue.models import DATE_FROM_EXIF, DATE_FROM_VIDEO, DATE_MANUAL, Photo, Video
from photologue.utils import capture_date_from_exif, capture_date_from_video
class Command(BaseCommand):
help = (
"Read the capture date of existing media from their metadata. Media whose "
"date was set by hand are never touched. By default only media without a "
"date, and media imported before dates were tracked, are read."
)
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Re-read every media, even those with a known date source.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report what would change without writing anything.",
)
def handle(self, *args, **options):
for model, reader in ((Photo, self.photo_date), (Video, self.video_date)):
queryset = model.objects.exclude(date_source=DATE_MANUAL)
if not options["force"]:
# An empty source means the media predates this field, and its date
# may have been stored in the wrong time zone.
queryset = queryset.filter(date_taken__isnull=True) | queryset.filter(date_source="")
self.process(model, queryset.distinct(), reader, options)
def process(self, model, queryset, reader, options):
name = model._meta.verbose_name_plural
found = unchanged = missing = 0
for media in queryset.iterator():
taken, source = reader(media)
if taken is None:
missing += 1
self.stdout.write(f" no date in {media.title}")
continue
if taken == media.date_taken and source == media.date_source:
unchanged += 1
continue
found += 1
self.stdout.write(f" {media.title}: {media.date_taken} -> {taken} ({source})")
if not options["dry_run"]:
media.set_capture_date(taken, source)
media.save(update_fields=["date_taken", "date_source"])
done = "would be updated" if options["dry_run"] else "updated"
self.stdout.write(
self.style.SUCCESS(f"{name}: {found} {done}, {unchanged} already correct, {missing} without metadata")
)
@staticmethod
def photo_date(photo):
if not photo.image:
return None, None
try:
exif_date = photo.exif(photo.image.file).get("EXIF DateTimeOriginal", None)
except Exception:
return None, None
if exif_date is None:
return None, None
return capture_date_from_exif(exif_date.values), DATE_FROM_EXIF
@staticmethod
def video_date(video):
if not video.file:
return None, None
return capture_date_from_video(video.file.path), DATE_FROM_VIDEO

View file

@ -1,33 +0,0 @@
# Generated by Django 6.0.4 on 2026-09-24 09:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('photologue', '0010_video'),
]
operations = [
migrations.AddField(
model_name='photo',
name='date_source',
field=models.CharField(blank=True, choices=[('exif', 'EXIF metadata'), ('video', 'video metadata'), ('file', 'file date at upload'), ('manual', 'set by hand')], editable=False, help_text='Where the capture date comes from.', max_length=10, verbose_name='date source'),
),
migrations.AddField(
model_name='video',
name='date_source',
field=models.CharField(blank=True, choices=[('exif', 'EXIF metadata'), ('video', 'video metadata'), ('file', 'file date at upload'), ('manual', 'set by hand')], editable=False, help_text='Where the capture date comes from.', max_length=10, verbose_name='date source'),
),
migrations.AddField(
model_name='video',
name='date_taken',
field=models.DateTimeField(blank=True, help_text='Date the media was captured; read from its metadata.', null=True, verbose_name='date taken'),
),
migrations.AlterField(
model_name='photo',
name='date_taken',
field=models.DateTimeField(blank=True, help_text='Date the media was captured; read from its metadata.', null=True, verbose_name='date taken'),
),
]

View file

@ -4,6 +4,7 @@
import logging import logging
import os import os
import unicodedata import unicodedata
from datetime import datetime
from functools import partial from functools import partial
from importlib import import_module from importlib import import_module
from inspect import isclass from inspect import isclass
@ -38,19 +39,6 @@ LATEST_LIMIT = getattr(settings, "PHOTOLOGUE_GALLERY_LATEST_LIMIT", None)
# max_length setting for the ImageModel ImageField # max_length setting for the ImageModel ImageField
IMAGE_FIELD_MAX_LENGTH = getattr(settings, "PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH", 100) IMAGE_FIELD_MAX_LENGTH = getattr(settings, "PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH", 100)
# Where a capture date comes from, from the most to the least trustworthy. An
# empty value means the date was never determined.
DATE_FROM_EXIF = "exif"
DATE_FROM_VIDEO = "video"
DATE_FROM_FILE = "file"
DATE_MANUAL = "manual"
DATE_SOURCE_CHOICES = (
(DATE_FROM_EXIF, _("EXIF metadata")),
(DATE_FROM_VIDEO, _("video metadata")),
(DATE_FROM_FILE, _("file date at upload")),
(DATE_MANUAL, _("set by hand")),
)
# Modify image file buffer size. # Modify image file buffer size.
ImageFile.MAXBLOCK = getattr(settings, "PHOTOLOGUE_MAXBLOCK", 256 * 2**10) ImageFile.MAXBLOCK = getattr(settings, "PHOTOLOGUE_MAXBLOCK", 256 * 2**10)
@ -277,20 +265,6 @@ class MediaModel(models.Model):
) )
thumbnail_width = models.PositiveIntegerField(editable=False, null=True) thumbnail_width = models.PositiveIntegerField(editable=False, null=True)
thumbnail_height = models.PositiveIntegerField(editable=False, null=True) thumbnail_height = models.PositiveIntegerField(editable=False, null=True)
date_taken = models.DateTimeField(
_("date taken"),
null=True,
blank=True,
help_text=_("Date the media was captured; read from its metadata."),
)
date_source = models.CharField(
_("date source"),
max_length=10,
blank=True,
editable=False,
choices=DATE_SOURCE_CHOICES,
help_text=_("Where the capture date comes from."),
)
is_video = False is_video = False
@ -314,32 +288,9 @@ class MediaModel(models.Model):
def get_download_url(self): def get_download_url(self):
raise NotImplementedError raise NotImplementedError
@property
def date_is_approximate(self):
"""True when the date was deduced rather than read from the capture itself."""
return self.date_source == DATE_FROM_FILE
def set_capture_date(self, value, source):
"""Record a capture date read from the file, and where it comes from."""
self.date_taken = value
self.date_source = source
self._date_read_from_file = True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.title) self.slug = slugify(self.title)
if self.pk and not getattr(self, "_date_read_from_file", False):
# A date changed by anything but our own readers was set by a human,
# and must survive a later re-read of the metadata.
stored = (
type(self)
.objects.filter(pk=self.pk)
.values_list("date_taken", flat=True)
.first()
)
if stored != self.date_taken:
self.date_source = DATE_MANUAL
self._date_read_from_file = False
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -351,6 +302,12 @@ class Photo(MediaModel):
width_field="thumbnail_width", width_field="thumbnail_width",
height_field="thumbnail_height", height_field="thumbnail_height",
) )
date_taken = models.DateTimeField(
_("date taken"),
null=True,
blank=True,
help_text=_("Date image was taken; is obtained from the image EXIF data."),
)
view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False) view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False)
crop_from = models.CharField( crop_from = models.CharField(
_("crop from"), _("crop from"),
@ -620,15 +577,21 @@ class Photo(MediaModel):
if (self.date_taken is None or image_has_changed) and self.image: if (self.date_taken is None or image_has_changed) and self.image:
# Attempt to get the date the photo was taken from the EXIF data. # Attempt to get the date the photo was taken from the EXIF data.
try: try:
from .utils import capture_date_from_exif
exif_date = self.exif(self.image.file).get( exif_date = self.exif(self.image.file).get(
"EXIF DateTimeOriginal", None "EXIF DateTimeOriginal", None
) )
if exif_date is not None: if exif_date is not None:
taken = capture_date_from_exif(exif_date.values) d, t = exif_date.values.split()
if taken is not None: year, month, day = d.split(":")
self.set_capture_date(taken, DATE_FROM_EXIF) hour, minute, second = t.split(":")
self.date_taken = datetime(
int(year),
int(month),
int(day),
int(hour),
int(minute),
int(second),
)
except Exception: except Exception:
logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True) logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True)
# If crop_from property has been changed on existing image, # If crop_from property has been changed on existing image,

View file

@ -5,7 +5,7 @@
// Init gallery // Init gallery
const lgInstance = lightGallery(document.getElementById('lightgallery'), { lightGallery(document.getElementById('lightgallery'), {
selector: 'a.photo-item', selector: 'a.photo-item',
plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom, lgVideo], plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom, lgVideo],
autoplayFirstVideo: true, autoplayFirstVideo: true,
@ -26,41 +26,6 @@ const lgInstance = lightGallery(document.getElementById('lightgallery'), {
//Add download warning code //Add download warning code
const lgContainer = document.getElementById('lightgallery'); const lgContainer = document.getElementById('lightgallery');
// Change photo with a two-finger horizontal swipe on a trackpad. Without this the
// browser reads the gesture as "go back" and leaves the gallery.
const SWIPE_DISTANCE = 80; // horizontal pixels before switching photo
const SWIPE_COOLDOWN = 400; // ms, so the inertia of one swipe moves one photo
const SWIPE_GAP = 200; // ms of quiet that starts a new gesture
let swipeOuter = null;
let swipeAmount = 0;
let swipeLastWheel = 0;
let swipeLastMove = 0;
function onTrackpadSwipe(e) {
// Leave vertical scrolling, and zoomed in photos, alone.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
e.preventDefault();
if (swipeOuter.classList.contains('lg-zoomed')) return;
const now = performance.now();
if (now - swipeLastWheel > SWIPE_GAP) swipeAmount = 0;
swipeLastWheel = now;
if (now - swipeLastMove < SWIPE_COOLDOWN) return;
swipeAmount += e.deltaX;
if (Math.abs(swipeAmount) < SWIPE_DISTANCE) return;
if (swipeAmount > 0) lgInstance.goToNextSlide();
else lgInstance.goToPrevSlide();
swipeAmount = 0;
swipeLastMove = now;
}
lgContainer.addEventListener('lgAfterOpen', () => {
swipeOuter = document.querySelector('.lg-outer');
swipeAmount = 0;
swipeOuter.addEventListener('wheel', onTrackpadSwipe, { passive: false });
});
// On attend que la galerie soit ouverte pour cibler le bouton // On attend que la galerie soit ouverte pour cibler le bouton
lgContainer.addEventListener('lgAfterOpen', () => { lgContainer.addEventListener('lgAfterOpen', () => {
const downloadBtn = document.querySelector('.lg-download'); const downloadBtn = document.querySelector('.lg-download');

View file

@ -3,25 +3,6 @@
(function () { (function () {
const SPACING = 3; const SPACING = 3;
const PADDING = 3; const PADDING = 3;
// Photos that all share the same aspect ratio give rows of identical height,
// which reads as a contact sheet. Some photos are therefore displayed with a
// slightly different ratio than their own: `object-fit: cover` trims the
// edges, and the justified rows become irregular on their own.
const CROP_STEPS = [0.8, 1, 1, 1.25]; // display ratio multipliers, drawn per photo
// id -> multiplier, drawn once per page load and kept on resize (and when a
// photo is deleted) so the gallery does not reshuffle while it is looked at.
const cropPlan = new Map();
function displayRatio(item, index) {
const w = parseInt(item.dataset.width) || 1;
const h = parseInt(item.dataset.height) || 1;
const id = item.dataset.slideName || 'i' + index;
if (!cropPlan.has(id)) {
cropPlan.set(id, CROP_STEPS[Math.floor(Math.random() * CROP_STEPS.length)]);
}
return (w / h) * cropPlan.get(id);
}
function applyLayout() { function applyLayout() {
const container = document.getElementById('lightgallery'); const container = document.getElementById('lightgallery');
@ -32,7 +13,11 @@
const containerWidth = container.offsetWidth - PADDING * 2; const containerWidth = container.offsetWidth - PADDING * 2;
const TARGET_HEIGHT = Math.min(220, Math.floor(containerWidth * 0.4)); const TARGET_HEIGHT = Math.min(220, Math.floor(containerWidth * 0.4));
const ratios = items.map(displayRatio); const ratios = items.map(a => {
const w = parseInt(a.dataset.width) || 1;
const h = parseInt(a.dataset.height) || 1;
return w / h;
});
// Group items into rows // Group items into rows
const rows = []; const rows = [];
@ -80,6 +65,8 @@
container.style.height = (top - SPACING + PADDING) + 'px'; container.style.height = (top - SPACING + PADDING) + 'px';
items.forEach(item => { items.forEach(item => {
item.style.visibility = 'visible'; item.style.visibility = 'visible';
const img = item.querySelector('img[data-lazy]');
if (img) img.src = img.dataset.lazy;
}); });
} }

View file

@ -99,8 +99,6 @@ async function uploadFile(file, galleryID, csrfvalue, ownerID) {
const sendform = new FormData(); const sendform = new FormData();
sendform.append('csrfmiddlewaretoken', csrfvalue); sendform.append('csrfmiddlewaretoken', csrfvalue);
sendform.append('file_field', file); sendform.append('file_field', file);
// The capture date is often missing from the metadata but present here.
if (file.lastModified) sendform.append('file_modified', file.lastModified);
sendform.append('gallery', galleryID); sendform.append('gallery', galleryID);
if (ownerID) sendform.append('owner', ownerID); if (ownerID) sendform.append('owner', ownerID);
const start = performance.now(); const start = performance.now();

View file

@ -112,9 +112,9 @@ SPDX-License-Identifier: GPL-3.0-or-later
data-uncensor-url="{% url 'photologue:pl-media-uncensor' item.model_name item.id %}" data-uncensor-url="{% url 'photologue:pl-media-uncensor' item.model_name item.id %}"
data-width="{{ item.thumbnail_width|default:1 }}" data-width="{{ item.thumbnail_width|default:1 }}"
data-height="{{ item.thumbnail_height|default:1 }}"> data-height="{{ item.thumbnail_height|default:1 }}">
<img src="{{ item.get_thumbnail_url }}" loading="lazy" decoding="async" <img src="{{ item.get_thumbnail_url }}" data-lazy="{{ item.get_thumbnail_url }}"
class="{% if not item.is_public %}photo-private{% endif %}" class="{% if not item.is_public %}photo-private{% endif %}"
alt="{{ item.title }}{% if item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}"> alt="{{ item.title }}{% if not item.is_video and item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %} - {{ item.owner.first_name|default:item.owner.username }}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}">
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -12,10 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="row"> <div class="row">
<div class="col-lg-12"> <div class="col-lg-12">
<h1>{{ object.title }}</h1> <h1>{{ object.title }}</h1>
<p class="text-muted small"> <p class="text-muted small">{% trans "Published" %} {{ object.date_added }}</p>
{% if object.date_taken %}{% trans "Taken" %} {{ object.date_taken }}{% if object.date_is_approximate %} ({% trans "approximate date" %}){% endif %} &middot; {% endif %}
{% trans "Published" %} {{ object.date_added }}
</p>
</div> </div>
</div> </div>
{% if object.caption %}<p>{{ object.caption }}</p>{% endif %} {% if object.caption %}<p>{{ object.caption }}</p>{% endif %}

View file

@ -5,12 +5,7 @@
import logging import logging
import os import os
from datetime import datetime, timezone as dt_timezone
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from io import BytesIO from io import BytesIO
from PIL import Image from PIL import Image
import av import av
@ -66,89 +61,3 @@ def generate_video_thumbnail(video):
video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True) video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True)
except Exception: except Exception:
logger.error("Failed to save video thumbnail", exc_info=True) logger.error("Failed to save video thumbnail", exc_info=True)
def capture_date_from_exif(exif_value):
"""Turn an EXIF "YYYY:MM:DD HH:MM:SS" value into an aware datetime.
EXIF dates carry no time zone: they are local time as read by the camera, so
they are interpreted in the site time zone.
"""
try:
day, hour = str(exif_value).split()
year, month, dayofmonth = (int(part) for part in day.split(":"))
hours, minutes, seconds = (int(part) for part in hour.split(":"))
naive = datetime(year, month, dayofmonth, hours, minutes, seconds)
except (AttributeError, TypeError, ValueError):
logger.error("Unreadable EXIF date %r", exif_value)
return None
return timezone.make_aware(naive) if settings.USE_TZ else naive
def capture_date_from_video(path):
"""Read the capture date from a video container, or None."""
try:
container = av.open(path)
raw = container.metadata.get("creation_time")
container.close()
except Exception:
logger.error("Failed to read video metadata from %s", path, exc_info=True)
return None
if not raw:
return None
parsed = parse_datetime(raw)
if parsed is None:
return None
if settings.USE_TZ and timezone.is_naive(parsed):
# Containers usually store UTC; a naive value is the camera's local time.
parsed = timezone.make_aware(parsed)
return parsed
def capture_date_from_upload(last_modified_ms):
"""Turn a browser File.lastModified value (ms since epoch) into a datetime."""
try:
stamp = int(last_modified_ms) / 1000
except (TypeError, ValueError):
return None
if not stamp:
return None
return datetime.fromtimestamp(stamp, tz=dt_timezone.utc)
def order_media_by_capture(items):
"""Sort media in capture order, and fill in the dates that are missing.
A file name is a reliable chronological clue inside one photographer's batch
(most cameras number their files in order) and meaningless between two of
them. So media without a capture date borrow the date of the closest dated
file of the same owner, which puts them back in the middle of the sequence
instead of exiling them at the end of the gallery. Only media whose owner has
no dated file at all end up at the end, in file name order.
Every item is annotated with `capture_date` and `capture_date_is_guessed`.
"""
by_owner = {}
for item in items:
item.capture_date = item.date_taken
item.capture_date_is_guessed = False
by_owner.setdefault(item.owner_id, []).append(item)
dated, undated = [], []
for batch in by_owner.values():
batch.sort(key=lambda item: (item.title, item.pk))
# Forwards, then backwards for the files that come before the first date.
for sequence in (batch, batch[::-1]):
known = None
for item in sequence:
if item.capture_date is not None:
known = item.capture_date
elif known is not None:
item.capture_date = known
item.capture_date_is_guessed = True
for item in batch:
(dated if item.capture_date is not None else undated).append(item)
dated.sort(key=lambda item: (item.capture_date, item.owner_id, item.title, item.pk))
undated.sort(key=lambda item: (item.owner_id, item.title, item.pk))
return dated + undated

View file

@ -26,15 +26,8 @@ from django.contrib.auth import get_user_model
from django.db.models import F, Q from django.db.models import F, Q
from .forms import UploadForm from .forms import UploadForm
from .models import DATE_FROM_FILE, DATE_FROM_VIDEO, Gallery, Photo, Tag, Video from .models import Gallery, Photo, Tag, Video
from .utils import ( from .utils import generate_video_thumbnail, is_photo, is_video
capture_date_from_upload,
capture_date_from_video,
generate_video_thumbnail,
is_photo,
is_video,
order_media_by_capture,
)
MEDIA_MODELS = {"photo": Photo, "video": Video} MEDIA_MODELS = {"photo": Photo, "video": Video}
@ -236,9 +229,9 @@ class GalleryDetailView(DetailView):
context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"]) context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"])
context["videos"] = context["videos"].filter(owner__id=self.kwargs["owner"]) context["videos"] = context["videos"].filter(owner__id=self.kwargs["owner"])
# Combine photos and videos into a single list, in capture order # Combine photos and videos into a single sorted list for the template
context["media_items"] = order_media_by_capture( context["media_items"] = sorted(
[*context["photos"], *context["videos"]] [*context["photos"], *context["videos"]], key=lambda x: x.title
) )
# Increment the photo view count # Increment the photo view count
@ -311,25 +304,7 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
kwargs["is_staff"] = self.request.user.is_staff kwargs["is_staff"] = self.request.user.is_staff
return kwargs return kwargs
@staticmethod def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, owner, post_save=None):
def _complete_capture_date(obj, modified_ms):
"""
Fill in the capture date that the file itself did not carry: photos get
theirs from EXIF when saved, videos from their container, and anything
still undated falls back on the file date sent by the browser.
"""
taken = source = None
if obj.date_taken is None and obj.is_video:
taken, source = capture_date_from_video(obj.file_path), DATE_FROM_VIDEO
if taken is None and obj.date_taken is None and modified_ms:
taken, source = capture_date_from_upload(modified_ms), DATE_FROM_FILE
if taken is None:
return
obj.set_capture_date(taken, source)
obj.save(update_fields=["date_taken", "date_source"])
def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, owner,
post_save=None, modified_ms=None):
""" """
Create a media object, save it to the DB, schedule file save on commit. Create a media object, save it to the DB, schedule file save on commit.
Returns True if uploaded, False if already exists. Returns True if uploaded, False if already exists.
@ -342,12 +317,11 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
with transaction.atomic(): with transaction.atomic():
obj.save() obj.save()
obj.galleries.set([gallery]) obj.galleries.set([gallery])
def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save, ms=modified_ms): def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save):
f.seek(0) f.seek(0)
getattr(o, ff).save(fp, f) getattr(o, ff).save(fp, f)
if ps: if ps:
ps(o) ps(o)
self._complete_capture_date(o, ms)
transaction.on_commit(_save) transaction.on_commit(_save)
return True return True
@ -385,14 +359,11 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
uploaded_photo_name = [] uploaded_photo_name = []
already_exists = 0 already_exists = 0
files = form.cleaned_data["file_field"] files = form.cleaned_data["file_field"]
modified_ms = form.cleaned_data.get("file_modified")
for photo_file in files: for photo_file in files:
if is_photo(photo_file): if is_photo(photo_file):
uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir, owner, uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir, owner)
modified_ms=modified_ms)
elif is_video(photo_file): elif is_video(photo_file):
uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, owner, uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, owner, post_save=generate_video_thumbnail)
post_save=generate_video_thumbnail, modified_ms=modified_ms)
else: else:
messages.error(self.request, f"{photo_file.name} is not a recognized image or video") messages.error(self.request, f"{photo_file.name} is not a recognized image or video")
jsondata["code"] = 400 jsondata["code"] = 400