Add video support with unified media display.
All checks were successful
Docker / build (release) Successful in 9s
All checks were successful
Docker / build (release) Successful in 9s
This commit is contained in:
parent
a634cc88bd
commit
f4052a3d99
16 changed files with 700 additions and 224 deletions
|
|
@ -5,7 +5,7 @@
|
|||
from django.contrib import admin
|
||||
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):
|
||||
|
|
@ -29,40 +29,31 @@ class GalleryAdmin(admin.ModelAdmin):
|
|||
|
||||
|
||||
|
||||
class PhotoAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"date_taken",
|
||||
"date_added",
|
||||
"is_public",
|
||||
"view_count",
|
||||
"admin_thumbnail",
|
||||
"get_owner",
|
||||
"get_galleries"
|
||||
)
|
||||
list_filter = ["date_added", "is_public", "owner","galleries"]
|
||||
class MediaAdmin(admin.ModelAdmin):
|
||||
"""Shared admin base for Photo and Video."""
|
||||
list_filter = ["date_added", "is_public", "owner", "galleries"]
|
||||
search_fields = ["title", "slug", "caption"]
|
||||
list_per_page = 25
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
readonly_fields = ("date_taken",)
|
||||
model = Photo
|
||||
|
||||
def get_owner(self, obj):
|
||||
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):
|
||||
return ", ".join([g.title for g in obj.galleries.all()])## get all linked galeries
|
||||
|
||||
get_galleries.short_description = _("Gallery")
|
||||
get_galleries.admin_order_field = 'galleries__title'
|
||||
return ", ".join([g.title for g in obj.galleries.all()])
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).prefetch_related("owner", "galleries")
|
||||
|
||||
get_owner.admin_order_field = "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):
|
||||
|
|
@ -72,6 +63,12 @@ class TagAdmin(admin.ModelAdmin):
|
|||
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(Photo, PhotoAdmin)
|
||||
admin.site.register(Video, VideoAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
|
|
|
|||
|
|
@ -19,20 +19,12 @@ class MultipleFileInput(forms.ClearableFileInput):
|
|||
|
||||
|
||||
class MultipleFileField(forms.FileField):
|
||||
allowed_extensions = [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"tiff",
|
||||
] # Specify allowed extensions here
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault(
|
||||
"widget",
|
||||
MultipleFileInput(
|
||||
attrs={
|
||||
"accept": "image/*",
|
||||
"accept": "image/*,video/*",
|
||||
"class": "mb-3",
|
||||
}
|
||||
),
|
||||
|
|
@ -41,25 +33,9 @@ class MultipleFileField(forms.FileField):
|
|||
|
||||
def clean(self, data, initial=None):
|
||||
single_file_clean = super().clean
|
||||
|
||||
if isinstance(data, (list, tuple)):
|
||||
result = [self.validate_file(d, single_file_clean, initial) for d in data]
|
||||
else:
|
||||
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
|
||||
return [single_file_clean(d, initial) for d in data]
|
||||
return single_file_clean(data, initial)
|
||||
|
||||
|
||||
class UploadForm(forms.Form):
|
||||
|
|
|
|||
83
photologue/migrations/0010_video.py
Normal file
83
photologue/migrations/0010_video.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -63,6 +63,15 @@ else:
|
|||
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
|
||||
# Value 0thRow 0thColumn
|
||||
# 1 top left
|
||||
|
|
@ -174,6 +183,12 @@ class Gallery(models.Model):
|
|||
verbose_name=_("photos"),
|
||||
blank=True,
|
||||
)
|
||||
videos = models.ManyToManyField(
|
||||
"photologue.Video",
|
||||
related_name="galleries",
|
||||
verbose_name=_("videos"),
|
||||
blank=True,
|
||||
)
|
||||
is_public = models.BooleanField(
|
||||
_("is public"),
|
||||
default=False,
|
||||
|
|
@ -216,20 +231,77 @@ class Gallery(models.Model):
|
|||
"""Return a count of private photos in this gallery."""
|
||||
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_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"),
|
||||
max_length=IMAGE_FIELD_MAX_LENGTH,
|
||||
upload_to=get_storage_path,
|
||||
width_field="image_width",
|
||||
height_field="image_height",
|
||||
width_field="thumbnail_width",
|
||||
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"),
|
||||
null=True,
|
||||
|
|
@ -244,9 +316,24 @@ class ImageModel(models.Model):
|
|||
default="center",
|
||||
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:
|
||||
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):
|
||||
try:
|
||||
|
|
@ -505,6 +592,11 @@ class ImageModel(models.Model):
|
|||
)
|
||||
except Exception:
|
||||
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)
|
||||
self.pre_cache(recreate)
|
||||
|
||||
|
|
@ -524,61 +616,16 @@ class ImageModel(models.Model):
|
|||
super().delete()
|
||||
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):
|
||||
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):
|
||||
"""Return the public galleries to which this photo belongs."""
|
||||
return self.galleries
|
||||
|
|
@ -699,11 +746,10 @@ class PhotoSize(models.Model):
|
|||
return self.name
|
||||
|
||||
def clear_cache(self):
|
||||
for cls in ImageModel.__subclasses__():
|
||||
for obj in cls.objects.all():
|
||||
obj.remove_size(self)
|
||||
if self.pre_cache:
|
||||
obj.create_size(self)
|
||||
for obj in Photo.objects.all():
|
||||
obj.remove_size(self)
|
||||
if self.pre_cache:
|
||||
obj.create_size(self)
|
||||
PhotoSizeCache().reset()
|
||||
|
||||
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):
|
||||
name = models.CharField(
|
||||
max_length=250,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
|
||||
// Init gallery
|
||||
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,
|
||||
customSlideName: true,
|
||||
licenseKey: '94ED9732-30284A12-B88B0137-4FF9CEE6',
|
||||
|
|
@ -66,7 +70,7 @@ lgContainer.addEventListener('lgAfterOpen', () => {
|
|||
lgContainer.addEventListener('lgAfterOpen', () => {
|
||||
// On cible le conteneur de la galerie qui vient de s'afficher
|
||||
const lgOuter = document.querySelector('.lg-outer');
|
||||
|
||||
|
||||
lgOuter.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
}, false);
|
||||
|
|
|
|||
144
photologue/static/lightgallery/css/lg-video.css
Normal file
144
photologue/static/lightgallery/css/lg-video.css
Normal 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);
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ class lgAdmin {
|
|||
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true";
|
||||
this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
this.photoId = 0;
|
||||
this.deleteUrl = '';
|
||||
this.adminUrl = '';
|
||||
this.isVideo = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -49,14 +52,19 @@ class lgAdmin {
|
|||
// Event called when showing a new slide
|
||||
onAfterSlide(event) {
|
||||
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}']`);
|
||||
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 canDelete = this.isStaff || (ownerId && ownerId === this.userId);
|
||||
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;
|
||||
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
|
||||
|
|
@ -66,11 +74,12 @@ class lgAdmin {
|
|||
const photoId = this.photoId;
|
||||
let data = new FormData();
|
||||
data.append('csrfmiddlewaretoken', this.csrfToken);
|
||||
fetch(`/photo/${photoId}/uncensor/`, {
|
||||
fetch(this.uncensorUrl, {
|
||||
method: "POST",
|
||||
body: data,
|
||||
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}']`);
|
||||
if (el) {
|
||||
el.dataset.isPublic = 'true';
|
||||
|
|
@ -132,7 +141,7 @@ class lgAdmin {
|
|||
const currentIndex = this.core.index;
|
||||
let data = new FormData();
|
||||
data.append('csrfmiddlewaretoken', this.csrfToken);
|
||||
fetch(`/photo/${photoId}/delete/`, {
|
||||
fetch(this.deleteUrl, {
|
||||
method: "POST",
|
||||
redirect: "manual", // do not load gallery again
|
||||
body: data,
|
||||
|
|
@ -150,7 +159,7 @@ class lgAdmin {
|
|||
const currentIndex = this.core.index;
|
||||
let data = new FormData();
|
||||
data.append('csrfmiddlewaretoken', this.csrfToken);
|
||||
fetch(`/photo/${photoId}/report/`, {
|
||||
fetch(this.reportUrl, {
|
||||
method: "POST",
|
||||
redirect: "manual", // do not load gallery again
|
||||
body: data,
|
||||
|
|
|
|||
8
photologue/static/lightgallery/plugins/video/lg-video.min.js
vendored
Normal file
8
photologue/static/lightgallery/plugins/video/lg-video.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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/lg-zoom.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'lightgallery/css/lg-thumbnail.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'lightgallery/css/lg-video.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% 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/thumbnail/lg-thumbnail.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_detail.js' %}"></script>
|
||||
<script src="{% static 'sweetalert.js' %}"></script>
|
||||
|
|
@ -90,12 +92,33 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
|||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0" id="lightgallery">
|
||||
{% for photo in photos %}
|
||||
<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 }}">
|
||||
<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 %}">
|
||||
{% for item in media_items %}
|
||||
<a class="photo-item"
|
||||
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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<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">
|
||||
|
|
|
|||
26
photologue/templates/photologue/media_confirm_delete.html
Normal file
26
photologue/templates/photologue/media_confirm_delete.html
Normal 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 %}
|
||||
|
|
@ -12,10 +12,10 @@ from .views import (
|
|||
GalleryPublicToggleView,
|
||||
GalleryUpload,
|
||||
GalleryYearArchiveView,
|
||||
PhotoDeleteView,
|
||||
MediaDeleteView,
|
||||
MediaReportView,
|
||||
MediaUncensorView,
|
||||
PhotoDetailView,
|
||||
PhotoReportView,
|
||||
PhotoUncensorView,
|
||||
TagDetail,
|
||||
)
|
||||
|
||||
|
|
@ -41,9 +41,9 @@ urlpatterns = [
|
|||
name="pl-gallery-download",
|
||||
),
|
||||
path("photo/<int:pk>/", PhotoDetailView.as_view(), name="pl-photo"),
|
||||
path("photo/<int:pk>/delete/", PhotoDeleteView.as_view(), name="pl-photo-delete"),
|
||||
path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"),
|
||||
path("photo/<int:pk>/uncensor/", PhotoUncensorView.as_view(), name="pl-photo-uncensor"),
|
||||
path("<str:model_name>/<int:pk>/delete/", MediaDeleteView.as_view(), name="pl-media-delete"),
|
||||
path("<str:model_name>/<int:pk>/report/", MediaReportView.as_view(), name="pl-media-report"),
|
||||
path("<str:model_name>/<int:pk>/uncensor/", MediaUncensorView.as_view(), name="pl-media-uncensor"),
|
||||
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
|
||||
path("gallery/<slug:slug>/toggle-public/", GalleryPublicToggleView.as_view(), name="pl-gallery-toggle-public"),
|
||||
]
|
||||
|
|
|
|||
63
photologue/utils.py
Normal file
63
photologue/utils.py
Normal 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)
|
||||
|
|
@ -21,13 +21,36 @@ from django.views import View
|
|||
from django.views.generic.dates import ArchiveIndexView, YearArchiveView
|
||||
from django.views.generic.detail import DetailView
|
||||
from django.views.generic.edit import DeleteView, FormView
|
||||
from PIL import Image
|
||||
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 .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)
|
||||
User = get_user_model()
|
||||
|
|
@ -39,12 +62,14 @@ class GalleryDateView(LoginRequiredMixin):
|
|||
allow_empty = True # Do not 404 if no galleries
|
||||
|
||||
def get_queryset(self):
|
||||
"""Hide galleries with only private photos"""
|
||||
"""Hide galleries with no public media"""
|
||||
qs = super().get_queryset()
|
||||
if self.request.user.is_staff:
|
||||
return qs
|
||||
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):
|
||||
"""Always show all years in archive"""
|
||||
|
|
@ -75,12 +100,13 @@ class PhotoDetailView(LoginRequiredMixin, DetailView):
|
|||
return qs.filter(is_public=True)
|
||||
|
||||
|
||||
class PhotoDeleteView(LoginRequiredMixin, DeleteView):
|
||||
model = Photo
|
||||
class MediaDeleteView(ModelFromUrlMixin, LoginRequiredMixin, DeleteView):
|
||||
template_name = "photologue/media_confirm_delete.html"
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
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
|
||||
raise PermissionDenied
|
||||
return obj
|
||||
|
|
@ -93,54 +119,53 @@ class PhotoDeleteView(LoginRequiredMixin, DeleteView):
|
|||
return reverse_lazy("photologue:pl-gallery", args=[slug])
|
||||
|
||||
|
||||
class PhotoReportView(DetailView):
|
||||
model = Photo
|
||||
class MediaReportView(ModelFromUrlMixin, DetailView):
|
||||
template_name = "photologue/photo_confirm_report.html"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
photo = self.get_object()
|
||||
if not photo.galleries.filter(is_public=True).exists():
|
||||
obj = self.get_object()
|
||||
if not obj.galleries.filter(is_public=True).exists():
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
return redirect_to_login(request.get_full_path())
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Make photo private on POST.
|
||||
"""
|
||||
# Mark photo as private
|
||||
photo = self.get_object()
|
||||
photo.is_public = False
|
||||
photo.save()
|
||||
# Mark media as private
|
||||
obj = self.get_object()
|
||||
obj.is_public = False
|
||||
obj.save()
|
||||
|
||||
# Get gallery
|
||||
galleries = photo.galleries.all()
|
||||
galleries = obj.galleries.all()
|
||||
gallery_slug = galleries[0].slug if galleries else ""
|
||||
if not gallery_slug:
|
||||
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)
|
||||
|
||||
# Send mail to managers
|
||||
reporter = request.user.username if request.user.is_authenticated else "Anonymous (public link)"
|
||||
mail_admins(
|
||||
subject=f"Abuse report for photo id {photo.pk}",
|
||||
message=f"{reporter} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}",
|
||||
subject=f"Abuse report for {self.model._meta.verbose_name} id {obj.pk}",
|
||||
message=f"{reporter} reported an abuse for `{obj.title}`: {url}#lg=1&slide={obj.pk}",
|
||||
)
|
||||
|
||||
# Redirect to gallery
|
||||
return redirect(url)
|
||||
|
||||
|
||||
class PhotoUncensorView(LoginRequiredMixin, View):
|
||||
def post(self, request, pk):
|
||||
class MediaUncensorView(ModelFromUrlMixin, LoginRequiredMixin, View):
|
||||
model = None
|
||||
|
||||
def post(self, request, pk, **kwargs):
|
||||
if not request.user.has_perm("photologue.can_resolve_censorship"):
|
||||
from django.core.exceptions import PermissionDenied
|
||||
raise PermissionDenied
|
||||
photo = get_object_or_404(Photo, pk=pk)
|
||||
photo.is_public = True
|
||||
photo.save()
|
||||
obj = get_object_or_404(self.model, pk=pk)
|
||||
obj.is_public = True
|
||||
obj.save()
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
|
|
@ -180,13 +205,14 @@ class GalleryDetailView(DetailView):
|
|||
can_resolve = self.request.user.has_perm("photologue.can_resolve_censorship")
|
||||
context["can_resolve_censorship"] = can_resolve
|
||||
|
||||
# Non-staff members only see public photos + prefetch all owners informations (Optimisation)
|
||||
if self.request.user.is_staff or can_resolve:
|
||||
context["photos"] = self.object.photos.all().select_related("owner")
|
||||
else:
|
||||
context["photos"] = self.object.photos.filter(
|
||||
is_public=True
|
||||
).select_related("owner")
|
||||
# Non-staff members only see public media
|
||||
visible_only = not (self.request.user.is_staff or can_resolve)
|
||||
def media_qs(relation):
|
||||
qs = relation.select_related("owner")
|
||||
return qs.filter(is_public=True) if visible_only else qs.all()
|
||||
|
||||
context["photos"] = media_qs(self.object.photos)
|
||||
context["videos"] = media_qs(self.object.videos)
|
||||
|
||||
# List 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()
|
||||
# context["owners"] = User.objects.filter(pk__in=owners_pk_distinct)
|
||||
|
||||
for photo in context["photos"]:
|
||||
if photo.owner not in context["owners"]:
|
||||
context["owners"].append(photo.owner)
|
||||
for media in [*context["photos"], *context["videos"]]:
|
||||
if media.owner not in context["owners"]:
|
||||
context["owners"].append(media.owner)
|
||||
|
||||
# Filter on owner
|
||||
if "owner" in self.kwargs:
|
||||
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
|
||||
|
||||
|
|
@ -231,11 +263,12 @@ class GalleryDownload(DetailView):
|
|||
gallery_year = os.path.join("/photos/", str(gallery.date_start.year))
|
||||
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:
|
||||
zip_file = zipfile.ZipFile(zip_bytes, "w")
|
||||
for photo in gallery.photos.filter(is_public=True):
|
||||
filename = os.path.basename(os.path.normpath(photo.image.path))
|
||||
zip_file.write(photo.image.path, filename)
|
||||
for item in media:
|
||||
filename = os.path.basename(os.path.normpath(item.file_path))
|
||||
zip_file.write(item.file_path, filename)
|
||||
zip_file.close()
|
||||
|
||||
# Return the path to it
|
||||
|
|
@ -266,6 +299,26 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
|
|||
success_url = reverse_lazy("photologue:pl-gallery-upload")
|
||||
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):
|
||||
if not self.request.accepts("text/html") and self.request.accepts("application/json"):
|
||||
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_dir = gallery_year / gallery.slug
|
||||
|
||||
# Upload pictures
|
||||
# Upload pictures and videos
|
||||
uploaded_photo_name = []
|
||||
already_exists = 0
|
||||
files = form.cleaned_data["file_field"]
|
||||
for photo_file in files:
|
||||
# Check that we have a valid image
|
||||
try:
|
||||
opened = Image.open(photo_file)
|
||||
opened.verify()
|
||||
photo_file.seek(0)
|
||||
except Exception:
|
||||
# Pillow doesn't recognize it as an image, skip it
|
||||
messages.error(
|
||||
self.request, f"{photo_file.name} was not recognized as an image"
|
||||
)
|
||||
if is_photo(photo_file):
|
||||
uploaded = self._upload_media(Photo, "image", photo_file, gallery, gallery_dir)
|
||||
elif is_video(photo_file):
|
||||
uploaded = self._upload_media(Video, "file", photo_file, gallery, gallery_dir, post_save=generate_video_thumbnail)
|
||||
else:
|
||||
messages.error(self.request, f"{photo_file.name} is not a recognized image or video")
|
||||
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
|
||||
|
||||
title = Path(photo_file.name).stem
|
||||
|
||||
already_exist = Photo.objects.filter(
|
||||
title=title,
|
||||
owner=self.request.user,
|
||||
galleries=gallery
|
||||
).exists()
|
||||
|
||||
if already_exist:
|
||||
if not uploaded:
|
||||
already_exists += 1
|
||||
continue
|
||||
|
||||
# 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)
|
||||
else:
|
||||
uploaded_photo_name.append(photo_file.name)
|
||||
|
||||
# Notify user then managers
|
||||
n_success = len(uploaded_photo_name)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue