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