Format code using black

This commit is contained in:
Alexandre Iooss 2022-03-02 21:23:40 +01:00
parent 2ad0c8dbc7
commit 59136050fb
14 changed files with 809 additions and 413 deletions

View file

@ -5,40 +5,51 @@ from .models import Gallery, Photo, Tag
class GalleryAdmin(admin.ModelAdmin): class GalleryAdmin(admin.ModelAdmin):
list_display = ('title', 'date_start', 'photo_count', 'get_tags') list_display = ("title", "date_start", "photo_count", "get_tags")
list_filter = ['date_start', 'tags'] list_filter = ["date_start", "tags"]
date_hierarchy = 'date_start' date_hierarchy = "date_start"
prepopulated_fields = {'slug': ('title',)} prepopulated_fields = {"slug": ("title",)}
model = Gallery model = Gallery
exclude = ['photos'] exclude = ["photos"]
autocomplete_fields = ['tags'] autocomplete_fields = ["tags"]
search_fields = ['title', ] search_fields = [
"title",
]
def get_tags(self, obj): def get_tags(self, obj):
return ", ".join([t.name for t in obj.tags.all()]) return ", ".join([t.name for t in obj.tags.all()])
get_tags.short_description = _('tags')
get_tags.short_description = _("tags")
class PhotoAdmin(admin.ModelAdmin): class PhotoAdmin(admin.ModelAdmin):
list_display = ('title', 'date_taken', 'date_added', list_display = (
'is_public', 'view_count', 'admin_thumbnail', 'get_owner') "title",
list_filter = ['date_added', 'is_public', 'owner'] "date_taken",
search_fields = ['title', 'slug', 'caption'] "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 list_per_page = 25
prepopulated_fields = {'slug': ('title',)} prepopulated_fields = {"slug": ("title",)}
readonly_fields = ('date_taken',) readonly_fields = ("date_taken",)
model = Photo model = Photo
def get_owner(self, obj): def get_owner(self, obj):
return obj.owner.username 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): class TagAdmin(admin.ModelAdmin):
list_display = ('name',) list_display = ("name",)
search_fields = ('name',) search_fields = ("name",)
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {"slug": ("name",)}
model = Tag model = Tag

View file

@ -2,5 +2,5 @@ from django.apps import AppConfig
class PhotologueConfig(AppConfig): class PhotologueConfig(AppConfig):
default_auto_field = 'django.db.models.AutoField' default_auto_field = "django.db.models.AutoField"
name = 'photologue' name = "photologue"

View file

@ -12,40 +12,46 @@ from .models import Gallery, Tag
class UploadForm(forms.Form): class UploadForm(forms.Form):
file_field = forms.FileField( file_field = forms.FileField(
label="", label="",
widget=forms.FileInput(attrs={ widget=forms.FileInput(
'accept': 'image/*', attrs={
'multiple': True, "accept": "image/*",
'class': 'mb-3', "multiple": True,
}), "class": "mb-3",
}
),
) )
gallery = forms.ModelChoiceField( gallery = forms.ModelChoiceField(
Gallery.objects.all(), Gallery.objects.all(),
label=_('Gallery'), label=_("Gallery"),
required=False, required=False,
empty_label=_('-- Create a new gallery --'), empty_label=_("-- Create a new gallery --"),
help_text=_('Select a gallery to add these images to. Leave this empty to ' help_text=_(
'create a new gallery from the supplied title.') "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( new_gallery_title = forms.CharField(
label=_('New gallery title'), label=_("New gallery title"),
max_length=250, max_length=250,
required=False, required=False,
) )
new_gallery_date_start = forms.DateField( new_gallery_date_start = forms.DateField(
label=_('New gallery event start date'), label=_("New gallery event start date"),
initial=datetime.date.today, initial=datetime.date.today,
required=False, required=False,
) )
new_gallery_date_end = forms.DateField( new_gallery_date_end = forms.DateField(
label=_('New gallery event end date'), label=_("New gallery event end date"),
initial=datetime.date.today, initial=datetime.date.today,
required=False, required=False,
) )
new_gallery_tags = forms.ModelMultipleChoiceField( new_gallery_tags = forms.ModelMultipleChoiceField(
Tag.objects.all(), Tag.objects.all(),
label=_('New gallery tags'), label=_("New gallery tags"),
required=False, 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): def __init__(self, *args, **kwargs):
@ -53,31 +59,35 @@ class UploadForm(forms.Form):
self.helper = FormHelper() self.helper = FormHelper()
self.helper.use_custom_control = False self.helper.use_custom_control = False
self.helper.layout = Layout( self.helper.layout = Layout(
'file_field', "file_field",
'gallery', "gallery",
'new_gallery_title', "new_gallery_title",
Div( Div(
Div('new_gallery_date_start', css_class='col'), Div("new_gallery_date_start", css_class="col"),
Div('new_gallery_date_end', css_class='col'), Div("new_gallery_date_end", css_class="col"),
css_class='row' css_class="row",
), ),
'new_gallery_tags', "new_gallery_tags",
Submit('submit', _('Upload'), css_class='btn btn-success mt-2') Submit("submit", _("Upload"), css_class="btn btn-success mt-2"),
) )
def clean_new_gallery_title(self): 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(): 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 return title
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
# Check that either an existing gallery is chosen, or new_gallery_title is filled # 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( 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 return cleaned_data
@ -85,16 +95,16 @@ class UploadForm(forms.Form):
""" """
Get or create gallery Get or create gallery
""" """
gallery = self.cleaned_data['gallery'] gallery = self.cleaned_data["gallery"]
if not gallery: if not gallery:
# Create new gallery # Create new gallery
title = self.cleaned_data.get('new_gallery_title') title = self.cleaned_data.get("new_gallery_title")
gallery = Gallery.objects.create( gallery = Gallery.objects.create(
title=title, title=title,
slug=slugify(title), slug=slugify(title),
date_start=self.cleaned_data['new_gallery_date_start'], date_start=self.cleaned_data["new_gallery_date_start"],
date_end=self.cleaned_data['new_gallery_date_end'], 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) gallery.tags.add(tag)
return gallery return gallery

View file

@ -5,26 +5,36 @@ from photologue.models import Gallery
class Command(BaseCommand): class Command(BaseCommand):
help = 'List all duplicate for chosen galleries' help = "List all duplicate for chosen galleries"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
'--slugs', nargs='+', help='Try to find duplicate in the selected galleries', default=[]) "--slugs",
parser.add_argument('-a', '--all', action='store_true', nargs="+",
help='Try to find duplicate in all galleries, overide any slugs given') help="Try to find duplicate in the selected galleries",
parser.add_argument('-d', '--delete', action='store_true') 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): def handle(self, *args, **options):
# Collect all required galleries # Collect all required galleries
if options['all']: if options["all"]:
galleries = Gallery.objects.all() galleries = Gallery.objects.all()
else: else:
galleries = [] galleries = []
for slug in options['slugs']: for slug in options["slugs"]:
gallery_query = Gallery.objects.filter(slug=slug) gallery_query = Gallery.objects.filter(slug=slug)
if not gallery_query: if not gallery_query:
raise CommandError(f"Slug {slug} does not correspond to a " raise CommandError(
"gallery in the database.") f"Slug {slug} does not correspond to a "
"gallery in the database."
)
galleries += gallery_query galleries += gallery_query
# Find duplicates in all galleries # Find duplicates in all galleries
@ -32,18 +42,16 @@ class Command(BaseCommand):
duplicates = find_duplicate(gallery) duplicates = find_duplicate(gallery)
self.stdout.write(f"Gallery {gallery.slug}:") self.stdout.write(f"Gallery {gallery.slug}:")
for original, copies in duplicates: 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: for copy in copies:
self.stdout.write(f" {copy.slug}") self.stdout.write(f" {copy.slug}")
# Delete them if --delete # Delete them if --delete
if options['delete']: if options["delete"]:
self.stdout.write( self.stdout.write(" Deleting duplicate in {} :".format(gallery.slug))
' Deleting duplicate in {} :'.format(gallery.slug))
for (_original, copies) in duplicates: for (_original, copies) in duplicates:
for copy in copies: for copy in copies:
self.stdout.write( self.stdout.write(" Deleting {}...".format(copy.slug))
' Deleting {}...'.format(copy.slug))
copy.delete() copy.delete()

View file

@ -7,22 +7,21 @@ from photologue.models import ImageModel, PhotoSize
class Command(BaseCommand): 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): def add_arguments(self, parser):
parser.add_argument('sizes', parser.add_argument("sizes", nargs="*", type=str, help="Name of the photosize.")
nargs='*', parser.add_argument(
type=str, "--reset",
help='Name of the photosize.') action="store_true",
parser.add_argument('--reset', default=False,
action='store_true', dest="reset",
default=False, help="Reset photo cache before generating.",
dest='reset', )
help='Reset photo cache before generating.')
def handle(self, *args, **options): def handle(self, *args, **options):
reset = options['reset'] reset = options["reset"]
sizes = options['sizes'] sizes = options["sizes"]
if not sizes: if not sizes:
photosizes = PhotoSize.objects.all() photosizes = PhotoSize.objects.all()
@ -30,13 +29,13 @@ class Command(BaseCommand):
photosizes = PhotoSize.objects.filter(name__in=sizes) photosizes = PhotoSize.objects.filter(name__in=sizes)
if not len(photosizes): 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 cls in ImageModel.__subclasses__():
for photosize in photosizes: for photosize in photosizes:
print('Cacheing %s size images' % photosize.name) print("Cacheing %s size images" % photosize.name)
for obj in cls.objects.all(): for obj in cls.objects.all():
if reset: if reset:
obj.remove_size(photosize) obj.remove_size(photosize)

View file

@ -6,17 +6,15 @@ from photologue.models import PhotoSize
class Command(BaseCommand): class Command(BaseCommand):
help = ('Creates a new Photologue photo size interactively.') help = "Creates a new Photologue photo size interactively."
requires_model_validation = True requires_model_validation = True
can_import_settings = True can_import_settings = True
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('name', parser.add_argument("name", type=str, help="Name of the new photo size")
type=str,
help='Name of the new photo size')
def handle(self, *args, **options): def handle(self, *args, **options):
create_photosize(options['name']) create_photosize(options["name"])
def get_response(msg, func=int, default=None): def get_response(msg, func=int, default=None):
@ -27,10 +25,12 @@ def get_response(msg, func=int, default=None):
try: try:
return func(resp) return func(resp)
except Exception: 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: try:
size = PhotoSize.objects.get(name=name) size = PhotoSize.objects.get(name=name)
exists = True exists = True
@ -38,15 +38,20 @@ def create_photosize(name, width=0, height=0, crop=False, pre_cache=False, incre
size = PhotoSize(name=name) size = PhotoSize(name=name)
exists = False exists = False
if exists: if exists:
msg = 'A "%s" photo size already exists. Do you want to replace it? (yes, no):' % name msg = (
if not get_response(msg, lambda inp: inp == 'yes', False): '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 return
print('\nWe will now define the "%s" photo size:\n' % size) print('\nWe will now define the "%s" photo size:\n' % size)
w = get_response('Width (in pixels):', lambda inp: int(inp), width) w = get_response("Width (in pixels):", lambda inp: int(inp), width)
h = get_response('Height (in pixels):', lambda inp: int(inp), height) h = get_response("Height (in pixels):", lambda inp: int(inp), height)
c = get_response('Crop to fit? (yes, no):', lambda inp: inp == 'yes', crop) 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) 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) i = get_response(
"Increment count? (yes, no):", lambda inp: inp == "yes", increment_count
)
size.width = w size.width = w
size.height = h size.height = h
size.crop = c size.crop = c

View file

@ -6,16 +6,13 @@ from photologue.models import ImageModel, PhotoSize
class Command(BaseCommand): 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): def add_arguments(self, parser):
parser.add_argument('sizes', parser.add_argument("sizes", nargs="*", type=str, help="Name of the photosize.")
nargs='*',
type=str,
help='Name of the photosize.')
def handle(self, *args, **options): def handle(self, *args, **options):
sizes = options['sizes'] sizes = options["sizes"]
if not sizes: if not sizes:
photosizes = PhotoSize.objects.all() photosizes = PhotoSize.objects.all()
@ -23,12 +20,12 @@ class Command(BaseCommand):
photosizes = PhotoSize.objects.filter(name__in=sizes) photosizes = PhotoSize.objects.filter(name__in=sizes)
if not len(photosizes): 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 cls in ImageModel.__subclasses__():
for photosize in photosizes: for photosize in photosizes:
print('Flushing %s size images' % photosize.name) print("Flushing %s size images" % photosize.name)
for obj in cls.objects.all(): for obj in cls.objects.all():
obj.remove_size(photosize) obj.remove_size(photosize)

View file

@ -7,17 +7,17 @@ from photologue.models import Gallery
class Command(BaseCommand): 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): def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true') parser.add_argument("--apply", action="store_true")
def handle(self, *args, **options): def handle(self, *args, **options):
media_dir = Path(settings.MEDIA_ROOT) media_dir = Path(settings.MEDIA_ROOT)
for gallery in Gallery.objects.all(): for gallery in Gallery.objects.all():
# Create gallery directory # Create gallery directory
gallery_year = str(gallery.date_start.year) 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 gallery_path = media_dir / gallery_dir
if not gallery_path.exists(): if not gallery_path.exists():
self.stdout.write(f"Creating {gallery_dir}") self.stdout.write(f"Creating {gallery_dir}")

View file

@ -1,10 +1,11 @@
# Generated by Django 3.2.11 on 2022-01-30 10:14 # Generated by Django 3.2.11 on 2022-01-30 10:14
from django.conf import settings
import django.core.validators import django.core.validators
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
import photologue.models import photologue.models
@ -18,79 +19,313 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='PhotoSize', name="PhotoSize",
fields=[ 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')), "id",
('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')), models.AutoField(
('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')), auto_created=True,
('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')), primary_key=True,
('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?')), serialize=False,
('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?')), verbose_name="ID",
('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?')), ),
(
"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={ options={
'verbose_name': 'photo size', "verbose_name": "photo size",
'verbose_name_plural': 'photo sizes', "verbose_name_plural": "photo sizes",
'ordering': ['width', 'height'], "ordering": ["width", "height"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Tag', name="Tag",
fields=[ 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')), "id",
('slug', models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='slug')), 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={ options={
'verbose_name': 'tag', "verbose_name": "tag",
'verbose_name_plural': 'tags', "verbose_name_plural": "tags",
'ordering': ['name'], "ordering": ["name"],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Photo', name="Photo",
fields=[ 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')), "id",
('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')), models.AutoField(
('view_count', models.PositiveIntegerField(default=0, editable=False, verbose_name='view count')), auto_created=True,
('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')), primary_key=True,
('title', models.CharField(max_length=250, unique=True, verbose_name='title')), serialize=False,
('slug', models.SlugField(help_text='A "slug" is a unique URL-friendly title for an object.', max_length=250, unique=True, verbose_name='slug')), verbose_name="ID",
('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')), "image",
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='owner')), 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={ options={
'verbose_name': 'photo', "verbose_name": "photo",
'verbose_name_plural': 'photos', "verbose_name_plural": "photos",
'ordering': ['-date_added'], "ordering": ["-date_added"],
'get_latest_by': 'date_added', "get_latest_by": "date_added",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Gallery', name="Gallery",
fields=[ 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')), "id",
('title', models.CharField(max_length=250, unique=True, verbose_name='title')), models.AutoField(
('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')), auto_created=True,
('date_start', models.DateField(default=django.utils.timezone.now, verbose_name='start date')), primary_key=True,
('date_end', models.DateField(blank=True, null=True, verbose_name='end date')), serialize=False,
('description', models.TextField(blank=True, verbose_name='description')), verbose_name="ID",
('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')), (
"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={ options={
'verbose_name': 'gallery', "verbose_name": "gallery",
'verbose_name_plural': 'galleries', "verbose_name_plural": "galleries",
'ordering': ['-date_added'], "ordering": ["-date_added"],
'get_latest_by': 'date_added', "get_latest_by": "date_added",
}, },
), ),
] ]

View file

@ -6,16 +6,21 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('photologue', '0001_initial'), ("photologue", "0001_initial"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='gallery', name="gallery",
options={'get_latest_by': 'date_start', 'ordering': ['-date_start'], 'verbose_name': 'gallery', 'verbose_name_plural': 'galleries'}, options={
"get_latest_by": "date_start",
"ordering": ["-date_start"],
"verbose_name": "gallery",
"verbose_name_plural": "galleries",
},
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='gallery', model_name="gallery",
name='date_added', name="date_added",
), ),
] ]

View file

@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('photologue', '0002_auto_20220130_1020'), ("photologue", "0002_auto_20220130_1020"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='gallery', model_name="gallery",
name='is_public', name="is_public",
), ),
] ]

View file

@ -25,31 +25,37 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageFile, ImageFilter from PIL import Image, ImageFile, ImageFilter
logger = logging.getLogger('photologue.models') logger = logging.getLogger("photologue.models")
# Default limit for gallery.latest # 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 # 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. # 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 # 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 PHOTOLOGUE_PATH is not None:
if callable(PHOTOLOGUE_PATH): if callable(PHOTOLOGUE_PATH):
get_storage_path = PHOTOLOGUE_PATH get_storage_path = PHOTOLOGUE_PATH
else: else:
parts = PHOTOLOGUE_PATH.split('.') parts = PHOTOLOGUE_PATH.split(".")
module_name = '.'.join(parts[:-1]) module_name = ".".join(parts[:-1])
module = import_module(module_name) module = import_module(module_name)
get_storage_path = getattr(module, parts[-1]) get_storage_path = getattr(module, parts[-1])
else: else:
def get_storage_path(instance, filename): def get_storage_path(instance, filename):
fn = unicodedata.normalize('NFKD', force_str(filename)).encode('ascii', 'ignore').decode('ascii') fn = (
return os.path.join('photos', fn) unicodedata.normalize("NFKD", force_str(filename))
.encode("ascii", "ignore")
.decode("ascii")
)
return os.path.join("photos", fn)
# Exif Orientation values # Exif Orientation values
# Value 0thRow 0thColumn # Value 0thRow 0thColumn
@ -73,42 +79,47 @@ IMAGE_EXIF_ORIENTATION_MAP = {
# Quality options for JPEG images # Quality options for JPEG images
JPEG_QUALITY_CHOICES = ( JPEG_QUALITY_CHOICES = (
(30, _('Very Low')), (30, _("Very Low")),
(40, _('Low')), (40, _("Low")),
(50, _('Medium-Low')), (50, _("Medium-Low")),
(60, _('Medium')), (60, _("Medium")),
(70, _('Medium-High')), (70, _("Medium-High")),
(80, _('High')), (80, _("High")),
(90, _('Very High')), (90, _("Very High")),
) )
# choices for new crop_anchor field in Photo # choices for new crop_anchor field in Photo
CROP_ANCHOR_CHOICES = ( CROP_ANCHOR_CHOICES = (
('top', _('Top')), ("top", _("Top")),
('right', _('Right')), ("right", _("Right")),
('bottom', _('Bottom')), ("bottom", _("Bottom")),
('left', _('Left')), ("left", _("Left")),
('center', _('Center (Default)')), ("center", _("Center (Default)")),
) )
IMAGE_TRANSPOSE_CHOICES = ( IMAGE_TRANSPOSE_CHOICES = (
('FLIP_LEFT_RIGHT', _('Flip left to right')), ("FLIP_LEFT_RIGHT", _("Flip left to right")),
('FLIP_TOP_BOTTOM', _('Flip top to bottom')), ("FLIP_TOP_BOTTOM", _("Flip top to bottom")),
('ROTATE_90', _('Rotate 90 degrees counter-clockwise')), ("ROTATE_90", _("Rotate 90 degrees counter-clockwise")),
('ROTATE_270', _('Rotate 90 degrees clockwise')), ("ROTATE_270", _("Rotate 90 degrees clockwise")),
('ROTATE_180', _('Rotate 180 degrees')), ("ROTATE_180", _("Rotate 180 degrees")),
) )
# Prepare a list of image filters # Prepare a list of image filters
filter_names = [] filter_names = []
for n in dir(ImageFilter): for n in dir(ImageFilter):
klass = getattr(ImageFilter, n) klass = getattr(ImageFilter, n)
if isclass(klass) and issubclass(klass, ImageFilter.BuiltinFilter) and \ if (
hasattr(klass, 'name'): isclass(klass)
and issubclass(klass, ImageFilter.BuiltinFilter)
and hasattr(klass, "name")
):
filter_names.append(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_HELP_TEXT = _(
'. Image filters will be applied in order. The following filters are available: %s.' 'Chain multiple filters using the following pattern "FILTER_ONE->FILTER_TWO->FILTER_THREE"'
% (', '.join(filter_names))) ". Image filters will be applied in order. The following filters are available: %s."
% (", ".join(filter_names))
)
size_method_map = {} size_method_map = {}
@ -119,22 +130,22 @@ class TagField(models.CharField):
""" """
def __init__(self, **kwargs): def __init__(self, **kwargs):
default_kwargs = {'max_length': 255, 'blank': True} default_kwargs = {"max_length": 255, "blank": True}
default_kwargs.update(kwargs) default_kwargs.update(kwargs)
super().__init__(**default_kwargs) super().__init__(**default_kwargs)
def get_internal_type(self): def get_internal_type(self):
return 'CharField' return "CharField"
class Gallery(models.Model): class Gallery(models.Model):
title = models.CharField(_('title'), title = models.CharField(_("title"), max_length=250, unique=True)
max_length=250, slug = models.SlugField(
unique=True) _("title slug"),
slug = models.SlugField(_('title slug'), unique=True,
unique=True, max_length=250,
max_length=250, help_text=_('A "slug" is a unique URL-friendly title for an object.'),
help_text=_('A "slug" is a unique URL-friendly title for an object.')) )
date_start = models.DateField( date_start = models.DateField(
default=now, default=now,
verbose_name=_("start date"), verbose_name=_("start date"),
@ -144,30 +155,31 @@ class Gallery(models.Model):
null=True, null=True,
verbose_name=_("end date"), verbose_name=_("end date"),
) )
description = models.TextField(_('description'), description = models.TextField(_("description"), blank=True)
blank=True)
tags = models.ManyToManyField( tags = models.ManyToManyField(
'photologue.Tag', "photologue.Tag",
related_name='galleries', related_name="galleries",
verbose_name=_('tags'), verbose_name=_("tags"),
blank=True,
)
photos = models.ManyToManyField(
"photologue.Photo",
related_name="galleries",
verbose_name=_("photos"),
blank=True, blank=True,
) )
photos = models.ManyToManyField('photologue.Photo',
related_name='galleries',
verbose_name=_('photos'),
blank=True)
class Meta: class Meta:
ordering = ['-date_start'] ordering = ["-date_start"]
get_latest_by = 'date_start' get_latest_by = "date_start"
verbose_name = _('gallery') verbose_name = _("gallery")
verbose_name_plural = _('galleries') verbose_name_plural = _("galleries")
def __str__(self): def __str__(self):
return f"{ self.title } ({self.date_start})" return f"{ self.title } ({self.date_start})"
def get_absolute_url(self): 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): def sample(self, public=True):
"""Return a sample of photos, ordered at random.""" """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 a count of private photos in this gallery."""
return self.photos.filter(is_public=False).count() return self.photos.filter(is_public=False).count()
photo_count.short_description = _('count') photo_count.short_description = _("count")
photo_private_count.short_description = _('private count') photo_private_count.short_description = _("private count")
class ImageModel(models.Model): class ImageModel(models.Model):
image = models.ImageField(_('image'), image = models.ImageField(
max_length=IMAGE_FIELD_MAX_LENGTH, _("image"), max_length=IMAGE_FIELD_MAX_LENGTH, upload_to=get_storage_path
upload_to=get_storage_path) )
date_taken = models.DateTimeField(_('date taken'), date_taken = models.DateTimeField(
null=True, _("date taken"),
blank=True, null=True,
help_text=_('Date image was taken; is obtained from the image EXIF data.')) blank=True,
view_count = models.PositiveIntegerField(_('view count'), help_text=_("Date image was taken; is obtained from the image EXIF data."),
default=0, )
editable=False) view_count = models.PositiveIntegerField(_("view count"), default=0, editable=False)
crop_from = models.CharField(_('crop from'), crop_from = models.CharField(
blank=True, _("crop from"),
max_length=10, blank=True,
default='center', max_length=10,
choices=CROP_ANCHOR_CHOICES) default="center",
choices=CROP_ANCHOR_CHOICES,
)
class Meta: class Meta:
abstract = True abstract = True
@ -220,38 +234,44 @@ class ImageModel(models.Model):
if file: if file:
tags = exifread.process_file(file) tags = exifread.process_file(file)
else: 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) tags = exifread.process_file(file, details=False)
return tags return tags
except Exception: except Exception:
return {} return {}
def admin_thumbnail(self): def admin_thumbnail(self):
func = getattr(self, 'get_admin_thumbnail_url', None) func = getattr(self, "get_admin_thumbnail_url", None)
if func is None: if func is None:
return _('An "admin_thumbnail" photo size has not been defined.') return _('An "admin_thumbnail" photo size has not been defined.')
else: else:
if hasattr(self, 'get_absolute_url'): if hasattr(self, "get_absolute_url"):
return mark_safe('<a href="{}"><img src="{}"></a>'.format(self.get_absolute_url(), func())) return mark_safe(
'<a href="{}"><img src="{}"></a>'.format(
self.get_absolute_url(), func()
)
)
else: 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 admin_thumbnail.allow_tags = True
def cache_path(self): def cache_path(self):
return os.path.join(os.path.dirname(self.image.name), "cache") return os.path.join(os.path.dirname(self.image.name), "cache")
def cache_url(self): 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): def image_filename(self):
return os.path.basename(force_str(self.image.name)) return os.path.basename(force_str(self.image.name))
def _get_filename_for_size(self, size): 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()) base, ext = os.path.splitext(self.image_filename())
return ''.join([base, '_', size, ext]) return "".join([base, "_", size, ext])
def _get_size_photosize(self, size): def _get_size_photosize(self, size):
return PhotoSizeCache().sizes.get(size) return PhotoSizeCache().sizes.get(size)
@ -261,8 +281,9 @@ class ImageModel(models.Model):
if not self.size_exists(photosize): if not self.size_exists(photosize):
self.create_size(photosize) self.create_size(photosize)
try: try:
return Image.open(self.image.storage.open( return Image.open(
self._get_size_filename(size))).size self.image.storage.open(self._get_size_filename(size))
).size
except Exception: except Exception:
return None return None
@ -272,14 +293,18 @@ class ImageModel(models.Model):
self.create_size(photosize) self.create_size(photosize)
if photosize.increment_count: if photosize.increment_count:
self.increment_count() self.increment_count()
return '/'.join([ return "/".join(
self.cache_url(), [
filepath_to_uri(self._get_filename_for_size(photosize.name))]) self.cache_url(),
filepath_to_uri(self._get_filename_for_size(photosize.name)),
]
)
def _get_size_filename(self, size): def _get_size_filename(self, size):
photosize = PhotoSizeCache().sizes.get(size) photosize = PhotoSizeCache().sizes.get(size)
return smart_str(os.path.join(self.cache_path(), return smart_str(
self._get_filename_for_size(photosize.name))) os.path.join(self.cache_path(), self._get_filename_for_size(photosize.name))
)
def increment_count(self): def increment_count(self):
self.view_count += 1 self.view_count += 1
@ -291,7 +316,7 @@ class ImageModel(models.Model):
init_size_method_map() init_size_method_map()
di = size_method_map.get(name, None) di = size_method_map.get(name, None)
if di is not 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) setattr(self, name, result)
return result return result
else: else:
@ -313,38 +338,45 @@ class ImageModel(models.Model):
new_width, new_height = photosize.size new_width, new_height = photosize.size
if photosize.crop: if photosize.crop:
ratio = max(float(new_width) / cur_width, float(new_height) / cur_height) ratio = max(float(new_width) / cur_width, float(new_height) / cur_height)
x = (cur_width * ratio) x = cur_width * ratio
y = (cur_height * ratio) y = cur_height * ratio
xd = abs(new_width - x) xd = abs(new_width - x)
yd = abs(new_height - y) yd = abs(new_height - y)
x_diff = int(xd / 2) x_diff = int(xd / 2)
y_diff = int(yd / 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) 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)) 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 # y - yd = new_height
box = (int(x_diff), int(yd), int(x_diff + new_width), int(y)) 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 # x - xd = new_width
box = (int(xd), int(y_diff), int(x), int(y_diff + new_height)) box = (int(xd), int(y_diff), int(x), int(y_diff + new_height))
else: 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) im = im.resize((int(x), int(y)), Image.ANTIALIAS).crop(box)
else: else:
if not new_width == 0 and not new_height == 0: if not new_width == 0 and not new_height == 0:
ratio = min(float(new_width) / cur_width, ratio = min(
float(new_height) / cur_height) float(new_width) / cur_width, float(new_height) / cur_height
)
else: else:
if new_width == 0: if new_width == 0:
ratio = float(new_height) / cur_height ratio = float(new_height) / cur_height
else: else:
ratio = float(new_width) / cur_width ratio = float(new_width) / cur_width
new_dimensions = (int(round(cur_width * ratio)), new_dimensions = (
int(round(cur_height * ratio))) int(round(cur_width * ratio)),
if new_dimensions[0] > cur_width or \ int(round(cur_height * ratio)),
new_dimensions[1] > cur_height: )
if new_dimensions[0] > cur_width or new_dimensions[1] > cur_height:
if not photosize.upscale: if not photosize.upscale:
return im return im
im = im.resize(new_dimensions, Image.ANTIALIAS) im = im.resize(new_dimensions, Image.ANTIALIAS)
@ -360,10 +392,16 @@ class ImageModel(models.Model):
# Save the original format # Save the original format
im_format = im.format im_format = im.format
# Rotate if found & necessary # Rotate if found & necessary
if 'Image Orientation' in self.exif() and \ if (
self.exif().get('Image Orientation').values[0] in IMAGE_EXIF_ORIENTATION_MAP: "Image Orientation" in self.exif()
and self.exif().get("Image Orientation").values[0]
in IMAGE_EXIF_ORIENTATION_MAP
):
im = im.transpose( 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 # Resize/crop image
if (im.size != photosize.size and photosize.size != (0, 0)) or recreate: if (im.size != photosize.size and photosize.size != (0, 0)) or recreate:
im = self.resize_image(im, photosize) im = self.resize_image(im, photosize)
@ -371,14 +409,13 @@ class ImageModel(models.Model):
im_filename = getattr(self, "get_%s_filename" % photosize.name)() im_filename = getattr(self, "get_%s_filename" % photosize.name)()
try: try:
buffer = BytesIO() buffer = BytesIO()
if im_format != 'JPEG': if im_format != "JPEG":
im.save(buffer, im_format) im.save(buffer, im_format)
else: else:
# Issue #182 - test fix from https://github.com/bashu/django-watermark/issues/31 # 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 = im.convert(im.mode[:-1])
im.save(buffer, 'JPEG', quality=int(photosize.quality), im.save(buffer, "JPEG", quality=int(photosize.quality), optimize=True)
optimize=True)
buffer_contents = ContentFile(buffer.getvalue()) buffer_contents = ContentFile(buffer.getvalue())
self.image.storage.save(im_filename, buffer_contents) self.image.storage.save(im_filename, buffer_contents)
except OSError as e: except OSError as e:
@ -411,7 +448,7 @@ class ImageModel(models.Model):
self._old_image = self.image self._old_image = self.image
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
recreate = kwargs.pop('recreate', False) recreate = kwargs.pop("recreate", False)
image_has_changed = False image_has_changed = False
if self._get_pk_val() and (self._old_image != self.image): if self._get_pk_val() and (self._old_image != self.image):
image_has_changed = True image_has_changed = True
@ -423,26 +460,39 @@ class ImageModel(models.Model):
self.image = self._old_image self.image = self._old_image
self.clear_cache() self.clear_cache()
self.image = new_image # Back to the new image. 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: if self.date_taken is None or image_has_changed:
# Attempt to get the date the photo was taken from the EXIF data. # Attempt to get the date the photo was taken from the EXIF data.
try: 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: if exif_date is not None:
d, t = exif_date.values.split() d, t = exif_date.values.split()
year, month, day = d.split(':') year, month, day = d.split(":")
hour, minute, second = t.split(':') hour, minute, second = t.split(":")
self.date_taken = datetime(int(year), int(month), int(day), self.date_taken = datetime(
int(hour), int(minute), int(second)) int(year),
int(month),
int(day),
int(hour),
int(minute),
int(second),
)
except Exception: 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) super().save(*args, **kwargs)
self.pre_cache(recreate) self.pre_cache(recreate)
def delete(self): def delete(self):
assert self._get_pk_val() is not None, \ assert (
"%s object can't be deleted because its %s attribute is set to None." % \ self._get_pk_val() is not None
(self._meta.object_name, self._meta.pk.attname) ), "%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() self.clear_cache()
# Files associated to a FileField have to be manually deleted: # 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 # 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): class Photo(ImageModel):
title = models.CharField(_('title'), title = models.CharField(_("title"), max_length=250, unique=True)
max_length=250, slug = models.SlugField(
unique=True) _("slug"),
slug = models.SlugField(_('slug'), unique=True,
unique=True, max_length=250,
max_length=250, help_text=_('A "slug" is a unique URL-friendly title for an object.'),
help_text=_('A "slug" is a unique URL-friendly title for an object.')) )
caption = models.TextField(_('caption'), caption = models.TextField(_("caption"), blank=True)
blank=True) date_added = models.DateTimeField(_("date added"), default=now)
date_added = models.DateTimeField(_('date added'),
default=now)
owner = models.ForeignKey( owner = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -475,15 +523,17 @@ class Photo(ImageModel):
blank=True, blank=True,
verbose_name=_("license"), verbose_name=_("license"),
) )
is_public = models.BooleanField(_('is public'), is_public = models.BooleanField(
default=True, _("is public"),
help_text=_('Public photographs will be displayed in the default views.')) default=True,
help_text=_("Public photographs will be displayed in the default views."),
)
class Meta: class Meta:
# We do not have a reliable date for ordering, so let's use # We do not have a reliable date for ordering, so let's use
# the title which is incremented by most cameras # the title which is incremented by most cameras
ordering = ['title'] ordering = ["title"]
get_latest_by = 'date_added' get_latest_by = "date_added"
verbose_name = _("photo") verbose_name = _("photo")
verbose_name_plural = _("photos") verbose_name_plural = _("photos")
@ -502,7 +552,7 @@ class Photo(ImageModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_absolute_url(self): 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): def public_galleries(self):
"""Return the public galleries to which this photo belongs.""" """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. We assume that the gallery and all its photos are on the same site.
""" """
if not self.is_public: 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) photos = gallery.photos.filter(is_public=True)
if self not in photos: if self not in photos:
raise ValueError('Photo does not belong to gallery.') raise ValueError("Photo does not belong to gallery.")
previous = None previous = None
for photo in photos: for photo in photos:
if photo == self: if photo == self:
@ -528,10 +578,10 @@ class Photo(ImageModel):
We assume that the gallery and all its photos are on the same site. We assume that the gallery and all its photos are on the same site.
""" """
if not self.is_public: 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) photos = gallery.photos.filter(is_public=True)
if self not in photos: if self not in photos:
raise ValueError('Photo does not belong to gallery.') raise ValueError("Photo does not belong to gallery.")
matched = False matched = False
for photo in photos: for photo in photos:
if matched: if matched:
@ -546,51 +596,79 @@ class PhotoSize(models.Model):
so the name has to follow the same restrictions as any Python method name, so the name has to follow the same restrictions as any Python method name,
e.g. no spaces or non-ascii characters.""" e.g. no spaces or non-ascii characters."""
name = models.CharField(_('name'), name = models.CharField(
max_length=40, _("name"),
unique=True, max_length=40,
help_text=_( unique=True,
'Photo size name should contain only letters, numbers and underscores. Examples: ' help_text=_(
'"thumbnail", "display", "small", "main_page_widget".'), "Photo size name should contain only letters, numbers and underscores. Examples: "
validators=[RegexValidator(regex='^[a-z0-9_]+$', '"thumbnail", "display", "small", "main_page_widget".'
message='Use only plain lowercase letters (ASCII), numbers and ' ),
'underscores.' validators=[
)] RegexValidator(
) regex="^[a-z0-9_]+$",
width = models.PositiveIntegerField(_('width'), message="Use only plain lowercase letters (ASCII), numbers and "
default=0, "underscores.",
help_text=_( )
'If width is set to "0" the image will be scaled to the supplied height.')) ],
height = models.PositiveIntegerField(_('height'), )
default=0, width = models.PositiveIntegerField(
help_text=_( _("width"),
'If height is set to "0" the image will be scaled to the supplied width')) default=0,
quality = models.PositiveIntegerField(_('quality'), help_text=_(
choices=JPEG_QUALITY_CHOICES, 'If width is set to "0" the image will be scaled to the supplied height.'
default=70, ),
help_text=_('JPEG image quality.')) )
upscale = models.BooleanField(_('upscale images?'), height = models.PositiveIntegerField(
default=False, _("height"),
help_text=_('If selected the image will be scaled up if necessary to fit the ' default=0,
'supplied dimensions. Cropped sizes will be upscaled regardless of this ' help_text=_(
'setting.') 'If height is set to "0" the image will be scaled to the supplied width'
) ),
crop = models.BooleanField(_('crop to fit?'), )
default=False, quality = models.PositiveIntegerField(
help_text=_('If selected the image will be scaled and cropped to fit the supplied ' _("quality"),
'dimensions.')) choices=JPEG_QUALITY_CHOICES,
pre_cache = models.BooleanField(_('pre-cache?'), default=70,
default=False, help_text=_("JPEG image quality."),
help_text=_('If selected this photo size will be pre-cached as photos are added.')) )
increment_count = models.BooleanField(_('increment view count?'), upscale = models.BooleanField(
default=False, _("upscale images?"),
help_text=_('If selected the image\'s "view_count" will be incremented when ' default=False,
'this photo size is displayed.')) 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: class Meta:
ordering = ['width', 'height'] ordering = ["width", "height"]
verbose_name = _('photo size') verbose_name = _("photo size")
verbose_name_plural = _('photo sizes') verbose_name_plural = _("photo sizes")
def __str__(self): def __str__(self):
return self.name return self.name
@ -607,7 +685,10 @@ class PhotoSize(models.Model):
if self.crop is True: if self.crop is True:
if self.width == 0 or self.height == 0: if self.width == 0 or self.height == 0:
raise ValidationError( 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): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -615,8 +696,12 @@ class PhotoSize(models.Model):
self.clear_cache() self.clear_cache()
def delete(self): 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." \ assert (
% (self._meta.object_name, self._meta.pk.attname) 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() self.clear_cache()
super().delete() super().delete()
@ -648,33 +733,41 @@ class PhotoSizeCache:
def init_size_method_map(): def init_size_method_map():
global size_method_map global size_method_map
for size in PhotoSizeCache().sizes.keys(): for size in PhotoSizeCache().sizes.keys():
size_method_map['get_%s_size' % size] = \ size_method_map["get_%s_size" % size] = {
{'base_name': '_get_size_size', 'size': size} "base_name": "_get_size_size",
size_method_map['get_%s_photosize' % size] = \ "size": size,
{'base_name': '_get_size_photosize', 'size': size} }
size_method_map['get_%s_url' % size] = \ size_method_map["get_%s_photosize" % size] = {
{'base_name': '_get_size_url', 'size': size} "base_name": "_get_size_photosize",
size_method_map['get_%s_filename' % size] = \ "size": size,
{'base_name': '_get_size_filename', '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): class Tag(models.Model):
name = models.CharField( name = models.CharField(
max_length=250, max_length=250,
unique=True, unique=True,
verbose_name=_('name'), verbose_name=_("name"),
) )
slug = models.SlugField( slug = models.SlugField(
unique=True, unique=True,
max_length=250, max_length=250,
verbose_name=_('slug'), verbose_name=_("slug"),
help_text=_('A "slug" is a unique URL-friendly title for an object.'), help_text=_('A "slug" is a unique URL-friendly title for an object.'),
) )
class Meta: class Meta:
ordering = ['name'] ordering = ["name"]
verbose_name = _('tag') verbose_name = _("tag")
verbose_name_plural = _('tags') verbose_name_plural = _("tags")
def __str__(self): def __str__(self):
return self.name return self.name

View file

@ -1,19 +1,39 @@
from django.urls import path, re_path from django.urls import path, re_path
from .views import (GalleryDetailView, GalleryArchiveIndexView, from .views import (
GalleryDownload, GalleryUpload, GalleryYearArchiveView, GalleryArchiveIndexView,
PhotoDetailView, PhotoDeleteView, PhotoReportView, TagDetail) GalleryDetailView,
GalleryDownload,
GalleryUpload,
GalleryYearArchiveView,
PhotoDeleteView,
PhotoDetailView,
PhotoReportView,
TagDetail,
)
app_name = 'photologue' app_name = "photologue"
urlpatterns = [ urlpatterns = [
path('tag/<slug:slug>/', TagDetail.as_view(), name='tag-detail'), path("tag/<slug:slug>/", TagDetail.as_view(), name="tag-detail"),
path('gallery/', GalleryArchiveIndexView.as_view(), name='pl-gallery-archive'), 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'), re_path(
path('gallery/<slug:slug>/', GalleryDetailView.as_view(), name='pl-gallery'), r"^gallery/(?P<year>\d{4})/$",
path('gallery/<slug:slug>/<int:owner>/', GalleryDetailView.as_view(), name='pl-gallery-owner'), GalleryYearArchiveView.as_view(),
path('gallery/<slug:slug>/download/', GalleryDownload.as_view(), name='pl-gallery-download'), name="pl-gallery-archive-year",
path('photo/<int:pk>/', PhotoDetailView.as_view(), name='pl-photo'), ),
path('photo/<int:pk>/delete/', PhotoDeleteView.as_view(), name='pl-photo-delete'), path("gallery/<slug:slug>/", GalleryDetailView.as_view(), name="pl-gallery"),
path('photo/<int:pk>/report/', PhotoReportView.as_view(), name='pl-photo-report'), path(
path('upload/', GalleryUpload.as_view(), name='pl-gallery-upload'), "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"),
] ]

View file

@ -7,18 +7,17 @@ from io import BytesIO
from pathlib import Path from pathlib import Path
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import (LoginRequiredMixin, from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
PermissionRequiredMixin)
from django.core.mail import mail_managers from django.core.mail import mail_managers
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.edit import FormView, DeleteView from django.views.generic.edit import DeleteView, FormView
from PIL import Image from PIL import Image
from django.shortcuts import redirect
from .forms import UploadForm from .forms import UploadForm
from .models import Gallery, Photo, Tag from .models import Gallery, Photo, Tag
@ -26,7 +25,7 @@ from .models import Gallery, Photo, Tag
class GalleryDateView(LoginRequiredMixin): class GalleryDateView(LoginRequiredMixin):
model = Gallery model = Gallery
date_field = 'date_start' date_field = "date_start"
def get_queryset(self): def get_queryset(self):
"""Hide galleries with only private photos""" """Hide galleries with only private photos"""
@ -59,19 +58,19 @@ class PhotoDetailView(LoginRequiredMixin, DetailView):
class PhotoDeleteView(PermissionRequiredMixin, DeleteView): class PhotoDeleteView(PermissionRequiredMixin, DeleteView):
model = Photo model = Photo
permission_required = 'photologue.delete_photo' permission_required = "photologue.delete_photo"
def get_success_url(self): def get_success_url(self):
galleries = self.object.galleries.all() galleries = self.object.galleries.all()
if not galleries: if not galleries:
return reverse_lazy('photologue:pl-gallery-archive') return reverse_lazy("photologue:pl-gallery-archive")
slug = galleries[0].slug slug = galleries[0].slug
return reverse_lazy('photologue:pl-gallery', args=[slug]) return reverse_lazy("photologue:pl-gallery", args=[slug])
class PhotoReportView(LoginRequiredMixin, DetailView): class PhotoReportView(LoginRequiredMixin, DetailView):
model = Photo model = Photo
template_name = 'photologue/photo_confirm_report.html' template_name = "photologue/photo_confirm_report.html"
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
@ -84,10 +83,10 @@ class PhotoReportView(LoginRequiredMixin, DetailView):
# Get gallery # Get gallery
galleries = photo.galleries.all() galleries = photo.galleries.all()
gallery_slug = galleries[0].slug if galleries else '' gallery_slug = galleries[0].slug if galleries else ""
if not gallery_slug: if not gallery_slug:
url = reverse_lazy('photologue:pl-gallery-archive') url = reverse_lazy("photologue:pl-gallery-archive")
url = reverse_lazy('photologue:pl-gallery', args=[gallery_slug]) url = reverse_lazy("photologue:pl-gallery", args=[gallery_slug])
# Send mail to managers # Send mail to managers
mail_managers( mail_managers(
@ -108,8 +107,9 @@ class TagDetail(LoginRequiredMixin, DetailView):
""" """
current_tag = self.get_object().slug current_tag = self.get_object().slug
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['galleries'] = Gallery.objects.filter(tags__slug=current_tag) \ context["galleries"] = Gallery.objects.filter(tags__slug=current_tag).order_by(
.order_by('-date_start') "-date_start"
)
return context return context
@ -117,6 +117,7 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
""" """
Gallery detail view to filter on photo owner Gallery detail view to filter on photo owner
""" """
model = Gallery model = Gallery
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -124,19 +125,19 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
# Non-staff members only see public photos # Non-staff members only see public photos
if self.request.user.is_staff: if self.request.user.is_staff:
context['photos'] = self.object.photos.all() context["photos"] = self.object.photos.all()
else: else:
context['photos'] = self.object.photos.filter(is_public=True) context["photos"] = self.object.photos.filter(is_public=True)
# List owners # List owners
context['owners'] = [] context["owners"] = []
for photo in context['photos']: for photo in context["photos"]:
if photo.owner not in context['owners']: if photo.owner not in context["owners"]:
context['owners'].append(photo.owner) context["owners"].append(photo.owner)
# Filter on owner # Filter on owner
if 'owner' in self.kwargs: if "owner" in self.kwargs:
context['photos'] = context['photos'].filter(owner__id=self.kwargs['owner']) context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"])
return context return context
@ -158,8 +159,10 @@ class GalleryDownload(LoginRequiredMixin, DetailView):
zip_file.close() zip_file.close()
# Return zip file # Return zip file
response = HttpResponse(byte_data.getvalue(), content_type='application/x-zip-compressed') response = HttpResponse(
response['Content-Disposition'] = f"attachment; filename={gallery.slug}.zip" byte_data.getvalue(), content_type="application/x-zip-compressed"
)
response["Content-Disposition"] = f"attachment; filename={gallery.slug}.zip"
return response return response
@ -167,15 +170,16 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
""" """
Form to upload new photos in a gallery Form to upload new photos in a gallery
""" """
form_class = UploadForm form_class = UploadForm
template_name = "photologue/upload.html" template_name = "photologue/upload.html"
success_url = reverse_lazy("photologue:pl-gallery-upload") success_url = reverse_lazy("photologue:pl-gallery-upload")
permission_required = 'photologue.add_gallery' permission_required = "photologue.add_gallery"
def form_valid(self, form): def form_valid(self, form):
# Upload photos # Upload photos
# We take files from the request to support multiple upload # 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 = form.get_or_create_gallery()
gallery_year = Path(str(gallery.date_start.year)) gallery_year = Path(str(gallery.date_start.year))
gallery_dir = gallery_year / gallery.slug gallery_dir = gallery_year / gallery.slug
@ -187,7 +191,9 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
opened.verify() opened.verify()
except Exception: except Exception:
# Pillow doesn't recognize it as an image, skip it # Pillow doesn't recognize it as an image, skip it
messages.error(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 failed_upload += 1
continue continue
@ -203,7 +209,10 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
photo.save() photo.save()
photo.galleries.set([gallery]) photo.galleries.set([gallery])
except IntegrityError: 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 failed_upload += 1
# Notify user then managers # Notify user then managers
@ -211,9 +220,13 @@ class GalleryUpload(PermissionRequiredMixin, FormView):
messages.success(self.request, "All photos has been successfully uploaded.") messages.success(self.request, "All photos has been successfully uploaded.")
else: else:
n_success = len(files) - failed_upload 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) photos = ", ".join(f.name for f in files)
mail_managers( mail_managers(
subject="New photos upload", subject="New photos upload",