Merge branch 'moderation_ux' into 'master'

Moderation ux

See merge request bde/photo21!25
This commit is contained in:
erdnaxe 2022-03-02 21:27:54 +01:00
commit de9c37e617
18 changed files with 956 additions and 409 deletions

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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}")

View file

@ -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",
},
),
]

View file

@ -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",
),
]

View file

@ -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",
),
]

View file

@ -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.slug])
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

View file

@ -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

View file

@ -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 %}
@ -40,6 +41,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 +73,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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -1,17 +1,39 @@
from django.urls import path, re_path
from .views import (GalleryDetailView, GalleryArchiveIndexView,
GalleryDownload, GalleryUpload, GalleryYearArchiveView,
PhotoDetailView, 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/<slug:slug>/', PhotoDetailView.as_view(), name='pl-photo'),
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"),
]

View file

@ -7,16 +7,16 @@ 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
from django.views.generic.edit import DeleteView, FormView
from PIL import Image
from .forms import UploadForm
@ -25,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"""
@ -56,6 +56,48 @@ 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 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
@ -65,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
@ -74,6 +117,7 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
"""
Gallery detail view to filter on photo owner
"""
model = Gallery
def get_context_data(self, **kwargs):
@ -81,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
@ -115,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
@ -124,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
@ -144,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
@ -160,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
@ -168,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",