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

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

View file

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

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

View file

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

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

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/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">

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