Add video support with unified media display.
All checks were successful
Docker / build (release) Successful in 9s

This commit is contained in:
krek0 2026-05-16 15:13:14 +02:00
parent a634cc88bd
commit f4052a3d99
16 changed files with 700 additions and 224 deletions

View file

@ -68,6 +68,20 @@ SPDX-License-Identifier: GPL-3.0-or-later
outline-offset: -5px; outline-offset: -5px;
} }
/* Play button overlay for video thumbnails in the grid */
.photo-item[data-video]::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='white' d='M6.79 5.093A.5.5 0 0 0 6 5.5v5a.5.5 0 0 0 .79.407l3.5-2.5a.5.5 0 0 0 0-.814l-3.5-2.5z'/%3E%3C/svg%3E") center/32px no-repeat;
pointer-events: none;
}
/* Language selector */ /* Language selector */
.lang-select { .lang-select {
border: none; border: none;

View file

@ -9,7 +9,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.views.generic import ListView, View from django.views.generic import ListView, View
from photologue.models import Gallery, Photo from photologue.models import Gallery, Photo, Video
class MediaAccess(View): class MediaAccess(View):
@ -29,6 +29,12 @@ class MediaAccess(View):
image__startswith=original_dir + '/', image__startswith=original_dir + '/',
galleries__is_public=True, galleries__is_public=True,
).exists() ).exists()
# Video files and their thumbnails
if not allowed:
allowed = (
Video.objects.filter(file=path, galleries__is_public=True).exists()
or Video.objects.filter(thumbnail=path, galleries__is_public=True).exists()
)
except Exception: except Exception:
return redirect_to_login(request.get_full_path()) return redirect_to_login(request.get_full_path())
if not allowed: if not allowed:

View file

@ -5,7 +5,7 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Gallery, Photo, Tag from .models import Gallery, Photo, Tag, Video
class GalleryAdmin(admin.ModelAdmin): class GalleryAdmin(admin.ModelAdmin):
@ -29,40 +29,31 @@ class GalleryAdmin(admin.ModelAdmin):
class PhotoAdmin(admin.ModelAdmin): class MediaAdmin(admin.ModelAdmin):
list_display = ( """Shared admin base for Photo and Video."""
"title", list_filter = ["date_added", "is_public", "owner", "galleries"]
"date_taken",
"date_added",
"is_public",
"view_count",
"admin_thumbnail",
"get_owner",
"get_galleries"
)
list_filter = ["date_added", "is_public", "owner","galleries"]
search_fields = ["title", "slug", "caption"] search_fields = ["title", "slug", "caption"]
list_per_page = 25 list_per_page = 25
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
readonly_fields = ("date_taken",)
model = Photo
def get_owner(self, obj): def get_owner(self, obj):
return obj.owner.username return obj.owner.username
def get_queryset(self, request):
# Précharge les objets 'galleries' en une seule requête supplémentaire
return super().get_queryset(request).prefetch_related("owner",'galleries')
def get_galleries(self, obj): def get_galleries(self, obj):
return ", ".join([g.title for g in obj.galleries.all()])## get all linked galeries return ", ".join([g.title for g in obj.galleries.all()])
get_galleries.short_description = _("Gallery") def get_queryset(self, request):
get_galleries.admin_order_field = 'galleries__title' return super().get_queryset(request).prefetch_related("owner", "galleries")
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")
class PhotoAdmin(MediaAdmin):
list_display = ("title", "date_taken", "date_added", "is_public", "view_count", "admin_thumbnail", "get_owner", "get_galleries")
readonly_fields = ("date_taken",)
model = Photo
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):
@ -72,6 +63,12 @@ class TagAdmin(admin.ModelAdmin):
model = Tag model = Tag
class VideoAdmin(MediaAdmin):
list_display = ("title", "date_added", "is_public", "get_owner", "get_galleries")
model = Video
admin.site.register(Gallery, GalleryAdmin) admin.site.register(Gallery, GalleryAdmin)
admin.site.register(Photo, PhotoAdmin) admin.site.register(Photo, PhotoAdmin)
admin.site.register(Video, VideoAdmin)
admin.site.register(Tag, TagAdmin) admin.site.register(Tag, TagAdmin)

View file

@ -19,20 +19,12 @@ class MultipleFileInput(forms.ClearableFileInput):
class MultipleFileField(forms.FileField): class MultipleFileField(forms.FileField):
allowed_extensions = [
"jpg",
"jpeg",
"png",
"gif",
"tiff",
] # Specify allowed extensions here
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
kwargs.setdefault( kwargs.setdefault(
"widget", "widget",
MultipleFileInput( MultipleFileInput(
attrs={ attrs={
"accept": "image/*", "accept": "image/*,video/*",
"class": "mb-3", "class": "mb-3",
} }
), ),
@ -41,25 +33,9 @@ class MultipleFileField(forms.FileField):
def clean(self, data, initial=None): def clean(self, data, initial=None):
single_file_clean = super().clean single_file_clean = super().clean
if isinstance(data, (list, tuple)): if isinstance(data, (list, tuple)):
result = [self.validate_file(d, single_file_clean, initial) for d in data] return [single_file_clean(d, initial) for d in data]
else: return single_file_clean(data, initial)
result = self.validate_file(data, single_file_clean, initial)
return result
def validate_file(self, file, single_file_clean, initial):
# Perform the default clean
cleaned_file = single_file_clean(file, initial)
# Check the file extension
extension = file.name.split(".")[-1].lower()
if extension not in self.allowed_extensions:
raise forms.ValidationError(
f"{file.name} has an invalid file extension. "
f"Allowed extensions are: {', '.join(self.allowed_extensions)}"
)
return cleaned_file
class UploadForm(forms.Form): class UploadForm(forms.Form):

View file

@ -0,0 +1,83 @@
import django.db.models.deletion
import django.utils.timezone
import photologue.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('photologue', '0009_alter_photo_options_gallery_public_token'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# Rename Photo dimensions fields to shared names
migrations.RenameField(
model_name='photo',
old_name='image_width',
new_name='thumbnail_width',
),
migrations.RenameField(
model_name='photo',
old_name='image_height',
new_name='thumbnail_height',
),
# State-only: update width_field/height_field references, no DB change needed
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AlterField(
model_name='photo',
name='image',
field=models.ImageField(
height_field='thumbnail_height',
max_length=100,
upload_to=photologue.models.get_storage_path,
verbose_name='image',
width_field='thumbnail_width',
),
),
],
database_operations=[],
),
# Restore Photo permissions dropped by 0009
migrations.AlterModelOptions(
name='photo',
options={
'get_latest_by': 'date_added',
'ordering': ['title'],
'permissions': [('can_resolve_censorship', 'Can resolve censorship (restore private photos)')],
'verbose_name': 'photo',
'verbose_name_plural': 'photos',
},
),
migrations.CreateModel(
name='Video',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=250, verbose_name='title')),
('slug', models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='slug')),
('caption', models.TextField(blank=True, verbose_name='caption')),
('date_added', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date added')),
('is_public', models.BooleanField(default=True, help_text='Public photographs will be displayed in the default views.', verbose_name='is public')),
('thumbnail_width', models.PositiveIntegerField(editable=False, null=True)),
('thumbnail_height', models.PositiveIntegerField(editable=False, null=True)),
('file', models.FileField(max_length=200, upload_to=photologue.models.get_video_storage_path, verbose_name='video file')),
('thumbnail', models.ImageField(blank=True, height_field='thumbnail_height', max_length=200, upload_to=photologue.models.get_video_storage_path, verbose_name='thumbnail', width_field='thumbnail_width')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='owner')),
],
options={
'verbose_name': 'video',
'verbose_name_plural': 'videos',
'ordering': ['title'],
'get_latest_by': 'date_added',
},
),
migrations.AddField(
model_name='gallery',
name='videos',
field=models.ManyToManyField(blank=True, related_name='galleries', to='photologue.Video', verbose_name='videos'),
),
]

View file

@ -63,6 +63,15 @@ else:
return os.path.join("photos", fn) return os.path.join("photos", fn)
def get_video_storage_path(instance, filename):
fn = (
unicodedata.normalize("NFKD", force_str(filename))
.encode("ascii", "ignore")
.decode("ascii")
)
return os.path.join("videos", fn)
# Exif Orientation values # Exif Orientation values
# Value 0thRow 0thColumn # Value 0thRow 0thColumn
# 1 top left # 1 top left
@ -174,6 +183,12 @@ class Gallery(models.Model):
verbose_name=_("photos"), verbose_name=_("photos"),
blank=True, blank=True,
) )
videos = models.ManyToManyField(
"photologue.Video",
related_name="galleries",
verbose_name=_("videos"),
blank=True,
)
is_public = models.BooleanField( is_public = models.BooleanField(
_("is public"), _("is public"),
default=False, default=False,
@ -216,20 +231,77 @@ class Gallery(models.Model):
"""Return a count of private photos in this gallery.""" """Return a count of private photos in this gallery."""
return self.photos.filter(is_public=False).count() return self.photos.filter(is_public=False).count()
def media_count(self, public=True):
"""Return a count of all photos and videos in this gallery."""
return self.photo_count(public) + (
self.videos.filter(is_public=True).count() if public else self.videos.count()
)
photo_count.short_description = _("count") photo_count.short_description = _("count")
photo_private_count.short_description = _("private count") photo_private_count.short_description = _("private count")
class ImageModel(models.Model): class MediaModel(models.Model):
"""Abstract base model with fields shared between Photo and Video."""
title = models.CharField(_("title"), max_length=250)
slug = models.SlugField(
_("slug"),
unique=True,
max_length=250,
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
)
caption = models.TextField(_("caption"), blank=True)
date_added = models.DateTimeField(_("date added"), default=now)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("owner"),
)
is_public = models.BooleanField(
_("is public"),
default=True,
help_text=_("Public photographs will be displayed in the default views."),
)
thumbnail_width = models.PositiveIntegerField(editable=False, null=True)
thumbnail_height = models.PositiveIntegerField(editable=False, null=True)
is_video = False
class Meta:
abstract = True
def __str__(self):
return self.title
@property
def model_name(self):
return self._meta.model_name
@property
def file_path(self):
raise NotImplementedError
def get_admin_url(self):
return reverse(f"admin:photologue_{self._meta.model_name}_change", args=[self.pk])
def get_download_url(self):
raise NotImplementedError
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
class Photo(MediaModel):
image = models.ImageField( image = models.ImageField(
_("image"), _("image"),
max_length=IMAGE_FIELD_MAX_LENGTH, max_length=IMAGE_FIELD_MAX_LENGTH,
upload_to=get_storage_path, upload_to=get_storage_path,
width_field="image_width", width_field="thumbnail_width",
height_field="image_height", height_field="thumbnail_height",
) )
image_width = models.PositiveIntegerField(editable=False, null=True)
image_height = models.PositiveIntegerField(editable=False, null=True)
date_taken = models.DateTimeField( date_taken = models.DateTimeField(
_("date taken"), _("date taken"),
null=True, null=True,
@ -244,9 +316,24 @@ class ImageModel(models.Model):
default="center", default="center",
choices=CROP_ANCHOR_CHOICES, choices=CROP_ANCHOR_CHOICES,
) )
license = models.CharField(
max_length=255,
blank=True,
verbose_name=_("license"),
)
is_video = False # class attribute used in templates to distinguish media types
class Meta: class Meta:
abstract = True # We do not have a reliable date for ordering, so let's use
# the title which is incremented by most cameras
ordering = ["title"]
get_latest_by = "date_added"
verbose_name = _("photo")
verbose_name_plural = _("photos")
permissions = [
("can_resolve_censorship", "Can resolve censorship (restore private photos)"),
]
def exif(self, file=None): def exif(self, file=None):
try: try:
@ -505,6 +592,11 @@ class ImageModel(models.Model):
) )
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,
# force image recreation
current = Photo.objects.get(pk=self.pk) if self.pk else None
if current and (current.crop_from != self.crop_from):
recreate = True
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.pre_cache(recreate) self.pre_cache(recreate)
@ -524,61 +616,16 @@ class ImageModel(models.Model):
super().delete() super().delete()
self.image.storage.delete(self.image.name) self.image.storage.delete(self.image.name)
class Photo(ImageModel):
title = models.CharField(_("title"), max_length=250)
slug = models.SlugField(
_("slug"),
unique=True,
max_length=250,
help_text=_('A "slug" is a unique URL-friendly title for an object.'),
)
caption = models.TextField(_("caption"), blank=True)
date_added = models.DateTimeField(_("date added"), default=now)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("owner"),
)
license = models.CharField(
max_length=255,
blank=True,
verbose_name=_("license"),
)
is_public = models.BooleanField(
_("is public"),
default=True,
help_text=_("Public photographs will be displayed in the default views."),
)
class Meta:
# We do not have a reliable date for ordering, so let's use
# the title which is incremented by most cameras
ordering = ["title"]
get_latest_by = "date_added"
verbose_name = _("photo")
verbose_name_plural = _("photos")
permissions = [
("can_resolve_censorship", "Can resolve censorship (restore private photos)"),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
# If crop_from property has been changed on existing image,
# update kwargs to force image recreation in parent class
current = Photo.objects.get(pk=self.pk) if self.pk else None
if current and (current.crop_from != self.crop_from):
kwargs.update(recreate=True)
if self.slug is None:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("photologue:pl-photo", args=[self.pk]) return reverse("photologue:pl-photo", args=[self.pk])
def get_download_url(self):
return self.image.url
@property
def file_path(self):
return self.image.path
def public_galleries(self): def public_galleries(self):
"""Return the public galleries to which this photo belongs.""" """Return the public galleries to which this photo belongs."""
return self.galleries return self.galleries
@ -699,11 +746,10 @@ class PhotoSize(models.Model):
return self.name return self.name
def clear_cache(self): def clear_cache(self):
for cls in ImageModel.__subclasses__(): for obj in Photo.objects.all():
for obj in cls.objects.all(): obj.remove_size(self)
obj.remove_size(self) if self.pre_cache:
if self.pre_cache: obj.create_size(self)
obj.create_size(self)
PhotoSizeCache().reset() PhotoSizeCache().reset()
def clean(self): def clean(self):
@ -782,6 +828,67 @@ def init_size_method_map():
} }
class Video(MediaModel):
file = models.FileField(
_("video file"),
max_length=200,
upload_to=get_video_storage_path,
)
thumbnail = models.ImageField(
_("thumbnail"),
max_length=200,
upload_to=get_video_storage_path,
blank=True,
width_field="thumbnail_width",
height_field="thumbnail_height",
)
is_video = True # class attribute used in templates to distinguish media types
class Meta:
ordering = ["title"]
get_latest_by = "date_added"
verbose_name = _("video")
verbose_name_plural = _("videos")
def get_thumbnail_url(self):
missing = not self.thumbnail or not os.path.exists(self.thumbnail.path)
if missing and self.file:
from .utils import generate_video_thumbnail
generate_video_thumbnail(self)
return self.thumbnail.url if self.thumbnail else ''
def get_display_url(self):
return self.file.url
def get_download_url(self):
return self.file.url
@property
def file_path(self):
return self.file.path
def get_mime_type(self):
import mimetypes
mime, _ = mimetypes.guess_type(self.file.name)
return mime or 'video/mp4'
def delete(self):
assert (
self._get_pk_val() is not None
), "%s object can't be deleted because its %s attribute is set to None." % (
self._meta.object_name,
self._meta.pk.attname,
)
# Files associated to a FileField have to be manually deleted:
# https://docs.djangoproject.com/en/dev/releases/1.3/#deleting-a-model-doesn-t-delete-associated-files
super().delete()
if self.thumbnail:
self.thumbnail.storage.delete(self.thumbnail.name)
if self.file:
self.file.storage.delete(self.file.name)
class Tag(models.Model): class Tag(models.Model):
name = models.CharField( name = models.CharField(
max_length=250, max_length=250,

View file

@ -6,7 +6,11 @@
// Init gallery // Init gallery
lightGallery(document.getElementById('lightgallery'), { lightGallery(document.getElementById('lightgallery'), {
plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom], selector: 'a.photo-item',
plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom, lgVideo],
autoplayFirstVideo: true,
autoplayVideoOnSlide: true,
gotoNextSlideOnVideoEnd: false,
download: true, download: true,
customSlideName: true, customSlideName: true,
licenseKey: '94ED9732-30284A12-B88B0137-4FF9CEE6', licenseKey: '94ED9732-30284A12-B88B0137-4FF9CEE6',
@ -66,7 +70,7 @@ lgContainer.addEventListener('lgAfterOpen', () => {
lgContainer.addEventListener('lgAfterOpen', () => { lgContainer.addEventListener('lgAfterOpen', () => {
// On cible le conteneur de la galerie qui vient de s'afficher // On cible le conteneur de la galerie qui vient de s'afficher
const lgOuter = document.querySelector('.lg-outer'); const lgOuter = document.querySelector('.lg-outer');
lgOuter.addEventListener('contextmenu', (e) => { lgOuter.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.preventDefault();
}, false); }, false);

View file

@ -0,0 +1,144 @@
.lg-outer .lg-video-cont {
text-align: center;
display: inline-block;
vertical-align: middle;
position: relative;
}
.lg-outer .lg-video-cont .lg-object {
width: 100% !important;
height: 100% !important;
}
.lg-outer .lg-has-iframe .lg-video-cont {
-webkit-overflow-scrolling: touch;
overflow: auto;
}
.lg-outer .lg-video-object {
position: absolute;
left: 0;
right: 0;
width: 100%;
height: 100%;
top: 0;
bottom: 0;
z-index: 3;
}
.lg-outer .lg-video-poster {
z-index: 1;
}
.lg-outer .lg-has-video .lg-video-object {
opacity: 0;
will-change: opacity;
-webkit-transition: opacity 0.3s ease-in;
-o-transition: opacity 0.3s ease-in;
transition: opacity 0.3s ease-in;
}
.lg-outer .lg-has-video.lg-video-loaded .lg-video-poster,
.lg-outer .lg-has-video.lg-video-loaded .lg-video-play-button {
opacity: 0 !important;
}
.lg-outer .lg-has-video.lg-video-loaded .lg-video-object {
opacity: 1;
}
@keyframes lg-play-stroke {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
@keyframes lg-play-rotate {
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.lg-video-play-button {
width: 18%;
max-width: 140px;
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
cursor: pointer;
transform: translate(-50%, -50%) scale(1);
will-change: opacity, transform;
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s;
-moz-transition: -moz-transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s;
-o-transition: -o-transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s;
transition: transform 0.25s cubic-bezier(0.17, 0.88, 0.32, 1.28), opacity 0.1s;
}
.lg-video-play-button:hover .lg-video-play-icon-bg,
.lg-video-play-button:hover .lg-video-play-icon {
opacity: 1;
}
.lg-video-play-icon-bg {
fill: none;
stroke-width: 3%;
stroke: #fcfcfc;
opacity: 0.6;
will-change: opacity;
-webkit-transition: opacity 0.12s ease-in;
-o-transition: opacity 0.12s ease-in;
transition: opacity 0.12s ease-in;
}
.lg-video-play-icon-circle {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
fill: none;
stroke-width: 3%;
stroke: rgba(30, 30, 30, 0.9);
stroke-opacity: 1;
stroke-linecap: round;
stroke-dasharray: 200;
stroke-dashoffset: 200;
}
.lg-video-play-icon {
position: absolute;
width: 25%;
max-width: 120px;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
opacity: 0.6;
will-change: opacity;
-webkit-transition: opacity 0.12s ease-in;
-o-transition: opacity 0.12s ease-in;
transition: opacity 0.12s ease-in;
}
.lg-video-play-icon .lg-video-play-icon-inner {
fill: #fcfcfc;
}
.lg-video-loading .lg-video-play-icon-circle {
animation: lg-play-rotate 2s linear 0.25s infinite, lg-play-stroke 1.5s ease-in-out 0.25s infinite;
}
.lg-video-loaded .lg-video-play-button {
opacity: 0;
transform: translate(-50%, -50%) scale(0.7);
}

View file

@ -15,6 +15,9 @@ class lgAdmin {
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true"; this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true";
this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
this.photoId = 0; this.photoId = 0;
this.deleteUrl = '';
this.adminUrl = '';
this.isVideo = false;
return this; return this;
} }
@ -49,14 +52,19 @@ class lgAdmin {
// Event called when showing a new slide // Event called when showing a new slide
onAfterSlide(event) { onAfterSlide(event) {
this.photoId = this.core.galleryItems[event.detail.index].slideName; this.photoId = this.core.galleryItems[event.detail.index].slideName;
document.getElementById("lg-admin").href = `/admin/photologue/photo/${this.photoId}/change/`;
const el = document.querySelector(`[data-slide-name='${this.photoId}']`); const el = document.querySelector(`[data-slide-name='${this.photoId}']`);
this.deleteUrl = el ? el.dataset.deleteUrl : '';
this.adminUrl = el ? el.dataset.adminUrl : '';
this.reportUrl = el ? el.dataset.reportUrl : '';
this.uncensorUrl = el ? el.dataset.uncensorUrl : '';
this.isVideo = el ? el.dataset.video !== undefined : false;
document.getElementById("lg-admin").href = this.adminUrl;
const ownerId = el ? el.dataset.ownerId : null; const ownerId = el ? el.dataset.ownerId : null;
const canDelete = this.isStaff || (ownerId && ownerId === this.userId); const canDelete = this.isStaff || (ownerId && ownerId === this.userId);
document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none'; document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none';
document.getElementById("lg-report").style.display = canDelete ? 'none' : 'block'; document.getElementById("lg-report").style.display = (!canDelete && this.reportUrl) ? 'block' : 'none';
const isCensored = el ? el.dataset.isPublic === 'false' : false; const isCensored = el ? el.dataset.isPublic === 'false' : false;
document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored) ? 'block' : 'none'; document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored && this.uncensorUrl) ? 'block' : 'none';
} }
// Event called when user clicks the restore (uncensor) button // Event called when user clicks the restore (uncensor) button
@ -66,11 +74,12 @@ class lgAdmin {
const photoId = this.photoId; const photoId = this.photoId;
let data = new FormData(); let data = new FormData();
data.append('csrfmiddlewaretoken', this.csrfToken); data.append('csrfmiddlewaretoken', this.csrfToken);
fetch(`/photo/${photoId}/uncensor/`, { fetch(this.uncensorUrl, {
method: "POST", method: "POST",
body: data, body: data,
credentials: 'same-origin', credentials: 'same-origin',
}).then(() => { }).then(response => {
if (!response.ok) { console.error('Uncensor failed', response.status, this.uncensorUrl); return; }
const el = document.querySelector(`[data-slide-name='${photoId}']`); const el = document.querySelector(`[data-slide-name='${photoId}']`);
if (el) { if (el) {
el.dataset.isPublic = 'true'; el.dataset.isPublic = 'true';
@ -132,7 +141,7 @@ class lgAdmin {
const currentIndex = this.core.index; const currentIndex = this.core.index;
let data = new FormData(); let data = new FormData();
data.append('csrfmiddlewaretoken', this.csrfToken); data.append('csrfmiddlewaretoken', this.csrfToken);
fetch(`/photo/${photoId}/delete/`, { fetch(this.deleteUrl, {
method: "POST", method: "POST",
redirect: "manual", // do not load gallery again redirect: "manual", // do not load gallery again
body: data, body: data,
@ -150,7 +159,7 @@ class lgAdmin {
const currentIndex = this.core.index; const currentIndex = this.core.index;
let data = new FormData(); let data = new FormData();
data.append('csrfmiddlewaretoken', this.csrfToken); data.append('csrfmiddlewaretoken', this.csrfToken);
fetch(`/photo/${photoId}/report/`, { fetch(this.reportUrl, {
method: "POST", method: "POST",
redirect: "manual", // do not load gallery again redirect: "manual", // do not load gallery again
body: data, body: data,

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<link rel="stylesheet" href="{% static 'lightgallery/css/lightgallery.css' %}" /> <link rel="stylesheet" href="{% static 'lightgallery/css/lightgallery.css' %}" />
<link rel="stylesheet" href="{% static 'lightgallery/css/lg-zoom.css' %}" /> <link rel="stylesheet" href="{% static 'lightgallery/css/lg-zoom.css' %}" />
<link rel="stylesheet" href="{% static 'lightgallery/css/lg-thumbnail.css' %}" /> <link rel="stylesheet" href="{% static 'lightgallery/css/lg-thumbnail.css' %}" />
<link rel="stylesheet" href="{% static 'lightgallery/css/lg-video.css' %}" />
{% endblock %} {% endblock %}
{% block extrajs %} {% block extrajs %}
@ -25,6 +26,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script> <script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/thumbnail/lg-thumbnail.min.js' %}"></script> <script src="{% static 'lightgallery/plugins/thumbnail/lg-thumbnail.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/zoom/lg-zoom.min.js' %}"></script> <script src="{% static 'lightgallery/plugins/zoom/lg-zoom.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/video/lg-video.min.js' %}"></script>
<script src="{% static 'gallery_justified.js' %}"></script> <script src="{% static 'gallery_justified.js' %}"></script>
<script src="{% static 'gallery_detail.js' %}"></script> <script src="{% static 'gallery_detail.js' %}"></script>
<script src="{% static 'sweetalert.js' %}"></script> <script src="{% static 'sweetalert.js' %}"></script>
@ -90,12 +92,33 @@ SPDX-License-Identifier: GPL-3.0-or-later
</ul> </ul>
</div> </div>
<div class="card-body p-0" id="lightgallery"> <div class="card-body p-0" id="lightgallery">
{% for photo in photos %} {% for item in media_items %}
<a class="photo-item" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url }}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}" data-owner-id="{{ photo.owner.id }}" data-is-public="{{ photo.is_public|yesno:'true,false' }}" data-width="{{ photo.image_width|default:1 }}" data-height="{{ photo.image_height|default:1 }}"> <a class="photo-item"
<img src="{{ photo.get_thumbnail_url }}" data-lazy="{{ photo.get_thumbnail_url }}" class="{% if not photo.is_public %}photo-private{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %}"> href="{% if not item.is_video %}{{ item.get_absolute_url }}{% endif %}"
{% if item.is_video %}
{% if item.get_thumbnail_url %}data-poster="{{ item.get_thumbnail_url }}"{% endif %}
data-video='{"source": [{"src": "{{ item.get_display_url }}", "type": "{{ item.get_mime_type }}"}], "attributes": {"controls": true, "preload": "metadata", "loop": true}}'
{% if item.thumbnail_width and item.thumbnail_height %}data-lg-size="{{ item.thumbnail_width }}-{{ item.thumbnail_height }}"{% endif %}
{% else %}
data-src="{{ item.get_display_url }}"
{% endif %}
data-download-url="{{ item.get_download_url }}"
data-slide-name="{{ item.id }}"
data-owner-id="{{ item.owner.id }}"
data-is-public="{{ item.is_public|yesno:'true,false' }}"
data-delete-url="{% url 'photologue:pl-media-delete' item.model_name item.id %}"
data-admin-url="{{ item.get_admin_url }}"
data-report-url="{% url 'photologue:pl-media-report' 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-height="{{ item.thumbnail_height|default:1 }}">
<img src="{{ item.get_thumbnail_url }}" data-lazy="{{ item.get_thumbnail_url }}"
class="{% if not item.is_public %}photo-private{% endif %}"
alt="{{ item.title }}{% if not item.is_video and item.date_taken %} - {{ item.date_taken|date }} {{ item.date_taken|time }}{% endif %}{% if item.owner.get_full_name %} - {{ item.owner.get_full_name }}{% else %} - {{ item.owner.username }}{% endif %}{% if not item.is_video and item.license %} - {{ item.license }}{% endif %}{% if not item.is_public %} - !PRIVATE!{% endif %}">
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
<div class="card-footer"> <div class="card-footer">
<a href="{% url 'photologue:pl-gallery-download' gallery.slug %}" class="btn btn-secondary btn-sm"> <a href="{% url 'photologue:pl-gallery-download' gallery.slug %}" class="btn btn-secondary btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-download" viewBox="0 0 16 16">

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% comment %}
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
{% endcomment %}
{% load i18n %}
{% block title %}{% trans "Delete confirmation" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-12">
<h1>{% trans "Delete confirmation" %}</h1>
<form method="post">{% csrf_token %}
<p>
{% blocktrans trimmed %}
Are you sure you want to delete <code>{{ object }}</code>?
{% endblocktrans %}
</p>
{{ form }}
<input type="submit" class="btn btn-danger" value="{% trans "Confirm" %}">
</form>
</div>
</div>
{% endblock %}

View file

@ -12,10 +12,10 @@ from .views import (
GalleryPublicToggleView, GalleryPublicToggleView,
GalleryUpload, GalleryUpload,
GalleryYearArchiveView, GalleryYearArchiveView,
PhotoDeleteView, MediaDeleteView,
MediaReportView,
MediaUncensorView,
PhotoDetailView, PhotoDetailView,
PhotoReportView,
PhotoUncensorView,
TagDetail, TagDetail,
) )
@ -41,9 +41,9 @@ urlpatterns = [
name="pl-gallery-download", name="pl-gallery-download",
), ),
path("photo/<int:pk>/", PhotoDetailView.as_view(), name="pl-photo"), path("photo/<int:pk>/", PhotoDetailView.as_view(), name="pl-photo"),
path("photo/<int:pk>/delete/", PhotoDeleteView.as_view(), name="pl-photo-delete"), path("<str:model_name>/<int:pk>/delete/", MediaDeleteView.as_view(), name="pl-media-delete"),
path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"), path("<str:model_name>/<int:pk>/report/", MediaReportView.as_view(), name="pl-media-report"),
path("photo/<int:pk>/uncensor/", PhotoUncensorView.as_view(), name="pl-photo-uncensor"), path("<str:model_name>/<int:pk>/uncensor/", MediaUncensorView.as_view(), name="pl-media-uncensor"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"), path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
path("gallery/<slug:slug>/toggle-public/", GalleryPublicToggleView.as_view(), name="pl-gallery-toggle-public"), path("gallery/<slug:slug>/toggle-public/", GalleryPublicToggleView.as_view(), name="pl-gallery-toggle-public"),
] ]

63
photologue/utils.py Normal file
View file

@ -0,0 +1,63 @@
# 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
import logging
import os
from django.core.files.base import ContentFile
from io import BytesIO
from PIL import Image
import av
logger = logging.getLogger("photologue.utils")
def is_photo(file_obj):
"""Return True if file_obj is a valid image readable by Pillow."""
try:
Image.open(file_obj).verify()
file_obj.seek(0)
return True
except Exception:
file_obj.seek(0)
return False
def is_video(file_obj):
"""Return True if file_obj is a valid video container (has at least one video stream)."""
try:
container = av.open(file_obj)
ok = len(container.streams.video) > 0
container.close()
file_obj.seek(0)
return ok
except Exception:
file_obj.seek(0)
return False
def generate_video_thumbnail(video):
"""Extract the first frame of a video file and save it as the video thumbnail."""
try:
container = av.open(video.file.path)
frame = next(container.decode(video=0))
img = frame.to_image()
rotation = frame.rotation
container.close()
if rotation:
img = img.rotate(rotation, expand=True)
except Exception:
logger.error("Failed to extract video frame for thumbnail", exc_info=True)
return
try:
buffer = BytesIO()
img.save(buffer, "JPEG", quality=70, optimize=True)
# Preserve directory structure: strip the "videos/" storage prefix so that
# get_video_storage_path places the thumb alongside the video file.
rel = video.file.name[len("videos/"):] if video.file.name.startswith("videos/") else video.file.name
thumb_name = os.path.splitext(rel)[0] + "_thumb.jpg"
video.thumbnail.save(thumb_name, ContentFile(buffer.getvalue()), save=True)
except Exception:
logger.error("Failed to save video thumbnail", exc_info=True)

View file

@ -21,13 +21,36 @@ from django.views import View
from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from PIL import Image
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import F from django.db.models import F, Q
from .forms import UploadForm from .forms import UploadForm
from .models import Gallery, Photo, Tag from .models import Gallery, Photo, Tag, Video
from .utils import generate_video_thumbnail, is_photo, is_video
MEDIA_MODELS = {"photo": Photo, "video": Video}
class ModelFromUrlMixin:
"""Sets self.model from the <model_name> URL kwarg using MEDIA_MODELS registry."""
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
model_name = kwargs.get("model_name")
self.model = MEDIA_MODELS.get(model_name)
if self.model is None:
from django.http import Http404
raise Http404
def unique_slug(model, title):
base = slugify(title)
slug, counter = base, 2
while model.objects.filter(slug=slug).exists():
slug = f"{base}-{counter}"
counter += 1
return slug
# Cette ligne renvoie le modèle d'utilisateur actif (le natif ou le vôtre) # Cette ligne renvoie le modèle d'utilisateur actif (le natif ou le vôtre)
User = get_user_model() User = get_user_model()
@ -39,12 +62,14 @@ class GalleryDateView(LoginRequiredMixin):
allow_empty = True # Do not 404 if no galleries allow_empty = True # Do not 404 if no galleries
def get_queryset(self): def get_queryset(self):
"""Hide galleries with only private photos""" """Hide galleries with no public media"""
qs = super().get_queryset() qs = super().get_queryset()
if self.request.user.is_staff: if self.request.user.is_staff:
return qs return qs
else: else:
return qs.filter(photos__is_public=True).distinct() return qs.filter(
Q(photos__is_public=True) | Q(videos__is_public=True)
).distinct()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Always show all years in archive""" """Always show all years in archive"""
@ -75,12 +100,13 @@ class PhotoDetailView(LoginRequiredMixin, DetailView):
return qs.filter(is_public=True) return qs.filter(is_public=True)
class PhotoDeleteView(LoginRequiredMixin, DeleteView): class MediaDeleteView(ModelFromUrlMixin, LoginRequiredMixin, DeleteView):
model = Photo template_name = "photologue/media_confirm_delete.html"
def get_object(self, queryset=None): def get_object(self, queryset=None):
obj = super().get_object(queryset) obj = super().get_object(queryset)
if obj.owner != self.request.user and not self.request.user.has_perm("photologue.delete_photo"): delete_perm = f"photologue.delete_{self.model._meta.model_name}"
if obj.owner != self.request.user and not self.request.user.has_perm(delete_perm):
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
raise PermissionDenied raise PermissionDenied
return obj return obj
@ -93,54 +119,53 @@ class PhotoDeleteView(LoginRequiredMixin, DeleteView):
return reverse_lazy("photologue:pl-gallery", args=[slug]) return reverse_lazy("photologue:pl-gallery", args=[slug])
class PhotoReportView(DetailView): class MediaReportView(ModelFromUrlMixin, DetailView):
model = Photo
template_name = "photologue/photo_confirm_report.html" template_name = "photologue/photo_confirm_report.html"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
photo = self.get_object() obj = self.get_object()
if not photo.galleries.filter(is_public=True).exists(): if not obj.galleries.filter(is_public=True).exists():
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path()) return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" # Mark media as private
Make photo private on POST. obj = self.get_object()
""" obj.is_public = False
# Mark photo as private obj.save()
photo = self.get_object()
photo.is_public = False
photo.save()
# Get gallery # Get gallery
galleries = photo.galleries.all() galleries = obj.galleries.all()
gallery_slug = galleries[0].slug if galleries else "" gallery_slug = galleries[0].slug if galleries else ""
if not gallery_slug: if not gallery_slug:
url = reverse_lazy("photologue:pl-gallery-archive") url = reverse_lazy("photologue:pl-gallery-archive")
url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug]) else:
url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug])
url = request.build_absolute_uri(url) url = request.build_absolute_uri(url)
# Send mail to managers # Send mail to managers
reporter = request.user.username if request.user.is_authenticated else "Anonymous (public link)" reporter = request.user.username if request.user.is_authenticated else "Anonymous (public link)"
mail_admins( mail_admins(
subject=f"Abuse report for photo id {photo.pk}", subject=f"Abuse report for {self.model._meta.verbose_name} id {obj.pk}",
message=f"{reporter} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}", message=f"{reporter} reported an abuse for `{obj.title}`: {url}#lg=1&slide={obj.pk}",
) )
# Redirect to gallery # Redirect to gallery
return redirect(url) return redirect(url)
class PhotoUncensorView(LoginRequiredMixin, View): class MediaUncensorView(ModelFromUrlMixin, LoginRequiredMixin, View):
def post(self, request, pk): model = None
def post(self, request, pk, **kwargs):
if not request.user.has_perm("photologue.can_resolve_censorship"): if not request.user.has_perm("photologue.can_resolve_censorship"):
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
raise PermissionDenied raise PermissionDenied
photo = get_object_or_404(Photo, pk=pk) obj = get_object_or_404(self.model, pk=pk)
photo.is_public = True obj.is_public = True
photo.save() obj.save()
return JsonResponse({"ok": True}) return JsonResponse({"ok": True})
@ -180,13 +205,14 @@ class GalleryDetailView(DetailView):
can_resolve = self.request.user.has_perm("photologue.can_resolve_censorship") can_resolve = self.request.user.has_perm("photologue.can_resolve_censorship")
context["can_resolve_censorship"] = can_resolve context["can_resolve_censorship"] = can_resolve
# Non-staff members only see public photos + prefetch all owners informations (Optimisation) # Non-staff members only see public media
if self.request.user.is_staff or can_resolve: visible_only = not (self.request.user.is_staff or can_resolve)
context["photos"] = self.object.photos.all().select_related("owner") def media_qs(relation):
else: qs = relation.select_related("owner")
context["photos"] = self.object.photos.filter( return qs.filter(is_public=True) if visible_only else qs.all()
is_public=True
).select_related("owner") context["photos"] = media_qs(self.object.photos)
context["videos"] = media_qs(self.object.videos)
# List owners # List owners
context["owners"] = [] context["owners"] = []
@ -194,13 +220,19 @@ class GalleryDetailView(DetailView):
# owners_pk_distinct = context["photos"].order_by('owner__pk').values_list('owner__pk', flat=True).distinct() # owners_pk_distinct = context["photos"].order_by('owner__pk').values_list('owner__pk', flat=True).distinct()
# context["owners"] = User.objects.filter(pk__in=owners_pk_distinct) # context["owners"] = User.objects.filter(pk__in=owners_pk_distinct)
for photo in context["photos"]: for media in [*context["photos"], *context["videos"]]:
if photo.owner not in context["owners"]: if media.owner not in context["owners"]:
context["owners"].append(photo.owner) context["owners"].append(media.owner)
# Filter on owner # Filter on owner
if "owner" in self.kwargs: if "owner" in self.kwargs:
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"])
# Combine photos and videos into a single sorted list for the template
context["media_items"] = sorted(
[*context["photos"], *context["videos"]], key=lambda x: x.title
)
# Increment the photo view count # Increment the photo view count
@ -231,11 +263,12 @@ class GalleryDownload(DetailView):
gallery_year = os.path.join("/photos/", str(gallery.date_start.year)) gallery_year = os.path.join("/photos/", str(gallery.date_start.year))
gallery_zip = os.path.join(gallery_year, (gallery.slug + ".zip")) gallery_zip = os.path.join(gallery_year, (gallery.slug + ".zip"))
media = [*gallery.photos.filter(is_public=True), *gallery.videos.filter(is_public=True)]
with open(settings.MEDIA_ROOT + gallery_zip, "wb") as zip_bytes: with open(settings.MEDIA_ROOT + gallery_zip, "wb") as zip_bytes:
zip_file = zipfile.ZipFile(zip_bytes, "w") zip_file = zipfile.ZipFile(zip_bytes, "w")
for photo in gallery.photos.filter(is_public=True): for item in media:
filename = os.path.basename(os.path.normpath(photo.image.path)) filename = os.path.basename(os.path.normpath(item.file_path))
zip_file.write(photo.image.path, filename) zip_file.write(item.file_path, filename)
zip_file.close() zip_file.close()
# Return the path to it # Return the path to it
@ -266,6 +299,26 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
success_url = reverse_lazy("photologue:pl-gallery-upload") success_url = reverse_lazy("photologue:pl-gallery-upload")
permission_required = "photologue.add_gallery" permission_required = "photologue.add_gallery"
def _upload_media(self, model, file_field, file_obj, gallery, gallery_dir, post_save=None):
"""
Create a media object, save it to the DB, schedule file save on commit.
Returns True if uploaded, False if already exists.
"""
title = Path(file_obj.name).stem
if model.objects.filter(title=title, owner=self.request.user, galleries=gallery).exists():
return False
obj = model(title=title, slug=unique_slug(model, title), owner=self.request.user)
file_path = str(gallery_dir / file_obj.name)
with transaction.atomic():
obj.save()
obj.galleries.set([gallery])
def _save(o=obj, fp=file_path, f=file_obj, ff=file_field, ps=post_save):
getattr(o, ff).save(fp, f)
if ps:
ps(o)
transaction.on_commit(_save)
return True
def form_invalid(self, form): def form_invalid(self, form):
if not self.request.accepts("text/html") and self.request.accepts("application/json"): if not self.request.accepts("text/html") and self.request.accepts("application/json"):
errors = {field: list(errs) for field, errs in form.errors.items()} errors = {field: list(errs) for field, errs in form.errors.items()}
@ -292,63 +345,25 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
gallery_year = Path(str(gallery.date_start.year)) gallery_year = Path(str(gallery.date_start.year))
gallery_dir = gallery_year / gallery.slug gallery_dir = gallery_year / gallery.slug
# Upload pictures # Upload pictures and videos
uploaded_photo_name = [] uploaded_photo_name = []
already_exists = 0 already_exists = 0
files = form.cleaned_data["file_field"] files = form.cleaned_data["file_field"]
for photo_file in files: for photo_file in files:
# Check that we have a valid image if is_photo(photo_file):
try: uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir)
opened = Image.open(photo_file) elif is_video(photo_file):
opened.verify() uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, post_save=generate_video_thumbnail)
photo_file.seek(0) else:
except Exception: messages.error(self.request, f"{photo_file.name} is not a recognized image or video")
# Pillow doesn't recognize it as an image, skip it
messages.error(
self.request, f"{photo_file.name} was not recognized as an image"
)
jsondata["code"] = 400 jsondata["code"] = 400
jsondata["error"] = f"{photo_file.name} was not recognized as an image" jsondata["error"] = f"{photo_file.name} is not a recognized image or video"
continue continue
title = Path(photo_file.name).stem if not uploaded:
already_exist = Photo.objects.filter(
title=title,
owner=self.request.user,
galleries=gallery
).exists()
if already_exist:
already_exists += 1 already_exists += 1
continue else:
uploaded_photo_name.append(photo_file.name)
# Find a uniq slug
base_slug = slugify(title)
slug = base_slug
counter = 2
while Photo.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
photo = Photo(
title=title,
slug=slug,
owner=self.request.user,
)
photo_name = str(gallery_dir / photo_file.name)
# Save photo and associate it with the gallery in a single database operation
# Defer image file saving until the database commit succeeds
with transaction.atomic():
photo.save()
photo.galleries.set([gallery])
def save_file():
photo.image.save(photo_name, photo_file)
transaction.on_commit(save_file)
uploaded_photo_name.append(photo_file.name)
# Notify user then managers # Notify user then managers
n_success = len(uploaded_photo_name) n_success = len(uploaded_photo_name)

View file

@ -10,3 +10,4 @@ whitenoise>=6.0
psycopg2-binary>=2.9 psycopg2-binary>=2.9
requests>=2.25 requests>=2.25
gunicorn>=21.0 gunicorn>=21.0
av>=12.0