Compare commits

...

10 commits

Author SHA1 Message Date
krek0
a634cc88bd Fix Select2 tag autocomplete failing with multiple gunicorn workers.
All checks were successful
Docker / build (release) Successful in 9s
2026-05-11 17:49:11 +02:00
krek0
1cdd1dce26 Add configurable per-gallery public access 2026-05-11 15:49:46 +02:00
krek0
72e9344102 Fix gallery photo deletion gap and uncensor UI glitche. 2026-05-11 15:21:18 +02:00
krek0
ed28d4a9c4 Add allauth_oauth app to sync user fields on OAuth login 2026-05-10 13:11:14 +02:00
krek0
b916a00c35 Fix gallery card image height inconsistency 2026-05-10 13:11:14 +02:00
krek0
753f0889e2 avoid uselsse exif() calls 2026-05-10 13:11:14 +02:00
krek0
1621e7c17b remove useless |saf tag 2026-05-10 13:11:14 +02:00
krek0
fdbf03800a Fix file descriptor leak when FileResponse raises an exception 2026-05-10 13:11:14 +02:00
krek0
c15e9bf654 Fix uploaded photos saved as empty files after Pillow verify() 2026-05-10 13:11:14 +02:00
krek0
1eb025ec5f Push latest tag on docker image on release 2026-05-10 13:11:14 +02:00
17 changed files with 204 additions and 51 deletions

View file

@ -22,11 +22,12 @@ jobs:
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build image - name: Build and push image
run: |
docker build -t git.sinfonie.org/sinfonie/photo26:${{ steps.meta.outputs.TAG }} .
- name: Push image
run: | run: |
docker build \
-t git.sinfonie.org/sinfonie/photo26:${{ steps.meta.outputs.TAG }} \
-t git.sinfonie.org/sinfonie/photo26:latest \
.
docker push git.sinfonie.org/sinfonie/photo26:${{ steps.meta.outputs.TAG }} docker push git.sinfonie.org/sinfonie/photo26:${{ steps.meta.outputs.TAG }}
docker push git.sinfonie.org/sinfonie/photo26:latest

12
allauth_oauth/apps.py Normal file
View file

@ -0,0 +1,12 @@
# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
class AllauthOAuthConfig(AppConfig):
name = "allauth_oauth"
def ready(self):
import allauth_oauth.signals # noqa: F401

29
allauth_oauth/signals.py Normal file
View file

@ -0,0 +1,29 @@
# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from allauth.socialaccount.signals import pre_social_login
from django.dispatch import receiver
@receiver(pre_social_login)
def sync_user_fields(sender, request, sociallogin, **kwargs):
if not sociallogin.is_existing:
return
user = sociallogin.user
data = sociallogin.account.extra_data
changed = False
email = data.get("email")
if email and user.email != email:
user.email = email
changed = True
username = data.get("username")
if username and user.username != username:
user.username = username
changed = True
if changed:
user.save()

View file

@ -166,9 +166,15 @@ CACHES = {
"default": { "default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "Master", "LOCATION": "Master",
} },
"select2": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": "/tmp/django_select2_cache",
},
} }
SELECT2_CACHE_BACKEND = "select2"
# Password validation # Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators

View file

@ -37,6 +37,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</button> </button>
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if request.user.is_authenticated %}
<li class="nav-item"> <li class="nav-item">
{% url 'photologue:pl-gallery-archive' as url %} {% url 'photologue:pl-gallery-archive' as url %}
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}">{% trans 'Galleries' %}</a> <a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}">{% trans 'Galleries' %}</a>
@ -52,6 +53,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="nav-link" href="{% url 'admin:index' %}">{% trans 'Manage' %}</a> <a class="nav-link" href="{% url 'admin:index' %}">{% trans 'Manage' %}</a>
</li> </li>
{% endif %} {% endif %}
{% endif %}
</ul> </ul>
<ul class="navbar-nav"> <ul class="navbar-nav">
{% get_available_languages as LANGUAGES %} {% get_available_languages as LANGUAGES %}

View file

@ -9,23 +9,44 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.views.generic import ListView, View from django.views.generic import ListView, View
from photologue.models import Gallery from photologue.models import Gallery, Photo
class MediaAccess(View): class MediaAccess(View):
def get(self, request, path): def get(self, request, path):
if not request.user.is_authenticated and not request.session.get('public_gallery_access'): if not request.user.is_authenticated:
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path()) try:
# Direct match (original photo file)
allowed = Photo.objects.filter(
image=path,
galleries__is_public=True,
).exists()
# Cache files (thumbnails/display) are derived from original photos
if not allowed and '/cache/' in path:
original_dir = os.path.dirname(os.path.dirname(path))
allowed = Photo.objects.filter(
image__startswith=original_dir + '/',
galleries__is_public=True,
).exists()
except Exception:
return redirect_to_login(request.get_full_path())
if not allowed:
return redirect_to_login(request.get_full_path())
media_root = os.path.realpath(settings.MEDIA_ROOT) media_root = os.path.realpath(settings.MEDIA_ROOT)
file_path = os.path.realpath(os.path.join(media_root, path)) file_path = os.path.realpath(os.path.join(media_root, path))
if not file_path.startswith(media_root + os.sep): if not file_path.startswith(media_root + os.sep):
raise Http404 raise Http404
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
raise Http404 raise Http404
response = FileResponse(open(file_path, 'rb')) f = open(file_path, 'rb')
response['Cache-Control'] = 'max-age=2678400' try:
return response response = FileResponse(f)
response['Cache-Control'] = 'max-age=2678400'
return response
except Exception:
f.close()
raise
class IndexView(LoginRequiredMixin, ListView): class IndexView(LoginRequiredMixin, ListView):

View file

@ -3,7 +3,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin from django.contrib import admin
from django.contrib.admin import EmptyFieldListFilter
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Gallery, Photo, Tag from .models import Gallery, Photo, Tag
@ -11,7 +10,7 @@ from .models import Gallery, Photo, Tag
class GalleryAdmin(admin.ModelAdmin): class GalleryAdmin(admin.ModelAdmin):
list_display = ("title", "date_start", "photo_count", "get_tags") list_display = ("title", "date_start", "photo_count", "get_tags")
list_filter = ["date_start", "tags", ("public_token", EmptyFieldListFilter)] list_filter = ["date_start", "tags", "is_public"]
date_hierarchy = "date_start" date_hierarchy = "date_start"
prepopulated_fields = {"slug": ("title",)} prepopulated_fields = {"slug": ("title",)}
model = Gallery model = Gallery

View file

@ -399,21 +399,21 @@ msgstr "Aucune galerie trouvée."
msgid "to" msgid "to"
msgstr "au" msgstr "au"
#: photologue/templates/photologue/gallery_detail.html:53 #: photologue/templates/photologue/gallery_detail.html:49
msgid "Public link:" msgid "censored photos"
msgstr "" msgstr "photos censurées"
#: photologue/templates/photologue/gallery_detail.html:55 msgid "Make public"
msgid "Copy" msgstr "Rendre publique"
msgstr ""
#: photologue/templates/photologue/gallery_detail.html:56 msgid "Make private"
msgid "Revoke" msgstr "Rendre privée"
msgstr ""
#: photologue/templates/photologue/gallery_detail.html:59 msgid "Public, anyone with the link can view this gallery."
msgid "Generate public link" msgstr "Publique, n'importe qui avec le lien peut voir cette galerie."
msgstr ""
msgid "Private, login required to view."
msgstr "Privée, connexion requise pour voir."
#: photologue/templates/photologue/gallery_detail.html:78 #: photologue/templates/photologue/gallery_detail.html:78
msgid "All pictures" msgid "All pictures"

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.13 on 2026-04-20 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('photologue', '0008_regen_thumbnails'),
]
operations = [
migrations.AlterModelOptions(
name='photo',
options={'get_latest_by': 'date_added', 'ordering': ['title'], 'verbose_name': 'photo', 'verbose_name_plural': 'photos'},
),
migrations.AddField(
model_name='gallery',
name='is_public',
field=models.BooleanField(default=False, help_text='Public galleries can be accessed without being logged in.', verbose_name='is public'),
),
]

View file

@ -174,6 +174,11 @@ class Gallery(models.Model):
verbose_name=_("photos"), verbose_name=_("photos"),
blank=True, blank=True,
) )
is_public = models.BooleanField(
_("is public"),
default=False,
help_text=_("Public galleries can be accessed without being logged in."),
)
class Meta: class Meta:
ordering = ["-date_start"] ordering = ["-date_start"]
@ -408,14 +413,15 @@ class ImageModel(models.Model):
# Save the original format # Save the original format
im_format = im.format im_format = im.format
# Rotate if found & necessary # Rotate if found & necessary
exif = self.exif()
if ( if (
"Image Orientation" in self.exif() "Image Orientation" in exif
and self.exif().get("Image Orientation").values[0] and exif.get("Image Orientation").values[0]
in IMAGE_EXIF_ORIENTATION_MAP in IMAGE_EXIF_ORIENTATION_MAP
): ):
im = im.transpose( im = im.transpose(
IMAGE_EXIF_ORIENTATION_MAP[ IMAGE_EXIF_ORIENTATION_MAP[
self.exif().get("Image Orientation").values[0] exif.get("Image Orientation").values[0]
] ]
) )
# Resize/crop image # Resize/crop image

View file

@ -70,6 +70,7 @@
}); });
} }
window.applyJustifiedLayout = applyLayout;
document.addEventListener('DOMContentLoaded', applyLayout); document.addEventListener('DOMContentLoaded', applyLayout);
window.addEventListener('resize', applyLayout); window.addEventListener('resize', applyLayout);
})(); })();

View file

@ -13,7 +13,6 @@ class lgAdmin {
this.isStaff = document.querySelector('[name=is_staff]').value === "true"; this.isStaff = document.querySelector('[name=is_staff]').value === "true";
this.userId = document.querySelector('[name=user_id]').value; this.userId = document.querySelector('[name=user_id]').value;
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true"; this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true";
this.guestMode = document.querySelector('[name=guest_mode]').value === "true";
this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
this.photoId = 0; this.photoId = 0;
return this; return this;
@ -76,9 +75,15 @@ class lgAdmin {
if (el) { if (el) {
el.dataset.isPublic = 'true'; el.dataset.isPublic = 'true';
const img = el.querySelector('img'); const img = el.querySelector('img');
if (img) img.classList.remove('border-danger', 'border-5'); if (img) img.classList.remove('border-danger', 'border-5', 'photo-private');
} }
document.getElementById("lg-restore").style.display = 'none'; document.getElementById("lg-restore").style.display = 'none';
const countSpan = document.getElementById('private-count');
if (countSpan) {
const newCount = parseInt(countSpan.textContent, 10) - 1;
if (newCount <= 0) document.getElementById('private-count-line').remove();
else countSpan.textContent = newCount;
}
}); });
} }
} }
@ -101,6 +106,7 @@ class lgAdmin {
this.core.LGel.off('lgAfterSlide.adminRemove'); this.core.LGel.off('lgAfterSlide.adminRemove');
const thumb = document.querySelector(`[data-slide-name='${photoId}']`); const thumb = document.querySelector(`[data-slide-name='${photoId}']`);
if (thumb) thumb.remove(); if (thumb) thumb.remove();
if (typeof window.applyJustifiedLayout === 'function') window.applyJustifiedLayout();
const lgId = this.core.lgId; const lgId = this.core.lgId;
const deletedItem = document.getElementById(`lg-item-${lgId}-${currentIndex}`); const deletedItem = document.getElementById(`lg-item-${lgId}-${currentIndex}`);
if (deletedItem) deletedItem.remove(); if (deletedItem) deletedItem.remove();

View file

@ -20,7 +20,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<input type="hidden" name="is_staff" value="{{ request.user.is_staff|yesno:'true,false' }}"> <input type="hidden" name="is_staff" value="{{ request.user.is_staff|yesno:'true,false' }}">
<input type="hidden" name="user_id" value="{{ request.user.id }}"> <input type="hidden" name="user_id" value="{{ request.user.id }}">
<input type="hidden" name="can_resolve_censorship" value="{{ can_resolve_censorship|yesno:'true,false' }}"> <input type="hidden" name="can_resolve_censorship" value="{{ can_resolve_censorship|yesno:'true,false' }}">
<input type="hidden" name="guest_mode" value="{{ guest_mode|yesno:'true,false' }}">
<script src="{% static 'lightgallery/lightgallery.min.js' %}"></script> <script src="{% static 'lightgallery/lightgallery.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script> <script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script>
<script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script> <script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script>
@ -29,6 +28,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static 'gallery_justified.js' %}"></script> <script src="{% static 'gallery_justified.js' %}"></script>
<script src="{% static 'gallery_detail.js' %}"></script> <script src="{% static 'gallery_detail.js' %}"></script>
<script src="{% static 'sweetalert.js' %}"></script> <script src="{% static 'sweetalert.js' %}"></script>
{% if not request.user.is_authenticated %}<style>#lg-download, #lg-admin, #lg-delete { display: none !important; }</style>{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -44,7 +44,23 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} {% endif %}
</h1> </h1>
{% if gallery.date_start %}<p class="text-muted small">{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}</p>{% endif %} {% if gallery.date_start %}<p class="text-muted small">{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}</p>{% endif %}
{% if request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small">{{ gallery.photo_private_count }} photos censurées</p>{% endif %} {% if request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small" id="private-count-line"><span id="private-count">{{ gallery.photo_private_count }}</span> {% trans "censored photos" %}</p>{% endif %}
<div class="d-flex align-items-center gap-2 mb-2">
{% if request.user.is_staff %}
<form method="post" action="{% url 'photologue:pl-gallery-toggle-public' gallery.slug %}" class="mb-0">{% csrf_token %}
{% if gallery.is_public %}
<button type="submit" class="btn btn-outline-warning btn-sm">{% trans "Make private" %}</button>
{% else %}
<button type="submit" class="btn btn-outline-success btn-sm">{% trans "Make public" %}</button>
{% endif %}
</form>
{% endif %}
{% if gallery.is_public %}
<p class="small text-muted mb-0">{% trans "Public, anyone with the link can view this gallery." %}</p>
{% else %}
<p class="small text-muted mb-0">{% trans "Private, login required to view." %}</p>
{% endif %}
</div>
{% if gallery.tags.all %} {% if gallery.tags.all %}
<p class="text-muted"> <p class="text-muted">
Tags : {% for tag in gallery.tags.all %} Tags : {% for tag in gallery.tags.all %}
@ -52,7 +68,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endfor %} {% endfor %}
</p> </p>
{% endif %} {% endif %}
{% if gallery.description %}<p>{{ gallery.description|safe|linebreaksbr }}</p>{% endif %} {% if gallery.description %}<p>{{ gallery.description|linebreaksbr }}</p>{% endif %}
<div class="card"> <div class="card">
<div class="card-header pb-0 border-bottom-0"> <div class="card-header pb-0 border-bottom-0">
@ -75,7 +91,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div> </div>
<div class="card-body p-0" id="lightgallery"> <div class="card-body p-0" id="lightgallery">
{% for photo in photos %} {% 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 }}"> <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 %}"> <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 %}">
</a> </a>
{% endfor %} {% endfor %}

View file

@ -7,7 +7,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="card bg-gradient"> <div class="card bg-gradient">
{% for photo in gallery.sample %} {% for photo in gallery.sample %}
<img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ photo.title }}"> <img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ photo.title }}" style="height:200px;object-fit:cover;">
{% endfor %} {% endfor %}
<div class="card-body"> <div class="card-body">
<h4 class="card-title h5">{{ gallery.title }}</h4> <h4 class="card-title h5">{{ gallery.title }}</h4>

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<p class="text-muted small">{% trans "Published" %} {{ object.date_added }}</p> <p class="text-muted small">{% trans "Published" %} {{ object.date_added }}</p>
</div> </div>
</div> </div>
{% if object.caption %}<p>{{ object.caption|safe }}</p>{% endif %} {% if object.caption %}<p>{{ object.caption }}</p>{% endif %}
<a href="{{ object.image.url }}"> <a href="{{ object.image.url }}">
<img src="{{ object.get_display_url }}" class="img-thumbnail" alt="{{ object.title }}"> <img src="{{ object.get_display_url }}" class="img-thumbnail" alt="{{ object.title }}">
</a> </a>

View file

@ -9,6 +9,7 @@ from .views import (
GalleryArchiveIndexView, GalleryArchiveIndexView,
GalleryDetailView, GalleryDetailView,
GalleryDownload, GalleryDownload,
GalleryPublicToggleView,
GalleryUpload, GalleryUpload,
GalleryYearArchiveView, GalleryYearArchiveView,
PhotoDeleteView, PhotoDeleteView,
@ -44,4 +45,5 @@ urlpatterns = [
path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"), 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("photo/<int:pk>/uncensor/", PhotoUncensorView.as_view(), name="pl-photo-uncensor"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"), path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
path("gallery/<slug:slug>/toggle-public/", GalleryPublicToggleView.as_view(), name="pl-gallery-toggle-public"),
] ]

View file

@ -4,23 +4,23 @@
import os import os
import zipfile import zipfile
from io import BytesIO
from pathlib import Path from pathlib import Path
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.mail import mail_admins from django.core.mail import mail_admins
from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.views import View
from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from django.conf import settings
from PIL import Image from PIL import Image
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -93,10 +93,18 @@ class PhotoDeleteView(LoginRequiredMixin, DeleteView):
return reverse_lazy("photologue:pl-gallery", args=[slug]) return reverse_lazy("photologue:pl-gallery", args=[slug])
class PhotoReportView(LoginRequiredMixin, DetailView): class PhotoReportView(DetailView):
model = Photo model = Photo
template_name = "photologue/photo_confirm_report.html" 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():
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): def post(self, request, *args, **kwargs):
""" """
Make photo private on POST. Make photo private on POST.
@ -115,9 +123,10 @@ class PhotoReportView(LoginRequiredMixin, DetailView):
url = request.build_absolute_uri(url) url = request.build_absolute_uri(url)
# Send mail to managers # Send mail to managers
reporter = request.user.username if request.user.is_authenticated else "Anonymous (public link)"
mail_admins( mail_admins(
subject=f"Abuse report for photo id {photo.pk}", subject=f"Abuse report for photo id {photo.pk}",
message=f"{self.request.user.username} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}", message=f"{reporter} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}",
) )
# Redirect to gallery # Redirect to gallery
@ -150,13 +159,21 @@ class TagDetail(LoginRequiredMixin, DetailView):
return context return context
class GalleryDetailView(LoginRequiredMixin, DetailView): class GalleryDetailView(DetailView):
""" """
Gallery detail view to filter on photo owner Gallery detail view to filter on photo owner
""" """
model = Gallery model = Gallery
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
self.object = self.get_object()
if not self.object.is_public:
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -192,10 +209,18 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
return context return context
class GalleryDownload(LoginRequiredMixin, DetailView): class GalleryDownload(DetailView):
### IN FUTURE, PUT IT as Django Task ### IN FUTURE, PUT IT as Django Task
model = Gallery model = Gallery
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
self.object = self.get_object()
if not self.object.is_public:
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
""" """
Download a zip file of the gallery on GET request. Download a zip file of the gallery on GET request.
@ -218,13 +243,17 @@ class GalleryDownload(LoginRequiredMixin, DetailView):
return redirect( return redirect(
(settings.MEDIA_URL + str(gallery_zip)).replace("\\", "/") (settings.MEDIA_URL + str(gallery_zip)).replace("\\", "/")
) # windows fix ) # windows fix
# Return zip file
# response = HttpResponse(
# byte_data.getvalue(), content_type="application/x-zip-compressed" class GalleryPublicToggleView(LoginRequiredMixin, View):
# ) def post(self, request, slug):
# response["Content-Disposition"] = f"attachment; filename={gallery.slug}.zip" if not request.user.is_staff:
# return response from django.core.exceptions import PermissionDenied
raise PermissionDenied
gallery = get_object_or_404(Gallery, slug=slug)
gallery.is_public = not gallery.is_public
gallery.save()
return redirect(reverse("photologue:pl-gallery", args=[slug]))
class GalleryUpload(PermissionRequiredMixin, FormView): class GalleryUpload(PermissionRequiredMixin, FormView):
@ -272,6 +301,7 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
try: try:
opened = Image.open(photo_file) opened = Image.open(photo_file)
opened.verify() opened.verify()
photo_file.seek(0)
except Exception: except Exception:
# Pillow doesn't recognize it as an image, skip it # Pillow doesn't recognize it as an image, skip it
messages.error( messages.error(