From ced85c7b44b44305992356a78ac198c288ff0f80 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss <erdnaxe@crans.org> Date: Wed, 2 Mar 2022 13:11:24 +0100 Subject: [PATCH 1/5] Show photo by pk --- photologue/models.py | 2 +- photologue/urls.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/photologue/models.py b/photologue/models.py index c1148a9..0072f6d 100644 --- a/photologue/models.py +++ b/photologue/models.py @@ -502,7 +502,7 @@ class Photo(ImageModel): super().save(*args, **kwargs) def get_absolute_url(self): - return reverse('photologue:pl-photo', args=[self.slug]) + return reverse('photologue:pl-photo', args=[self.pk]) def public_galleries(self): """Return the public galleries to which this photo belongs.""" diff --git a/photologue/urls.py b/photologue/urls.py index ff49533..5169131 100644 --- a/photologue/urls.py +++ b/photologue/urls.py @@ -12,6 +12,6 @@ urlpatterns = [ path('gallery/<slug:slug>/', GalleryDetailView.as_view(), name='pl-gallery'), path('gallery/<slug:slug>/<int:owner>/', GalleryDetailView.as_view(), name='pl-gallery-owner'), path('gallery/<slug:slug>/download/', GalleryDownload.as_view(), name='pl-gallery-download'), - path('photo/<slug:slug>/', PhotoDetailView.as_view(), name='pl-photo'), + path('photo/<int:pk>/', PhotoDetailView.as_view(), name='pl-photo'), path('upload/', GalleryUpload.as_view(), name='pl-gallery-upload'), ] -- GitLab From f17adbb2ed2a0f217a58fae8cb575b74987c48fe Mon Sep 17 00:00:00 2001 From: Alexandre Iooss <erdnaxe@crans.org> Date: Wed, 2 Mar 2022 13:11:57 +0100 Subject: [PATCH 2/5] Show private photos count in gallery --- photologue/templates/photologue/gallery_detail.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index 3561932..35d3538 100644 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -40,6 +40,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endif %} </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 request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small">{{ gallery.photo_private_count }} photos censurées</p>{% endif %} {% if gallery.tags.all %} <p class="text-muted"> Tags : {% for tag in gallery.tags.all %} @@ -71,7 +72,7 @@ SPDX-License-Identifier: GPL-3.0-or-later <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 }}"> - <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 %} - {{ photo.owner.get_full_name }}{% if photo.license %} - {{ photo.license }}{% endif %}"> + <img src="{{ photo.get_thumbnail_url }}" loading="lazy" class="img-thumbnail p-0{% if not photo.is_public %} border-danger border-5{% endif %}" alt="{{ gallery.title }} - {{ 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 %} </div> -- GitLab From f9c33e2cad70aaa1fa91daaaabe25e7b80eb0ae9 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss <erdnaxe@crans.org> Date: Wed, 2 Mar 2022 13:13:59 +0100 Subject: [PATCH 3/5] Add delete photo view --- .../photologue/photo_confirm_delete.html | 24 +++++++++++++++++++ photologue/urls.py | 3 ++- photologue/views.py | 14 ++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 photologue/templates/photologue/photo_confirm_delete.html diff --git a/photologue/templates/photologue/photo_confirm_delete.html b/photologue/templates/photologue/photo_confirm_delete.html new file mode 100644 index 0000000..29c88d9 --- /dev/null +++ b/photologue/templates/photologue/photo_confirm_delete.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% comment %} +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> + {% blocktranslate trimmed %} + Are you sure you want to delete <code>{{ object }}</code>? + {% endblocktranslate %} + </p> + {{ form }} + <input type="submit" class="btn btn-danger" value="{% trans "Confirm" %}"> + </form> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/photologue/urls.py b/photologue/urls.py index 5169131..547b0f0 100644 --- a/photologue/urls.py +++ b/photologue/urls.py @@ -2,7 +2,7 @@ from django.urls import path, re_path from .views import (GalleryDetailView, GalleryArchiveIndexView, GalleryDownload, GalleryUpload, GalleryYearArchiveView, - PhotoDetailView, TagDetail) + PhotoDetailView, PhotoDeleteView, TagDetail) app_name = 'photologue' urlpatterns = [ @@ -13,5 +13,6 @@ urlpatterns = [ path('gallery/<slug:slug>/<int:owner>/', GalleryDetailView.as_view(), name='pl-gallery-owner'), path('gallery/<slug:slug>/download/', GalleryDownload.as_view(), 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('upload/', GalleryUpload.as_view(), name='pl-gallery-upload'), ] diff --git a/photologue/views.py b/photologue/views.py index 8d75d87..75621ca 100644 --- a/photologue/views.py +++ b/photologue/views.py @@ -16,7 +16,7 @@ from django.urls import reverse_lazy from django.utils.text import slugify from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.detail import DetailView -from django.views.generic.edit import FormView +from django.views.generic.edit import FormView, DeleteView from PIL import Image from .forms import UploadForm @@ -56,6 +56,18 @@ class PhotoDetailView(LoginRequiredMixin, DetailView): return qs.filter(is_public=True) +class PhotoDeleteView(PermissionRequiredMixin, DeleteView): + model = Photo + permission_required = 'photologue.delete_photo' + + def get_success_url(self): + galleries = self.object.galleries.all() + if not galleries: + return reverse_lazy('photologue:pl-gallery-archive') + slug = galleries[0].slug + return reverse_lazy('photologue:pl-gallery', args=[slug]) + + class TagDetail(LoginRequiredMixin, DetailView): model = Tag -- GitLab From 2ad0c8dbc720de78e5a510ae8c370c0e7beb9b5d Mon Sep 17 00:00:00 2001 From: Alexandre Iooss <erdnaxe@crans.org> Date: Wed, 2 Mar 2022 21:22:44 +0100 Subject: [PATCH 4/5] Enable users to report without sending a mail --- .../lightgallery/plugins/admin/lg-admin.js | 66 +++++++++++++++++-- .../templates/photologue/gallery_detail.html | 1 + .../photologue/photo_confirm_report.html | 24 +++++++ photologue/urls.py | 3 +- photologue/views.py | 31 +++++++++ 5 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 photologue/templates/photologue/photo_confirm_report.html diff --git a/photologue/static/lightgallery/plugins/admin/lg-admin.js b/photologue/static/lightgallery/plugins/admin/lg-admin.js index 9f8695c..3faef11 100644 --- a/photologue/static/lightgallery/plugins/admin/lg-admin.js +++ b/photologue/static/lightgallery/plugins/admin/lg-admin.js @@ -9,6 +9,8 @@ class lgAdmin { this.core = instance; this.$LG = $LG; this.isStaff = instance.settings.isStaff; + this.csrfToken = instance.settings.csrfToken; + this.photoId = 0; return this; } @@ -18,24 +20,78 @@ class lgAdmin { const reportIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" fill=\"currentColor\" viewBox=\"0 0 16 16\"><path d=\"M11.46.146A.5.5 0 0 0 11.107 0H4.893a.5.5 0 0 0-.353.146L.146 4.54A.5.5 0 0 0 0 4.893v6.214a.5.5 0 0 0 .146.353l4.394 4.394a.5.5 0 0 0 .353.146h6.214a.5.5 0 0 0 .353-.146l4.394-4.394a.5.5 0 0 0 .146-.353V4.893a.5.5 0 0 0-.146-.353L11.46.146zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z\"/></svg>"; // Add button linking to Django admin page - this.core.$toolbar.append(`<a href="#" id="lg-admin" title="Go to admin" class="lg-icon lg-bi-icon">${adminIcon}</a>`); + this.core.$toolbar.append(`<a href="#" target="_blank" id="lg-admin" title="Go to admin" class="lg-icon lg-bi-icon">${adminIcon}</a>`); document.getElementById("lg-admin").style.display = this.isStaff ? 'block' : 'none'; // Add button to delete photo this.core.$toolbar.append(`<a href="#" id="lg-delete" title="Remove this photo" class="lg-icon lg-bi-icon">${deleteIcon}</a>`); document.getElementById("lg-delete").style.display = this.isStaff ? 'block' : 'none'; + document.getElementById("lg-delete").addEventListener('click', this.onDelete.bind(this)); // Add button to report photo 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)); this.core.LGel.on("lgAfterSlide.admin", this.onAfterSlide.bind(this)); } + // Event called when showing a new slide onAfterSlide(event) { - const photoId = this.core.galleryItems[event.detail.index].slideName; - document.getElementById("lg-admin").href = `https://photos.crans.org/admin/photologue/photo/${photoId}/change/`; - document.getElementById("lg-delete").href = `https://photos.crans.org/admin/photologue/photo/${photoId}/delete/`; - document.getElementById("lg-report").href = `mailto:photos@crans.org?subject=[ABUS] Photo ${photoId}&body=${encodeURIComponent(window.location.href)}`; + this.photoId = this.core.galleryItems[event.detail.index].slideName; + document.getElementById("lg-admin").href = `/admin/photologue/photo/${this.photoId}/change/`; + } + + // Event called when user click on delete button + onDelete(event) { + event.preventDefault(); + if(confirm("Are you sure to delete this photo?")) { + // Build form request + let data = new FormData(); + data.append('csrfmiddlewaretoken', this.csrfToken); + fetch(`/photo/${this.photoId}/delete/`, { + method: "POST", + body: data, + credentials: 'same-origin', + }).then(res => { + if(res.ok) { + console.log("Deletion complete, response:", res); + + // Remove HTML element + document.querySelectorAll(`[data-slide-name='${this.photoId}']`)[0].remove() + this.core.goToNextSlide(); + this.core.refresh(); + } + }); + } + } + + // Event called when user click on report button + onReport(event) { + event.preventDefault(); + if(confirm("Are you sure to report this photo?")) { + // Build form request + let data = new FormData(); + data.append('csrfmiddlewaretoken', this.csrfToken); + fetch(`/photo/${this.photoId}/report/`, { + method: "POST", + body: data, + credentials: 'same-origin', + }).then(res => { + if(res.ok) { + console.log("Report complete, response:", res); + + // Update HTML element + const thumbnail = document.querySelectorAll(`[data-slide-name='${this.photoId}']`)[0]; + if (!this.isStaff) { + thumbnail.remove() + this.core.goToNextSlide(); + this.core.refresh(); + } else { + location.reload(); + } + } + }); + } } // Plugins must have destroy prototype diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index 35d3538..0d14bac 100644 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -23,6 +23,7 @@ SPDX-License-Identifier: GPL-3.0-or-later plugins: [lgAdmin, lgHash, lgThumbnail, lgZoom], customSlideName: true, isStaff: {{ request.user.is_staff|yesno:"true,false" }}, + csrfToken: "{{ csrf_token }}", }); </script> {% endblock %} diff --git a/photologue/templates/photologue/photo_confirm_report.html b/photologue/templates/photologue/photo_confirm_report.html new file mode 100644 index 0000000..e682b9b --- /dev/null +++ b/photologue/templates/photologue/photo_confirm_report.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% comment %} +SPDX-License-Identifier: GPL-3.0-or-later +{% endcomment %} +{% load i18n %} + +{% block title %}{% trans "Report confirmation" %}{% endblock %} + +{% block content %} +<div class="row"> + <div class="col-lg-12"> + <h1>{% trans "Report confirmation" %}</h1> + <form method="post">{% csrf_token %} + <p> + {% blocktranslate trimmed %} + Are you sure you want to report <code>{{ object }}</code>? + This photo will no longer be public, and administrators will be notified. + {% endblocktranslate %} + </p> + <input type="submit" class="btn btn-warning" value="{% trans "Confirm" %}"> + </form> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/photologue/urls.py b/photologue/urls.py index 547b0f0..bb47615 100644 --- a/photologue/urls.py +++ b/photologue/urls.py @@ -2,7 +2,7 @@ from django.urls import path, re_path from .views import (GalleryDetailView, GalleryArchiveIndexView, GalleryDownload, GalleryUpload, GalleryYearArchiveView, - PhotoDetailView, PhotoDeleteView, TagDetail) + PhotoDetailView, PhotoDeleteView, PhotoReportView, TagDetail) app_name = 'photologue' urlpatterns = [ @@ -14,5 +14,6 @@ urlpatterns = [ path('gallery/<slug:slug>/download/', GalleryDownload.as_view(), 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('upload/', GalleryUpload.as_view(), name='pl-gallery-upload'), ] diff --git a/photologue/views.py b/photologue/views.py index 75621ca..3df8fa6 100644 --- a/photologue/views.py +++ b/photologue/views.py @@ -18,6 +18,7 @@ from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.detail import DetailView from django.views.generic.edit import FormView, DeleteView from PIL import Image +from django.shortcuts import redirect from .forms import UploadForm from .models import Gallery, Photo, Tag @@ -68,6 +69,36 @@ class PhotoDeleteView(PermissionRequiredMixin, DeleteView): return reverse_lazy('photologue:pl-gallery', args=[slug]) +class PhotoReportView(LoginRequiredMixin, DetailView): + model = Photo + template_name = 'photologue/photo_confirm_report.html' + + 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() + + # Get gallery + galleries = photo.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]) + + # Send mail to managers + mail_managers( + 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}", + ) + + # Redirect to gallery + return redirect(url) + + class TagDetail(LoginRequiredMixin, DetailView): model = Tag -- GitLab From 59136050fb531ae306b98ee40155ceb4e512f34f Mon Sep 17 00:00:00 2001 From: Alexandre Iooss <erdnaxe@crans.org> Date: Wed, 2 Mar 2022 21:23:40 +0100 Subject: [PATCH 5/5] Format code using black --- photologue/admin.py | 49 +- photologue/apps.py | 4 +- photologue/forms.py | 72 +-- photologue/management/commands/duplicate.py | 38 +- photologue/management/commands/plcache.py | 29 +- .../management/commands/plcreatesize.py | 33 +- photologue/management/commands/plflush.py | 15 +- .../management/commands/rename_media.py | 6 +- photologue/migrations/0001_initial.py | 343 ++++++++++-- .../migrations/0002_auto_20220130_1020.py | 15 +- .../0003_remove_gallery_is_public.py | 6 +- photologue/models.py | 491 +++++++++++------- photologue/urls.py | 48 +- photologue/views.py | 73 +-- 14 files changed, 809 insertions(+), 413 deletions(-) diff --git a/photologue/admin.py b/photologue/admin.py index 63cb50a..af7e428 100644 --- a/photologue/admin.py +++ b/photologue/admin.py @@ -5,40 +5,51 @@ from .models import Gallery, Photo, Tag class GalleryAdmin(admin.ModelAdmin): - list_display = ('title', 'date_start', 'photo_count', 'get_tags') - list_filter = ['date_start', 'tags'] - date_hierarchy = 'date_start' - prepopulated_fields = {'slug': ('title',)} + list_display = ("title", "date_start", "photo_count", "get_tags") + list_filter = ["date_start", "tags"] + date_hierarchy = "date_start" + prepopulated_fields = {"slug": ("title",)} model = Gallery - exclude = ['photos'] - autocomplete_fields = ['tags'] - search_fields = ['title', ] + exclude = ["photos"] + autocomplete_fields = ["tags"] + search_fields = [ + "title", + ] def get_tags(self, obj): return ", ".join([t.name for t in obj.tags.all()]) - get_tags.short_description = _('tags') + + get_tags.short_description = _("tags") class PhotoAdmin(admin.ModelAdmin): - list_display = ('title', 'date_taken', 'date_added', - 'is_public', 'view_count', 'admin_thumbnail', 'get_owner') - list_filter = ['date_added', 'is_public', 'owner'] - search_fields = ['title', 'slug', 'caption'] + list_display = ( + "title", + "date_taken", + "date_added", + "is_public", + "view_count", + "admin_thumbnail", + "get_owner", + ) + list_filter = ["date_added", "is_public", "owner"] + search_fields = ["title", "slug", "caption"] list_per_page = 25 - prepopulated_fields = {'slug': ('title',)} - readonly_fields = ('date_taken',) + prepopulated_fields = {"slug": ("title",)} + readonly_fields = ("date_taken",) model = Photo def get_owner(self, obj): return obj.owner.username - get_owner.admin_order_field = 'owner' - get_owner.short_description = _('owner') + + get_owner.admin_order_field = "owner" + get_owner.short_description = _("owner") class TagAdmin(admin.ModelAdmin): - list_display = ('name',) - search_fields = ('name',) - prepopulated_fields = {'slug': ('name',)} + list_display = ("name",) + search_fields = ("name",) + prepopulated_fields = {"slug": ("name",)} model = Tag diff --git a/photologue/apps.py b/photologue/apps.py index 19a451f..dad807a 100644 --- a/photologue/apps.py +++ b/photologue/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class PhotologueConfig(AppConfig): - default_auto_field = 'django.db.models.AutoField' - name = 'photologue' + default_auto_field = "django.db.models.AutoField" + name = "photologue" diff --git a/photologue/forms.py b/photologue/forms.py index 8ef1f1c..ed4eab5 100644 --- a/photologue/forms.py +++ b/photologue/forms.py @@ -12,40 +12,46 @@ from .models import Gallery, Tag class UploadForm(forms.Form): file_field = forms.FileField( label="", - widget=forms.FileInput(attrs={ - 'accept': 'image/*', - 'multiple': True, - 'class': 'mb-3', - }), + widget=forms.FileInput( + attrs={ + "accept": "image/*", + "multiple": True, + "class": "mb-3", + } + ), ) gallery = forms.ModelChoiceField( Gallery.objects.all(), - label=_('Gallery'), + label=_("Gallery"), required=False, - empty_label=_('-- Create a new gallery --'), - help_text=_('Select a gallery to add these images to. Leave this empty to ' - 'create a new gallery from the supplied title.') + empty_label=_("-- Create a new gallery --"), + help_text=_( + "Select a gallery to add these images to. Leave this empty to " + "create a new gallery from the supplied title." + ), ) new_gallery_title = forms.CharField( - label=_('New gallery title'), + label=_("New gallery title"), max_length=250, required=False, ) new_gallery_date_start = forms.DateField( - label=_('New gallery event start date'), + label=_("New gallery event start date"), initial=datetime.date.today, required=False, ) new_gallery_date_end = forms.DateField( - label=_('New gallery event end date'), + label=_("New gallery event end date"), initial=datetime.date.today, required=False, ) new_gallery_tags = forms.ModelMultipleChoiceField( Tag.objects.all(), - label=_('New gallery tags'), + label=_("New gallery tags"), required=False, - help_text=_('Hold down "Control", or "Command" on a Mac, to select more than one.') + help_text=_( + 'Hold down "Control", or "Command" on a Mac, to select more than one.' + ), ) def __init__(self, *args, **kwargs): @@ -53,31 +59,35 @@ class UploadForm(forms.Form): self.helper = FormHelper() self.helper.use_custom_control = False self.helper.layout = Layout( - 'file_field', - 'gallery', - 'new_gallery_title', + "file_field", + "gallery", + "new_gallery_title", Div( - Div('new_gallery_date_start', css_class='col'), - Div('new_gallery_date_end', css_class='col'), - css_class='row' + Div("new_gallery_date_start", css_class="col"), + Div("new_gallery_date_end", css_class="col"), + css_class="row", ), - 'new_gallery_tags', - Submit('submit', _('Upload'), css_class='btn btn-success mt-2') + "new_gallery_tags", + Submit("submit", _("Upload"), css_class="btn btn-success mt-2"), ) def clean_new_gallery_title(self): - title = self.cleaned_data['new_gallery_title'] + title = self.cleaned_data["new_gallery_title"] if title and Gallery.objects.filter(title=title).exists(): - raise forms.ValidationError(_('A gallery with that title already exists.')) + raise forms.ValidationError(_("A gallery with that title already exists.")) return title def clean(self): cleaned_data = super().clean() # Check that either an existing gallery is chosen, or new_gallery_title is filled - if not (bool(cleaned_data['gallery']) ^ bool(cleaned_data.get('new_gallery_title', None))): + if not ( + bool(cleaned_data["gallery"]) + ^ bool(cleaned_data.get("new_gallery_title", None)) + ): raise forms.ValidationError( - _('Select an existing gallery, or enter a title for a new gallery.')) + _("Select an existing gallery, or enter a title for a new gallery.") + ) return cleaned_data @@ -85,16 +95,16 @@ class UploadForm(forms.Form): """ Get or create gallery """ - gallery = self.cleaned_data['gallery'] + gallery = self.cleaned_data["gallery"] if not gallery: # Create new gallery - title = self.cleaned_data.get('new_gallery_title') + title = self.cleaned_data.get("new_gallery_title") gallery = Gallery.objects.create( title=title, slug=slugify(title), - date_start=self.cleaned_data['new_gallery_date_start'], - date_end=self.cleaned_data['new_gallery_date_end'], + date_start=self.cleaned_data["new_gallery_date_start"], + date_end=self.cleaned_data["new_gallery_date_end"], ) - for tag in self.cleaned_data['new_gallery_tags']: + for tag in self.cleaned_data["new_gallery_tags"]: gallery.tags.add(tag) return gallery diff --git a/photologue/management/commands/duplicate.py b/photologue/management/commands/duplicate.py index 56ce185..80786e4 100644 --- a/photologue/management/commands/duplicate.py +++ b/photologue/management/commands/duplicate.py @@ -5,26 +5,36 @@ from photologue.models import Gallery class Command(BaseCommand): - help = 'List all duplicate for chosen galleries' + help = "List all duplicate for chosen galleries" def add_arguments(self, parser): parser.add_argument( - '--slugs', nargs='+', help='Try to find duplicate in the selected galleries', default=[]) - parser.add_argument('-a', '--all', action='store_true', - help='Try to find duplicate in all galleries, overide any slugs given') - parser.add_argument('-d', '--delete', action='store_true') + "--slugs", + nargs="+", + help="Try to find duplicate in the selected galleries", + default=[], + ) + parser.add_argument( + "-a", + "--all", + action="store_true", + help="Try to find duplicate in all galleries, overide any slugs given", + ) + parser.add_argument("-d", "--delete", action="store_true") def handle(self, *args, **options): # Collect all required galleries - if options['all']: + if options["all"]: galleries = Gallery.objects.all() else: galleries = [] - for slug in options['slugs']: + for slug in options["slugs"]: gallery_query = Gallery.objects.filter(slug=slug) if not gallery_query: - raise CommandError(f"Slug {slug} does not correspond to a " - "gallery in the database.") + raise CommandError( + f"Slug {slug} does not correspond to a " + "gallery in the database." + ) galleries += gallery_query # Find duplicates in all galleries @@ -32,18 +42,16 @@ class Command(BaseCommand): duplicates = find_duplicate(gallery) self.stdout.write(f"Gallery {gallery.slug}:") for original, copies in duplicates: - self.stdout.write(f" {original.slug} is duplicated:", ending='') + self.stdout.write(f" {original.slug} is duplicated:", ending="") for copy in copies: self.stdout.write(f" {copy.slug}") # Delete them if --delete - if options['delete']: - self.stdout.write( - ' Deleting duplicate in {} :'.format(gallery.slug)) + if options["delete"]: + self.stdout.write(" Deleting duplicate in {} :".format(gallery.slug)) for (_original, copies) in duplicates: for copy in copies: - self.stdout.write( - ' Deleting {}...'.format(copy.slug)) + self.stdout.write(" Deleting {}...".format(copy.slug)) copy.delete() diff --git a/photologue/management/commands/plcache.py b/photologue/management/commands/plcache.py index 4884957..5d66fbb 100644 --- a/photologue/management/commands/plcache.py +++ b/photologue/management/commands/plcache.py @@ -7,22 +7,21 @@ from photologue.models import ImageModel, PhotoSize class Command(BaseCommand): - help = 'Manages Photologue cache file for the given sizes.' + help = "Manages Photologue cache file for the given sizes." def add_arguments(self, parser): - parser.add_argument('sizes', - nargs='*', - type=str, - help='Name of the photosize.') - parser.add_argument('--reset', - action='store_true', - default=False, - dest='reset', - help='Reset photo cache before generating.') + parser.add_argument("sizes", nargs="*", type=str, help="Name of the photosize.") + parser.add_argument( + "--reset", + action="store_true", + default=False, + dest="reset", + help="Reset photo cache before generating.", + ) def handle(self, *args, **options): - reset = options['reset'] - sizes = options['sizes'] + reset = options["reset"] + sizes = options["sizes"] if not sizes: photosizes = PhotoSize.objects.all() @@ -30,13 +29,13 @@ class Command(BaseCommand): photosizes = PhotoSize.objects.filter(name__in=sizes) if not len(photosizes): - raise CommandError('No photo sizes were found.') + raise CommandError("No photo sizes were found.") - print('Caching photos, this may take a while...') + print("Caching photos, this may take a while...") for cls in ImageModel.__subclasses__(): for photosize in photosizes: - print('Cacheing %s size images' % photosize.name) + print("Cacheing %s size images" % photosize.name) for obj in cls.objects.all(): if reset: obj.remove_size(photosize) diff --git a/photologue/management/commands/plcreatesize.py b/photologue/management/commands/plcreatesize.py index 48d4e31..b49ddb0 100644 --- a/photologue/management/commands/plcreatesize.py +++ b/photologue/management/commands/plcreatesize.py @@ -6,17 +6,15 @@ from photologue.models import PhotoSize class Command(BaseCommand): - help = ('Creates a new Photologue photo size interactively.') + help = "Creates a new Photologue photo size interactively." requires_model_validation = True can_import_settings = True def add_arguments(self, parser): - parser.add_argument('name', - type=str, - help='Name of the new photo size') + parser.add_argument("name", type=str, help="Name of the new photo size") def handle(self, *args, **options): - create_photosize(options['name']) + create_photosize(options["name"]) def get_response(msg, func=int, default=None): @@ -27,10 +25,12 @@ def get_response(msg, func=int, default=None): try: return func(resp) except Exception: - print('Invalid input.') + print("Invalid input.") -def create_photosize(name, width=0, height=0, crop=False, pre_cache=False, increment_count=False): +def create_photosize( + name, width=0, height=0, crop=False, pre_cache=False, increment_count=False +): try: size = PhotoSize.objects.get(name=name) exists = True @@ -38,15 +38,20 @@ def create_photosize(name, width=0, height=0, crop=False, pre_cache=False, incre size = PhotoSize(name=name) exists = False if exists: - msg = 'A "%s" photo size already exists. Do you want to replace it? (yes, no):' % name - if not get_response(msg, lambda inp: inp == 'yes', False): + msg = ( + 'A "%s" photo size already exists. Do you want to replace it? (yes, no):' + % name + ) + if not get_response(msg, lambda inp: inp == "yes", False): return print('\nWe will now define the "%s" photo size:\n' % size) - w = get_response('Width (in pixels):', lambda inp: int(inp), width) - h = get_response('Height (in pixels):', lambda inp: int(inp), height) - c = get_response('Crop to fit? (yes, no):', lambda inp: inp == 'yes', crop) - p = get_response('Pre-cache? (yes, no):', lambda inp: inp == 'yes', pre_cache) - i = get_response('Increment count? (yes, no):', lambda inp: inp == 'yes', increment_count) + w = get_response("Width (in pixels):", lambda inp: int(inp), width) + h = get_response("Height (in pixels):", lambda inp: int(inp), height) + c = get_response("Crop to fit? (yes, no):", lambda inp: inp == "yes", crop) + p = get_response("Pre-cache? (yes, no):", lambda inp: inp == "yes", pre_cache) + i = get_response( + "Increment count? (yes, no):", lambda inp: inp == "yes", increment_count + ) size.width = w size.height = h size.crop = c diff --git a/photologue/management/commands/plflush.py b/photologue/management/commands/plflush.py index 9f902e6..b92dcd0 100644 --- a/photologue/management/commands/plflush.py +++ b/photologue/management/commands/plflush.py @@ -6,16 +6,13 @@ from photologue.models import ImageModel, PhotoSize class Command(BaseCommand): - help = 'Clears the Photologue cache for the given sizes.' + help = "Clears the Photologue cache for the given sizes." def add_arguments(self, parser): - parser.add_argument('sizes', - nargs='*', - type=str, - help='Name of the photosize.') + parser.add_argument("sizes", nargs="*", type=str, help="Name of the photosize.") def handle(self, *args, **options): - sizes = options['sizes'] + sizes = options["sizes"] if not sizes: photosizes = PhotoSize.objects.all() @@ -23,12 +20,12 @@ class Command(BaseCommand): photosizes = PhotoSize.objects.filter(name__in=sizes) if not len(photosizes): - raise CommandError('No photo sizes were found.') + raise CommandError("No photo sizes were found.") - print('Flushing cache...') + print("Flushing cache...") for cls in ImageModel.__subclasses__(): for photosize in photosizes: - print('Flushing %s size images' % photosize.name) + print("Flushing %s size images" % photosize.name) for obj in cls.objects.all(): obj.remove_size(photosize) diff --git a/photologue/management/commands/rename_media.py b/photologue/management/commands/rename_media.py index 1f8767e..95f5926 100644 --- a/photologue/management/commands/rename_media.py +++ b/photologue/management/commands/rename_media.py @@ -7,17 +7,17 @@ from photologue.models import Gallery class Command(BaseCommand): - help = 'Rename uploaded media file to match gallery and photo names' + help = "Rename uploaded media file to match gallery and photo names" def add_arguments(self, parser): - parser.add_argument('--apply', action='store_true') + parser.add_argument("--apply", action="store_true") def handle(self, *args, **options): media_dir = Path(settings.MEDIA_ROOT) for gallery in Gallery.objects.all(): # Create gallery directory gallery_year = str(gallery.date_start.year) - gallery_dir = Path('photos') / gallery_year / gallery.slug + gallery_dir = Path("photos") / gallery_year / gallery.slug gallery_path = media_dir / gallery_dir if not gallery_path.exists(): self.stdout.write(f"Creating {gallery_dir}") diff --git a/photologue/migrations/0001_initial.py b/photologue/migrations/0001_initial.py index 47bca5b..2f0927c 100644 --- a/photologue/migrations/0001_initial.py +++ b/photologue/migrations/0001_initial.py @@ -1,10 +1,11 @@ # Generated by Django 3.2.11 on 2022-01-30 10:14 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + import photologue.models @@ -18,79 +19,313 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='PhotoSize', + name="PhotoSize", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".', max_length=40, unique=True, validators=[django.core.validators.RegexValidator(message='Use only plain lowercase letters (ASCII), numbers and underscores.', regex='^[a-z0-9_]+$')], verbose_name='name')), - ('width', models.PositiveIntegerField(default=0, help_text='If width is set to "0" the image will be scaled to the supplied height.', verbose_name='width')), - ('height', models.PositiveIntegerField(default=0, help_text='If height is set to "0" the image will be scaled to the supplied width', verbose_name='height')), - ('quality', models.PositiveIntegerField(choices=[(30, 'Very Low'), (40, 'Low'), (50, 'Medium-Low'), (60, 'Medium'), (70, 'Medium-High'), (80, 'High'), (90, 'Very High')], default=70, help_text='JPEG image quality.', verbose_name='quality')), - ('upscale', models.BooleanField(default=False, help_text='If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting.', verbose_name='upscale images?')), - ('crop', models.BooleanField(default=False, help_text='If selected the image will be scaled and cropped to fit the supplied dimensions.', verbose_name='crop to fit?')), - ('pre_cache', models.BooleanField(default=False, help_text='If selected this photo size will be pre-cached as photos are added.', verbose_name='pre-cache?')), - ('increment_count', models.BooleanField(default=False, help_text='If selected the image\'s "view_count" will be incremented when this photo size is displayed.', verbose_name='increment view count?')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text='Photo size name should contain only letters, numbers and underscores. Examples: "thumbnail", "display", "small", "main_page_widget".', + max_length=40, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Use only plain lowercase letters (ASCII), numbers and underscores.", + regex="^[a-z0-9_]+$", + ) + ], + verbose_name="name", + ), + ), + ( + "width", + models.PositiveIntegerField( + default=0, + help_text='If width is set to "0" the image will be scaled to the supplied height.', + verbose_name="width", + ), + ), + ( + "height", + models.PositiveIntegerField( + default=0, + help_text='If height is set to "0" the image will be scaled to the supplied width', + verbose_name="height", + ), + ), + ( + "quality", + models.PositiveIntegerField( + choices=[ + (30, "Very Low"), + (40, "Low"), + (50, "Medium-Low"), + (60, "Medium"), + (70, "Medium-High"), + (80, "High"), + (90, "Very High"), + ], + default=70, + help_text="JPEG image quality.", + verbose_name="quality", + ), + ), + ( + "upscale", + models.BooleanField( + default=False, + help_text="If selected the image will be scaled up if necessary to fit the supplied dimensions. Cropped sizes will be upscaled regardless of this setting.", + verbose_name="upscale images?", + ), + ), + ( + "crop", + models.BooleanField( + default=False, + help_text="If selected the image will be scaled and cropped to fit the supplied dimensions.", + verbose_name="crop to fit?", + ), + ), + ( + "pre_cache", + models.BooleanField( + default=False, + help_text="If selected this photo size will be pre-cached as photos are added.", + verbose_name="pre-cache?", + ), + ), + ( + "increment_count", + models.BooleanField( + default=False, + help_text='If selected the image\'s "view_count" will be incremented when this photo size is displayed.', + verbose_name="increment view count?", + ), + ), ], options={ - 'verbose_name': 'photo size', - 'verbose_name_plural': 'photo sizes', - 'ordering': ['width', 'height'], + "verbose_name": "photo size", + "verbose_name_plural": "photo sizes", + "ordering": ["width", "height"], }, ), migrations.CreateModel( - name='Tag', + name="Tag", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=250, unique=True, verbose_name='name')), - ('slug', models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='slug')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=250, unique=True, verbose_name="name"), + ), + ( + "slug", + models.SlugField( + help_text='A "slug" is a unique URL-friendly title for an object.', + max_length=250, + unique=True, + verbose_name="slug", + ), + ), ], options={ - 'verbose_name': 'tag', - 'verbose_name_plural': 'tags', - 'ordering': ['name'], + "verbose_name": "tag", + "verbose_name_plural": "tags", + "ordering": ["name"], }, ), migrations.CreateModel( - name='Photo', + name="Photo", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('image', models.ImageField(upload_to=photologue.models.get_storage_path, verbose_name='image')), - ('date_taken', models.DateTimeField(blank=True, help_text='Date image was taken; is obtained from the image EXIF data.', null=True, verbose_name='date taken')), - ('view_count', models.PositiveIntegerField(default=0, editable=False, verbose_name='view count')), - ('crop_from', models.CharField(blank=True, choices=[('top', 'Top'), ('right', 'Right'), ('bottom', 'Bottom'), ('left', 'Left'), ('center', 'Center (Default)')], default='center', max_length=10, verbose_name='crop from')), - ('title', models.CharField(max_length=250, unique=True, 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')), - ('license', models.CharField(blank=True, max_length=255, verbose_name='license')), - ('is_public', models.BooleanField(default=True, help_text='Public photographs will be displayed in the default views.', verbose_name='is public')), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='owner')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "image", + models.ImageField( + upload_to=photologue.models.get_storage_path, + verbose_name="image", + ), + ), + ( + "date_taken", + models.DateTimeField( + blank=True, + help_text="Date image was taken; is obtained from the image EXIF data.", + null=True, + verbose_name="date taken", + ), + ), + ( + "view_count", + models.PositiveIntegerField( + default=0, editable=False, verbose_name="view count" + ), + ), + ( + "crop_from", + models.CharField( + blank=True, + choices=[ + ("top", "Top"), + ("right", "Right"), + ("bottom", "Bottom"), + ("left", "Left"), + ("center", "Center (Default)"), + ], + default="center", + max_length=10, + verbose_name="crop from", + ), + ), + ( + "title", + models.CharField(max_length=250, unique=True, 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" + ), + ), + ( + "license", + models.CharField( + blank=True, max_length=255, verbose_name="license" + ), + ), + ( + "is_public", + models.BooleanField( + default=True, + help_text="Public photographs will be displayed in the default views.", + verbose_name="is public", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), ], options={ - 'verbose_name': 'photo', - 'verbose_name_plural': 'photos', - 'ordering': ['-date_added'], - 'get_latest_by': 'date_added', + "verbose_name": "photo", + "verbose_name_plural": "photos", + "ordering": ["-date_added"], + "get_latest_by": "date_added", }, ), migrations.CreateModel( - name='Gallery', + name="Gallery", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date_added', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date published')), - ('title', models.CharField(max_length=250, unique=True, 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='title slug')), - ('date_start', models.DateField(default=django.utils.timezone.now, verbose_name='start date')), - ('date_end', models.DateField(blank=True, null=True, verbose_name='end date')), - ('description', models.TextField(blank=True, verbose_name='description')), - ('is_public', models.BooleanField(default=True, help_text='Public galleries will be displayed in the default views.', verbose_name='is public')), - ('photos', models.ManyToManyField(blank=True, related_name='galleries', to='photologue.Photo', verbose_name='photos')), - ('tags', models.ManyToManyField(blank=True, related_name='galleries', to='photologue.Tag', verbose_name='tags')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "date_added", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date published" + ), + ), + ( + "title", + models.CharField(max_length=250, unique=True, 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="title slug", + ), + ), + ( + "date_start", + models.DateField( + default=django.utils.timezone.now, verbose_name="start date" + ), + ), + ( + "date_end", + models.DateField(blank=True, null=True, verbose_name="end date"), + ), + ( + "description", + models.TextField(blank=True, verbose_name="description"), + ), + ( + "is_public", + models.BooleanField( + default=True, + help_text="Public galleries will be displayed in the default views.", + verbose_name="is public", + ), + ), + ( + "photos", + models.ManyToManyField( + blank=True, + related_name="galleries", + to="photologue.Photo", + verbose_name="photos", + ), + ), + ( + "tags", + models.ManyToManyField( + blank=True, + related_name="galleries", + to="photologue.Tag", + verbose_name="tags", + ), + ), ], options={ - 'verbose_name': 'gallery', - 'verbose_name_plural': 'galleries', - 'ordering': ['-date_added'], - 'get_latest_by': 'date_added', + "verbose_name": "gallery", + "verbose_name_plural": "galleries", + "ordering": ["-date_added"], + "get_latest_by": "date_added", }, ), ] diff --git a/photologue/migrations/0002_auto_20220130_1020.py b/photologue/migrations/0002_auto_20220130_1020.py index 21338b0..9355753 100644 --- a/photologue/migrations/0002_auto_20220130_1020.py +++ b/photologue/migrations/0002_auto_20220130_1020.py @@ -6,16 +6,21 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('photologue', '0001_initial'), + ("photologue", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='gallery', - options={'get_latest_by': 'date_start', 'ordering': ['-date_start'], 'verbose_name': 'gallery', 'verbose_name_plural': 'galleries'}, + name="gallery", + options={ + "get_latest_by": "date_start", + "ordering": ["-date_start"], + "verbose_name": "gallery", + "verbose_name_plural": "galleries", + }, ), migrations.RemoveField( - model_name='gallery', - name='date_added', + model_name="gallery", + name="date_added", ), ] diff --git a/photologue/migrations/0003_remove_gallery_is_public.py b/photologue/migrations/0003_remove_gallery_is_public.py index 8092038..2554495 100644 --- a/photologue/migrations/0003_remove_gallery_is_public.py +++ b/photologue/migrations/0003_remove_gallery_is_public.py @@ -6,12 +6,12 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('photologue', '0002_auto_20220130_1020'), + ("photologue", "0002_auto_20220130_1020"), ] operations = [ migrations.RemoveField( - model_name='gallery', - name='is_public', + model_name="gallery", + name="is_public", ), ] diff --git a/photologue/models.py b/photologue/models.py index 0072f6d..29054bb 100644 --- a/photologue/models.py +++ b/photologue/models.py @@ -25,31 +25,37 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from PIL import Image, ImageFile, ImageFilter -logger = logging.getLogger('photologue.models') +logger = logging.getLogger("photologue.models") # Default limit for gallery.latest -LATEST_LIMIT = getattr(settings, 'PHOTOLOGUE_GALLERY_LATEST_LIMIT', None) +LATEST_LIMIT = getattr(settings, "PHOTOLOGUE_GALLERY_LATEST_LIMIT", None) # max_length setting for the ImageModel ImageField -IMAGE_FIELD_MAX_LENGTH = getattr(settings, 'PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH', 100) +IMAGE_FIELD_MAX_LENGTH = getattr(settings, "PHOTOLOGUE_IMAGE_FIELD_MAX_LENGTH", 100) # Modify image file buffer size. -ImageFile.MAXBLOCK = getattr(settings, 'PHOTOLOGUE_MAXBLOCK', 256 * 2 ** 10) +ImageFile.MAXBLOCK = getattr(settings, "PHOTOLOGUE_MAXBLOCK", 256 * 2 ** 10) # Look for user function to define file paths -PHOTOLOGUE_PATH = getattr(settings, 'PHOTOLOGUE_PATH', None) +PHOTOLOGUE_PATH = getattr(settings, "PHOTOLOGUE_PATH", None) if PHOTOLOGUE_PATH is not None: if callable(PHOTOLOGUE_PATH): get_storage_path = PHOTOLOGUE_PATH else: - parts = PHOTOLOGUE_PATH.split('.') - module_name = '.'.join(parts[:-1]) + parts = PHOTOLOGUE_PATH.split(".") + module_name = ".".join(parts[:-1]) module = import_module(module_name) get_storage_path = getattr(module, parts[-1]) else: + def get_storage_path(instance, filename): - fn = unicodedata.normalize('NFKD', force_str(filename)).encode('ascii', 'ignore').decode('ascii') - return os.path.join('photos', fn) + fn = ( + unicodedata.normalize("NFKD", force_str(filename)) + .encode("ascii", "ignore") + .decode("ascii") + ) + return os.path.join("photos", fn) + # Exif Orientation values # Value 0thRow 0thColumn @@ -73,42 +79,47 @@ IMAGE_EXIF_ORIENTATION_MAP = { # Quality options for JPEG images JPEG_QUALITY_CHOICES = ( - (30, _('Very Low')), - (40, _('Low')), - (50, _('Medium-Low')), - (60, _('Medium')), - (70, _('Medium-High')), - (80, _('High')), - (90, _('Very High')), + (30, _("Very Low")), + (40, _("Low")), + (50, _("Medium-Low")), + (60, _("Medium")), + (70, _("Medium-High")), + (80, _("High")), + (90, _("Very High")), ) # choices for new crop_anchor field in Photo CROP_ANCHOR_CHOICES = ( - ('top', _('Top')), - ('right', _('Right')), - ('bottom', _('Bottom')), - ('left', _('Left')), - ('center', _('Center (Default)')), + ("top", _("Top")), + ("right", _("Right")), + ("bottom", _("Bottom")), + ("left", _("Left")), + ("center", _("Center (Default)")), ) IMAGE_TRANSPOSE_CHOICES = ( - ('FLIP_LEFT_RIGHT', _('Flip left to right')), - ('FLIP_TOP_BOTTOM', _('Flip top to bottom')), - ('ROTATE_90', _('Rotate 90 degrees counter-clockwise')), - ('ROTATE_270', _('Rotate 90 degrees clockwise')), - ('ROTATE_180', _('Rotate 180 degrees')), + ("FLIP_LEFT_RIGHT", _("Flip left to right")), + ("FLIP_TOP_BOTTOM", _("Flip top to bottom")), + ("ROTATE_90", _("Rotate 90 degrees counter-clockwise")), + ("ROTATE_270", _("Rotate 90 degrees clockwise")), + ("ROTATE_180", _("Rotate 180 degrees")), ) # Prepare a list of image filters filter_names = [] for n in dir(ImageFilter): klass = getattr(ImageFilter, n) - if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \ - hasattr(klass, 'name'): + if ( + isclass(klass) + and issubclass(klass, ImageFilter.BuiltinFilter) + and hasattr(klass, "name") + ): filter_names.append(klass.__name__) -IMAGE_FILTERS_HELP_TEXT = _('Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE"' - '. Image filters will be applied in order. The following filters are available: %s.' - % (', '.join(filter_names))) +IMAGE_FILTERS_HELP_TEXT = _( + 'Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE"' + ". Image filters will be applied in order. The following filters are available: %s." + % (", ".join(filter_names)) +) size_method_map = {} @@ -119,22 +130,22 @@ class TagField(models.CharField): """ def __init__(self, **kwargs): - default_kwargs = {'max_length': 255, 'blank': True} + default_kwargs = {"max_length": 255, "blank": True} default_kwargs.update(kwargs) super().__init__(**default_kwargs) def get_internal_type(self): - return 'CharField' + return "CharField" class Gallery(models.Model): - title = models.CharField(_('title'), - max_length=250, - unique=True) - slug = models.SlugField(_('title slug'), - unique=True, - max_length=250, - help_text=_('A "slug" is a unique URL-friendly title for an object.')) + title = models.CharField(_("title"), max_length=250, unique=True) + slug = models.SlugField( + _("title slug"), + unique=True, + max_length=250, + help_text=_('A "slug" is a unique URL-friendly title for an object.'), + ) date_start = models.DateField( default=now, verbose_name=_("start date"), @@ -144,30 +155,31 @@ class Gallery(models.Model): null=True, verbose_name=_("end date"), ) - description = models.TextField(_('description'), - blank=True) + description = models.TextField(_("description"), blank=True) tags = models.ManyToManyField( - 'photologue.Tag', - related_name='galleries', - verbose_name=_('tags'), + "photologue.Tag", + related_name="galleries", + verbose_name=_("tags"), + blank=True, + ) + photos = models.ManyToManyField( + "photologue.Photo", + related_name="galleries", + verbose_name=_("photos"), blank=True, ) - photos = models.ManyToManyField('photologue.Photo', - related_name='galleries', - verbose_name=_('photos'), - blank=True) class Meta: - ordering = ['-date_start'] - get_latest_by = 'date_start' - verbose_name = _('gallery') - verbose_name_plural = _('galleries') + ordering = ["-date_start"] + get_latest_by = "date_start" + verbose_name = _("gallery") + verbose_name_plural = _("galleries") def __str__(self): return f"{ self.title } ({self.date_start})" def get_absolute_url(self): - return reverse('photologue:pl-gallery', args=[self.slug]) + return reverse("photologue:pl-gallery", args=[self.slug]) def sample(self, public=True): """Return a sample of photos, ordered at random.""" @@ -191,26 +203,28 @@ class Gallery(models.Model): """Return a count of private photos in this gallery.""" return self.photos.filter(is_public=False).count() - photo_count.short_description = _('count') - photo_private_count.short_description = _('private count') + photo_count.short_description = _("count") + photo_private_count.short_description = _("private count") class ImageModel(models.Model): - image = models.ImageField(_('image'), - max_length=IMAGE_FIELD_MAX_LENGTH, - upload_to=get_storage_path) - date_taken = models.DateTimeField(_('date taken'), - null=True, - blank=True, - help_text=_('Date image was taken; is obtained from the image EXIF data.')) - view_count = models.PositiveIntegerField(_('view count'), - default=0, - editable=False) - crop_from = models.CharField(_('crop from'), - blank=True, - max_length=10, - default='center', - choices=CROP_ANCHOR_CHOICES) + image = models.ImageField( + _("image"), max_length=IMAGE_FIELD_MAX_LENGTH, upload_to=get_storage_path + ) + date_taken = models.DateTimeField( + _("date taken"), + null=True, + blank=True, + help_text=_("Date image was taken; is obtained from the image EXIF data."), + ) + view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False) + crop_from = models.CharField( + _("crop from"), + blank=True, + max_length=10, + default="center", + choices=CROP_ANCHOR_CHOICES, + ) class Meta: abstract = True @@ -220,38 +234,44 @@ class ImageModel(models.Model): if file: tags = exifread.process_file(file) else: - with self.image.storage.open(self.image.name, 'rb') as file: + with self.image.storage.open(self.image.name, "rb") as file: tags = exifread.process_file(file, details=False) return tags except Exception: return {} def admin_thumbnail(self): - func = getattr(self, 'get_admin_thumbnail_url', None) + func = getattr(self, "get_admin_thumbnail_url", None) if func is None: return _('An "admin_thumbnail" photo size has not been defined.') else: - if hasattr(self, 'get_absolute_url'): - return mark_safe('<a href="{}"><img src="{}"></a>'.format(self.get_absolute_url(), func())) + if hasattr(self, "get_absolute_url"): + return mark_safe( + '<a href="{}"><img src="{}"></a>'.format( + self.get_absolute_url(), func() + ) + ) else: - return mark_safe('<a href="{}"><img src="{}"></a>'.format(self.image.url, func())) + return mark_safe( + '<a href="{}"><img src="{}"></a>'.format(self.image.url, func()) + ) - admin_thumbnail.short_description = _('Thumbnail') + admin_thumbnail.short_description = _("Thumbnail") admin_thumbnail.allow_tags = True def cache_path(self): return os.path.join(os.path.dirname(self.image.name), "cache") def cache_url(self): - return '/'.join([os.path.dirname(self.image.url), "cache"]) + return "/".join([os.path.dirname(self.image.url), "cache"]) def image_filename(self): return os.path.basename(force_str(self.image.name)) def _get_filename_for_size(self, size): - size = getattr(size, 'name', size) + size = getattr(size, "name", size) base, ext = os.path.splitext(self.image_filename()) - return ''.join([base, '_', size, ext]) + return "".join([base, "_", size, ext]) def _get_size_photosize(self, size): return PhotoSizeCache().sizes.get(size) @@ -261,8 +281,9 @@ class ImageModel(models.Model): if not self.size_exists(photosize): self.create_size(photosize) try: - return Image.open(self.image.storage.open( - self._get_size_filename(size))).size + return Image.open( + self.image.storage.open(self._get_size_filename(size)) + ).size except Exception: return None @@ -272,14 +293,18 @@ class ImageModel(models.Model): self.create_size(photosize) if photosize.increment_count: self.increment_count() - return '/'.join([ - self.cache_url(), - filepath_to_uri(self._get_filename_for_size(photosize.name))]) + return "/".join( + [ + self.cache_url(), + filepath_to_uri(self._get_filename_for_size(photosize.name)), + ] + ) def _get_size_filename(self, size): photosize = PhotoSizeCache().sizes.get(size) - return smart_str(os.path.join(self.cache_path(), - self._get_filename_for_size(photosize.name))) + return smart_str( + os.path.join(self.cache_path(), self._get_filename_for_size(photosize.name)) + ) def increment_count(self): self.view_count += 1 @@ -291,7 +316,7 @@ class ImageModel(models.Model): init_size_method_map() di = size_method_map.get(name, None) if di is not None: - result = partial(getattr(self, di['base_name']), di['size']) + result = partial(getattr(self, di["base_name"]), di["size"]) setattr(self, name, result) return result else: @@ -313,38 +338,45 @@ class ImageModel(models.Model): new_width, new_height = photosize.size if photosize.crop: ratio = max(float(new_width) / cur_width, float(new_height) / cur_height) - x = (cur_width * ratio) - y = (cur_height * ratio) + x = cur_width * ratio + y = cur_height * ratio xd = abs(new_width - x) yd = abs(new_height - y) x_diff = int(xd / 2) y_diff = int(yd / 2) - if self.crop_from == 'top': + if self.crop_from == "top": box = (int(x_diff), 0, int(x_diff + new_width), new_height) - elif self.crop_from == 'left': + elif self.crop_from == "left": box = (0, int(y_diff), new_width, int(y_diff + new_height)) - elif self.crop_from == 'bottom': + elif self.crop_from == "bottom": # y - yd = new_height box = (int(x_diff), int(yd), int(x_diff + new_width), int(y)) - elif self.crop_from == 'right': + elif self.crop_from == "right": # x - xd = new_width box = (int(xd), int(y_diff), int(x), int(y_diff + new_height)) else: - box = (int(x_diff), int(y_diff), int(x_diff + new_width), int(y_diff + new_height)) + box = ( + int(x_diff), + int(y_diff), + int(x_diff + new_width), + int(y_diff + new_height), + ) im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box) else: if not new_width == 0 and not new_height == 0: - ratio = min(float(new_width) / cur_width, - float(new_height) / cur_height) + ratio = min( + float(new_width) / cur_width, float(new_height) / cur_height + ) else: if new_width == 0: ratio = float(new_height) / cur_height else: ratio = float(new_width) / cur_width - new_dimensions = (int(round(cur_width * ratio)), - int(round(cur_height * ratio))) - if new_dimensions[0] > cur_width or \ - new_dimensions[1] > cur_height: + new_dimensions = ( + int(round(cur_width * ratio)), + int(round(cur_height * ratio)), + ) + if new_dimensions[0] > cur_width or new_dimensions[1] > cur_height: if not photosize.upscale: return im im = im.resize(new_dimensions, Image.ANTIALIAS) @@ -360,10 +392,16 @@ class ImageModel(models.Model): # Save the original format im_format = im.format # Rotate if found & necessary - if 'Image Orientation' in self.exif() and \ - self.exif().get('Image Orientation').values[0] in IMAGE_EXIF_ORIENTATION_MAP: + if ( + "Image Orientation" in self.exif() + and self.exif().get("Image Orientation").values[0] + in IMAGE_EXIF_ORIENTATION_MAP + ): im = im.transpose( - IMAGE_EXIF_ORIENTATION_MAP[self.exif().get('Image Orientation').values[0]]) + IMAGE_EXIF_ORIENTATION_MAP[ + self.exif().get("Image Orientation").values[0] + ] + ) # Resize/crop image if (im.size != photosize.size and photosize.size != (0, 0)) or recreate: im = self.resize_image(im, photosize) @@ -371,14 +409,13 @@ class ImageModel(models.Model): im_filename = getattr(self, "get_%s_filename" % photosize.name)() try: buffer = BytesIO() - if im_format != 'JPEG': + if im_format != "JPEG": im.save(buffer, im_format) else: # Issue #182 - test fix from https://github.com/bashu/django-watermark/issues/31 - if im.mode.endswith('A'): + if im.mode.endswith("A"): im = im.convert(im.mode[:-1]) - im.save(buffer, 'JPEG', quality=int(photosize.quality), - optimize=True) + im.save(buffer, "JPEG", quality=int(photosize.quality), optimize=True) buffer_contents = ContentFile(buffer.getvalue()) self.image.storage.save(im_filename, buffer_contents) except OSError as e: @@ -411,7 +448,7 @@ class ImageModel(models.Model): self._old_image = self.image def save(self, *args, **kwargs): - recreate = kwargs.pop('recreate', False) + recreate = kwargs.pop("recreate", False) image_has_changed = False if self._get_pk_val() and (self._old_image != self.image): image_has_changed = True @@ -423,26 +460,39 @@ class ImageModel(models.Model): self.image = self._old_image self.clear_cache() self.image = new_image # Back to the new image. - self._old_image.storage.delete(self._old_image.name) # Delete (old) base image. + self._old_image.storage.delete( + self._old_image.name + ) # Delete (old) base image. if self.date_taken is None or image_has_changed: # Attempt to get the date the photo was taken from the EXIF data. try: - exif_date = self.exif(self.image.file).get('EXIF DateTimeOriginal', None) + exif_date = self.exif(self.image.file).get( + "EXIF DateTimeOriginal", None + ) if exif_date is not None: d, t = exif_date.values.split() - year, month, day = d.split(':') - hour, minute, second = t.split(':') - self.date_taken = datetime(int(year), int(month), int(day), - int(hour), int(minute), int(second)) + year, month, day = d.split(":") + hour, minute, second = t.split(":") + self.date_taken = datetime( + int(year), + int(month), + int(day), + int(hour), + int(minute), + int(second), + ) except Exception: - logger.error('Failed to read EXIF DateTimeOriginal', exc_info=True) + logger.error("Failed to read EXIF DateTimeOriginal", exc_info=True) super().save(*args, **kwargs) self.pre_cache(recreate) 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) + 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, + ) self.clear_cache() # 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 @@ -454,17 +504,15 @@ class ImageModel(models.Model): class Photo(ImageModel): - title = models.CharField(_('title'), - max_length=250, - unique=True) - 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) + title = models.CharField(_("title"), max_length=250, unique=True) + 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, @@ -475,15 +523,17 @@ class Photo(ImageModel): blank=True, verbose_name=_("license"), ) - is_public = models.BooleanField(_('is public'), - default=True, - help_text=_('Public photographs will be displayed in the default views.')) + 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' + ordering = ["title"] + get_latest_by = "date_added" verbose_name = _("photo") verbose_name_plural = _("photos") @@ -502,7 +552,7 @@ class Photo(ImageModel): super().save(*args, **kwargs) def get_absolute_url(self): - return reverse('photologue:pl-photo', args=[self.pk]) + return reverse("photologue:pl-photo", args=[self.pk]) def public_galleries(self): """Return the public galleries to which this photo belongs.""" @@ -513,10 +563,10 @@ class Photo(ImageModel): We assume that the gallery and all its photos are on the same site. """ if not self.is_public: - raise ValueError('Cannot determine neighbours of a non-public photo.') + raise ValueError("Cannot determine neighbours of a non-public photo.") photos = gallery.photos.filter(is_public=True) if self not in photos: - raise ValueError('Photo does not belong to gallery.') + raise ValueError("Photo does not belong to gallery.") previous = None for photo in photos: if photo == self: @@ -528,10 +578,10 @@ class Photo(ImageModel): We assume that the gallery and all its photos are on the same site. """ if not self.is_public: - raise ValueError('Cannot determine neighbours of a non-public photo.') + raise ValueError("Cannot determine neighbours of a non-public photo.") photos = gallery.photos.filter(is_public=True) if self not in photos: - raise ValueError('Photo does not belong to gallery.') + raise ValueError("Photo does not belong to gallery.") matched = False for photo in photos: if matched: @@ -546,51 +596,79 @@ class PhotoSize(models.Model): so the name has to follow the same restrictions as any Python method name, e.g. no spaces or non-ascii characters.""" - name = models.CharField(_('name'), - max_length=40, - unique=True, - help_text=_( - 'Photo size name should contain only letters, numbers and underscores. Examples: ' - '"thumbnail", "display", "small", "main_page_widget".'), - validators=[RegexValidator(regex='^[a-z0-9_]+$', - message='Use only plain lowercase letters (ASCII), numbers and ' - 'underscores.' - )] - ) - width = models.PositiveIntegerField(_('width'), - default=0, - help_text=_( - 'If width is set to "0" the image will be scaled to the supplied height.')) - height = models.PositiveIntegerField(_('height'), - default=0, - help_text=_( - 'If height is set to "0" the image will be scaled to the supplied width')) - quality = models.PositiveIntegerField(_('quality'), - choices=JPEG_QUALITY_CHOICES, - default=70, - help_text=_('JPEG image quality.')) - upscale = models.BooleanField(_('upscale images?'), - default=False, - help_text=_('If selected the image will be scaled up if necessary to fit the ' - 'supplied dimensions. Cropped sizes will be upscaled regardless of this ' - 'setting.') - ) - crop = models.BooleanField(_('crop to fit?'), - default=False, - help_text=_('If selected the image will be scaled and cropped to fit the supplied ' - 'dimensions.')) - pre_cache = models.BooleanField(_('pre-cache?'), - default=False, - help_text=_('If selected this photo size will be pre-cached as photos are added.')) - increment_count = models.BooleanField(_('increment view count?'), - default=False, - help_text=_('If selected the image\'s "view_count" will be incremented when ' - 'this photo size is displayed.')) + name = models.CharField( + _("name"), + max_length=40, + unique=True, + help_text=_( + "Photo size name should contain only letters, numbers and underscores. Examples: " + '"thumbnail", "display", "small", "main_page_widget".' + ), + validators=[ + RegexValidator( + regex="^[a-z0-9_]+$", + message="Use only plain lowercase letters (ASCII), numbers and " + "underscores.", + ) + ], + ) + width = models.PositiveIntegerField( + _("width"), + default=0, + help_text=_( + 'If width is set to "0" the image will be scaled to the supplied height.' + ), + ) + height = models.PositiveIntegerField( + _("height"), + default=0, + help_text=_( + 'If height is set to "0" the image will be scaled to the supplied width' + ), + ) + quality = models.PositiveIntegerField( + _("quality"), + choices=JPEG_QUALITY_CHOICES, + default=70, + help_text=_("JPEG image quality."), + ) + upscale = models.BooleanField( + _("upscale images?"), + default=False, + help_text=_( + "If selected the image will be scaled up if necessary to fit the " + "supplied dimensions. Cropped sizes will be upscaled regardless of this " + "setting." + ), + ) + crop = models.BooleanField( + _("crop to fit?"), + default=False, + help_text=_( + "If selected the image will be scaled and cropped to fit the supplied " + "dimensions." + ), + ) + pre_cache = models.BooleanField( + _("pre-cache?"), + default=False, + help_text=_( + "If selected this photo size will be pre-cached as photos are added." + ), + ) + increment_count = models.BooleanField( + _("increment view count?"), + default=False, + help_text=_( + 'If selected the image\'s "view_count" will be incremented when ' + "this photo size is displayed." + ), + ) class Meta: - ordering = ['width', 'height'] - verbose_name = _('photo size') - verbose_name_plural = _('photo sizes') + ordering = ["width", "height"] + verbose_name = _("photo size") + verbose_name_plural = _("photo sizes") def __str__(self): return self.name @@ -607,7 +685,10 @@ class PhotoSize(models.Model): if self.crop is True: if self.width == 0 or self.height == 0: raise ValidationError( - _("Can only crop photos if both width and height dimensions are set.")) + _( + "Can only crop photos if both width and height dimensions are set." + ) + ) def save(self, *args, **kwargs): super().save(*args, **kwargs) @@ -615,8 +696,12 @@ class PhotoSize(models.Model): self.clear_cache() 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) + 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, + ) self.clear_cache() super().delete() @@ -648,33 +733,41 @@ class PhotoSizeCache: def init_size_method_map(): global size_method_map for size in PhotoSizeCache().sizes.keys(): - size_method_map['get_%s_size' % size] = \ - {'base_name': '_get_size_size', 'size': size} - size_method_map['get_%s_photosize' % size] = \ - {'base_name': '_get_size_photosize', 'size': size} - size_method_map['get_%s_url' % size] = \ - {'base_name': '_get_size_url', 'size': size} - size_method_map['get_%s_filename' % size] = \ - {'base_name': '_get_size_filename', 'size': size} + size_method_map["get_%s_size" % size] = { + "base_name": "_get_size_size", + "size": size, + } + size_method_map["get_%s_photosize" % size] = { + "base_name": "_get_size_photosize", + "size": size, + } + size_method_map["get_%s_url" % size] = { + "base_name": "_get_size_url", + "size": size, + } + size_method_map["get_%s_filename" % size] = { + "base_name": "_get_size_filename", + "size": size, + } class Tag(models.Model): name = models.CharField( max_length=250, unique=True, - verbose_name=_('name'), + verbose_name=_("name"), ) slug = models.SlugField( unique=True, max_length=250, - verbose_name=_('slug'), + verbose_name=_("slug"), help_text=_('A "slug" is a unique URL-friendly title for an object.'), ) class Meta: - ordering = ['name'] - verbose_name = _('tag') - verbose_name_plural = _('tags') + ordering = ["name"] + verbose_name = _("tag") + verbose_name_plural = _("tags") def __str__(self): return self.name diff --git a/photologue/urls.py b/photologue/urls.py index bb47615..eaddfa0 100644 --- a/photologue/urls.py +++ b/photologue/urls.py @@ -1,19 +1,39 @@ from django.urls import path, re_path -from .views import (GalleryDetailView, GalleryArchiveIndexView, - GalleryDownload, GalleryUpload, GalleryYearArchiveView, - PhotoDetailView, PhotoDeleteView, PhotoReportView, TagDetail) +from .views import ( + GalleryArchiveIndexView, + GalleryDetailView, + GalleryDownload, + GalleryUpload, + GalleryYearArchiveView, + PhotoDeleteView, + PhotoDetailView, + PhotoReportView, + TagDetail, +) -app_name = 'photologue' +app_name = "photologue" urlpatterns = [ - path('tag/<slug:slug>/', TagDetail.as_view(), name='tag-detail'), - path('gallery/', GalleryArchiveIndexView.as_view(), name='pl-gallery-archive'), - re_path(r'^gallery/(?P<year>\d{4})/$', GalleryYearArchiveView.as_view(), name='pl-gallery-archive-year'), - path('gallery/<slug:slug>/', GalleryDetailView.as_view(), name='pl-gallery'), - path('gallery/<slug:slug>/<int:owner>/', GalleryDetailView.as_view(), name='pl-gallery-owner'), - path('gallery/<slug:slug>/download/', GalleryDownload.as_view(), 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('upload/', GalleryUpload.as_view(), name='pl-gallery-upload'), + path("tag/<slug:slug>/", TagDetail.as_view(), name="tag-detail"), + path("gallery/", GalleryArchiveIndexView.as_view(), name="pl-gallery-archive"), + re_path( + r"^gallery/(?P<year>\d{4})/$", + GalleryYearArchiveView.as_view(), + name="pl-gallery-archive-year", + ), + path("gallery/<slug:slug>/", GalleryDetailView.as_view(), name="pl-gallery"), + path( + "gallery/<slug:slug>/<int:owner>/", + GalleryDetailView.as_view(), + name="pl-gallery-owner", + ), + path( + "gallery/<slug:slug>/download/", + GalleryDownload.as_view(), + 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("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"), ] diff --git a/photologue/views.py b/photologue/views.py index 3df8fa6..0b245ec 100644 --- a/photologue/views.py +++ b/photologue/views.py @@ -7,18 +7,17 @@ from io import BytesIO from pathlib import Path 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_managers from django.db import IntegrityError from django.http import HttpResponse +from django.shortcuts import redirect from django.urls import reverse_lazy from django.utils.text import slugify from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.detail import DetailView -from django.views.generic.edit import FormView, DeleteView +from django.views.generic.edit import DeleteView, FormView from PIL import Image -from django.shortcuts import redirect from .forms import UploadForm from .models import Gallery, Photo, Tag @@ -26,7 +25,7 @@ from .models import Gallery, Photo, Tag class GalleryDateView(LoginRequiredMixin): model = Gallery - date_field = 'date_start' + date_field = "date_start" def get_queryset(self): """Hide galleries with only private photos""" @@ -59,19 +58,19 @@ class PhotoDetailView(LoginRequiredMixin, DetailView): class PhotoDeleteView(PermissionRequiredMixin, DeleteView): model = Photo - permission_required = 'photologue.delete_photo' + permission_required = "photologue.delete_photo" def get_success_url(self): galleries = self.object.galleries.all() if not galleries: - return reverse_lazy('photologue:pl-gallery-archive') + return reverse_lazy("photologue:pl-gallery-archive") slug = galleries[0].slug - return reverse_lazy('photologue:pl-gallery', args=[slug]) + return reverse_lazy("photologue:pl-gallery", args=[slug]) class PhotoReportView(LoginRequiredMixin, DetailView): model = Photo - template_name = 'photologue/photo_confirm_report.html' + template_name = "photologue/photo_confirm_report.html" def post(self, request, *args, **kwargs): """ @@ -84,10 +83,10 @@ class PhotoReportView(LoginRequiredMixin, DetailView): # Get gallery galleries = photo.galleries.all() - gallery_slug = galleries[0].slug if galleries else '' + 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]) + url = reverse_lazy("photologue:pl-gallery-archive") + url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug]) # Send mail to managers mail_managers( @@ -108,8 +107,9 @@ class TagDetail(LoginRequiredMixin, DetailView): """ current_tag = self.get_object().slug context = super().get_context_data(**kwargs) - context['galleries'] = Gallery.objects.filter(tags__slug=current_tag) \ - .order_by('-date_start') + context["galleries"] = Gallery.objects.filter(tags__slug=current_tag).order_by( + "-date_start" + ) return context @@ -117,6 +117,7 @@ class GalleryDetailView(LoginRequiredMixin, DetailView): """ Gallery detail view to filter on photo owner """ + model = Gallery def get_context_data(self, **kwargs): @@ -124,19 +125,19 @@ class GalleryDetailView(LoginRequiredMixin, DetailView): # Non-staff members only see public photos if self.request.user.is_staff: - context['photos'] = self.object.photos.all() + context["photos"] = self.object.photos.all() else: - context['photos'] = self.object.photos.filter(is_public=True) + context["photos"] = self.object.photos.filter(is_public=True) # List owners - context['owners'] = [] - for photo in context['photos']: - if photo.owner not in context['owners']: - context['owners'].append(photo.owner) + context["owners"] = [] + for photo in context["photos"]: + if photo.owner not in context["owners"]: + context["owners"].append(photo.owner) # Filter on owner - if 'owner' in self.kwargs: - context['photos'] = context['photos'].filter(owner__id=self.kwargs['owner']) + if "owner" in self.kwargs: + context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"]) return context @@ -158,8 +159,10 @@ class GalleryDownload(LoginRequiredMixin, DetailView): zip_file.close() # Return zip file - response = HttpResponse(byte_data.getvalue(), content_type='application/x-zip-compressed') - response['Content-Disposition'] = f"attachment; filename={gallery.slug}.zip" + response = HttpResponse( + byte_data.getvalue(), content_type="application/x-zip-compressed" + ) + response["Content-Disposition"] = f"attachment; filename={gallery.slug}.zip" return response @@ -167,15 +170,16 @@ class GalleryUpload(PermissionRequiredMixin, FormView): """ Form to upload new photos in a gallery """ + form_class = UploadForm template_name = "photologue/upload.html" success_url = reverse_lazy("photologue:pl-gallery-upload") - permission_required = 'photologue.add_gallery' + permission_required = "photologue.add_gallery" def form_valid(self, form): # Upload photos # We take files from the request to support multiple upload - files = self.request.FILES.getlist('file_field') + files = self.request.FILES.getlist("file_field") gallery = form.get_or_create_gallery() gallery_year = Path(str(gallery.date_start.year)) gallery_dir = gallery_year / gallery.slug @@ -187,7 +191,9 @@ class GalleryUpload(PermissionRequiredMixin, FormView): opened.verify() 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") + messages.error( + self.request, f"{photo_file.name} was not recognized as an image" + ) failed_upload += 1 continue @@ -203,7 +209,10 @@ class GalleryUpload(PermissionRequiredMixin, FormView): photo.save() photo.galleries.set([gallery]) except IntegrityError: - messages.error(self.request, f"{photo_file.name} was not uploaded. Maybe the photo was already uploaded.") + messages.error( + self.request, + f"{photo_file.name} was not uploaded. Maybe the photo was already uploaded.", + ) failed_upload += 1 # Notify user then managers @@ -211,9 +220,13 @@ class GalleryUpload(PermissionRequiredMixin, FormView): messages.success(self.request, "All photos has been successfully uploaded.") else: n_success = len(files) - failed_upload - messages.warning(self.request, f"Only {n_success} photos were successfully uploaded !") + messages.warning( + self.request, f"Only {n_success} photos were successfully uploaded !" + ) - gallery_title = form.cleaned_data['gallery'] or form.cleaned_data.get('new_gallery_title', '') + gallery_title = form.cleaned_data["gallery"] or form.cleaned_data.get( + "new_gallery_title", "" + ) photos = ", ".join(f.name for f in files) mail_managers( subject="New photos upload", -- GitLab