Add can_resolve_censorship permission and uncensor feature

- Add custom permission to Photo model
- Add PhotoUncensorView POST endpoint to restore private photos
- Show private photos to users with can_resolve_censorship
- Add restore button in lightgallery toolbar for censored photos
This commit is contained in:
krek0 2026-04-22 08:31:07 +02:00
parent 40352cffee
commit f5ccb40e16
6 changed files with 71 additions and 2 deletions

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.13 on 2026-04-21 09:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('photologue', '0004_alter_photo_options_gallery_public_token'),
]
operations = [
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'},
),
]

View file

@ -553,6 +553,9 @@ class Photo(ImageModel):
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

View file

@ -12,6 +12,7 @@ class lgAdmin {
this.$LG = $LG;
this.isStaff = document.querySelector('[name=is_staff]').value === "true";
this.userId = document.querySelector('[name=user_id]').value;
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true";
this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
this.photoId = 0;
return this;
@ -35,6 +36,12 @@ class lgAdmin {
this.core.$toolbar.append(`<a href="#" id="lg-report" title="Notify abuse" class="lg-icon lg-bi-icon">${reportIcon}</a>`);
document.getElementById("lg-report").addEventListener('click', this.onReport.bind(this));
// Add button to restore a censored photo
const restoreIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" fill=\"currentColor\" class=\"bi\" viewBox=\"0 0 16 16\"><path d=\"M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z\"/><path d=\"M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z\"/></svg>";
this.core.$toolbar.append(`<a href="#" id="lg-restore" title="Restore photo (remove censorship)" class="lg-icon lg-bi-icon">${restoreIcon}</a>`);
document.getElementById("lg-restore").style.display = 'none';
document.getElementById("lg-restore").addEventListener('click', this.onRestore.bind(this));
this.core.LGel.on("lgAfterSlide.admin", this.onAfterSlide.bind(this));
}
@ -46,6 +53,31 @@ class lgAdmin {
const ownerId = el ? el.dataset.ownerId : null;
const canDelete = this.isStaff || (ownerId && ownerId === this.userId);
document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none';
const isCensored = el ? el.dataset.isPublic === 'false' : false;
document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored) ? 'block' : 'none';
}
// Event called when user clicks the restore (uncensor) button
onRestore(event) {
event.preventDefault();
if(confirm("Restore this photo and make it public again?")) {
const photoId = this.photoId;
let data = new FormData();
data.append('csrfmiddlewaretoken', this.csrfToken);
fetch(`/photo/${photoId}/uncensor/`, {
method: "POST",
body: data,
credentials: 'same-origin',
}).then(() => {
const el = document.querySelector(`[data-slide-name='${photoId}']`);
if (el) {
el.dataset.isPublic = 'true';
const img = el.querySelector('img');
if (img) img.classList.remove('border-danger', 'border-5');
}
document.getElementById("lg-restore").style.display = 'none';
});
}
}
// Navigate away from a photo that was just deleted/hidden.

View file

@ -19,6 +19,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% csrf_token %}
<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="can_resolve_censorship" value="{{ can_resolve_censorship|yesno:'true,false' }}">
<script src="{% static 'lightgallery/lightgallery.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script>
<script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script>
@ -88,7 +89,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %}
<div class="card-body row" id="lightgallery">
{% for photo in photos %}
<a class="col-6 col-md-3 mb-2 text-center" 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 }}">
<a class="col-6 col-md-3 mb-2 text-center" 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' }}">
<img src="{{ photo.get_thumbnail_url }}" loading="lazy" class="img-thumbnail p-0{% if not photo.is_public %} border-danger border-5{% 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>
{% endfor %}

View file

@ -15,6 +15,7 @@ from .views import (
PhotoDeleteView,
PhotoDetailView,
PhotoReportView,
PhotoUncensorView,
TagDetail,
)
@ -41,6 +42,7 @@ urlpatterns = [
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("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
path("share/<uuid:token>/", GalleryPublicView.as_view(), name="pl-gallery-public"),
path("gallery/<slug:slug>/token/", GalleryTokenView.as_view(), name="pl-gallery-token"),

View file

@ -121,6 +121,17 @@ class PhotoReportView(DetailView):
return redirect(url)
class PhotoUncensorView(LoginRequiredMixin, View):
def post(self, request, pk):
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()
return JsonResponse({"ok": True})
class TagDetail(LoginRequiredMixin, DetailView):
model = Tag
@ -146,8 +157,11 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
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:
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(