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

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