Skip to content

Commit bd87d9b

Browse files
authored
Merge pull request #47 from Xpirix/optimize_page_loads
Optimize page load performance by managing thumbnails correctly
2 parents 1be59fe + 95aaa27 commit bd87d9b

File tree

12 files changed

+360
-50
lines changed

12 files changed

+360
-50
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# coding=utf-8
2+
"""Management command to backfill image_file_thumbnail for all Version records."""
3+
from django.core.management.base import BaseCommand
4+
from changes.models import Version
5+
6+
7+
class Command(BaseCommand):
8+
help = (
9+
'Generate image_file_thumbnail for every Version that has an '
10+
'image_file but no thumbnail yet. Pass --force to regenerate all '
11+
'thumbnails even when one already exists.'
12+
)
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument(
16+
'--force',
17+
action='store_true',
18+
default=False,
19+
help='Regenerate thumbnails even when image_file_thumbnail already exists.',
20+
)
21+
22+
def handle(self, *args, **options):
23+
force = options['force']
24+
qs = Version.objects.exclude(image_file='')
25+
if not force:
26+
qs = qs.filter(image_file_thumbnail='')
27+
28+
total = qs.count()
29+
if total == 0:
30+
self.stdout.write(self.style.SUCCESS('Nothing to do — all thumbnails are up to date.'))
31+
return
32+
33+
self.stdout.write(f'Generating thumbnails for {total} version(s)…')
34+
35+
ok = 0
36+
failed = 0
37+
for version in qs.iterator():
38+
try:
39+
version._generate_thumbnail()
40+
ok += 1
41+
self.stdout.write(f' ✓ {version.name} ({version.image_file.name})')
42+
except Exception as exc:
43+
failed += 1
44+
self.stderr.write(
45+
self.style.ERROR(f' ✗ {version.name}: {exc}')
46+
)
47+
48+
summary = f'Done — {ok} generated'
49+
if failed:
50+
summary += f', {failed} failed'
51+
self.stdout.write(self.style.SUCCESS(summary))
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('changes', '0011_auto_20200730_0531'),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name='version',
13+
name='image_file_thumbnail',
14+
field=models.ImageField(
15+
blank=True,
16+
editable=False,
17+
help_text='Auto-generated 1000×500 WEBP thumbnail of image_file.',
18+
upload_to='images/thumbnails/versions/',
19+
),
20+
),
21+
]

django_project/changes/models/version.py

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from common.utilities import version_slugify
55
import os
66
import logging
7+
from functools import cached_property
78
from .entry import Entry
89
from .sponsorship_period import SponsorshipPeriod
910
from core.settings.contrib import STOP_WORDS
@@ -45,6 +46,12 @@ class Version(models.Model):
4546
upload_to=os.path.join(MEDIA_ROOT, 'images/projects'),
4647
blank=True)
4748

49+
image_file_thumbnail = models.ImageField(
50+
help_text='Auto-generated 1000×500 WEBP thumbnail of image_file.',
51+
upload_to='images/thumbnails/versions/',
52+
blank=True,
53+
editable=False)
54+
4855
description = models.TextField(
4956
null=True,
5057
blank=True,
@@ -83,8 +90,83 @@ def save(self, *args, **kwargs):
8390
new_list = ' '.join(filtered_words)
8491
self.slug = version_slugify(new_list)[:50]
8592
self.padded_version = self.pad_name(str(self.get_numerical_name()))
93+
94+
# Detect whether image_file has changed so we know whether to
95+
# regenerate the thumbnail after saving.
96+
old_image_name = None
97+
if self.pk:
98+
try:
99+
old_image_name = (
100+
Version.objects.filter(pk=self.pk)
101+
.values_list('image_file', flat=True)
102+
.first()
103+
)
104+
except Exception:
105+
pass
106+
86107
super(Version, self).save(*args, **kwargs)
87108

109+
# Regenerate thumbnail when image_file is set and has changed (or is
110+
# new), or when a thumbnail is missing for an existing image.
111+
if self.image_file:
112+
needs_thumb = (
113+
old_image_name != self.image_file.name or not self.image_file_thumbnail
114+
)
115+
if needs_thumb:
116+
self._generate_thumbnail()
117+
118+
def _generate_thumbnail(self, width=1000, height=500):
119+
"""Generate a WEBP thumbnail and store it in image_file_thumbnail.
120+
121+
Uses centre-crop so the thumbnail always fills width×height without
122+
distortion. Saves directly via QuerySet.update() to avoid recursion.
123+
"""
124+
from io import BytesIO
125+
from PIL import Image as PILImage
126+
from django.core.files.base import ContentFile
127+
128+
if not self.image_file:
129+
return
130+
try:
131+
self.image_file.seek(0)
132+
img = PILImage.open(self.image_file).convert('RGB')
133+
134+
# Centre-crop to the target aspect ratio before resizing.
135+
src_ratio = img.width / img.height
136+
dst_ratio = width / height
137+
if src_ratio > dst_ratio:
138+
# Image is wider — trim the sides.
139+
new_w = int(img.height * dst_ratio)
140+
left = (img.width - new_w) // 2
141+
img = img.crop((left, 0, left + new_w, img.height))
142+
elif src_ratio < dst_ratio:
143+
# Image is taller — trim top and bottom.
144+
new_h = int(img.width / dst_ratio)
145+
top = (img.height - new_h) // 2
146+
img = img.crop((0, top, img.width, top + new_h))
147+
148+
img = img.resize((width, height), PILImage.LANCZOS)
149+
150+
buf = BytesIO()
151+
img.save(buf, format='WEBP', quality=85)
152+
buf.seek(0)
153+
154+
base = os.path.splitext(os.path.basename(self.image_file.name))[0]
155+
thumb_name = f'{base}_thumb.webp'
156+
157+
# save=False avoids an extra full model.save() / infinite loop.
158+
self.image_file_thumbnail.save(
159+
thumb_name, ContentFile(buf.read()), save=False
160+
)
161+
# Persist only the thumbnail field.
162+
Version.objects.filter(pk=self.pk).update(
163+
image_file_thumbnail=self.image_file_thumbnail.name
164+
)
165+
except Exception as e:
166+
logger.warning(
167+
'Could not generate thumbnail for Version pk=%s: %s', self.pk, e
168+
)
169+
88170
def get_numerical_name(self):
89171
name = self.name
90172
non_decimal = re.compile(r'[^\d.]+')
@@ -144,6 +226,7 @@ def _entries_for_category(self, category):
144226
qs = Entry.objects.filter(version=self, category=category)
145227
return qs
146228

229+
@cached_property
147230
def categories(self):
148231
"""Get a list of categories where there are one or more entries.
149232
@@ -156,20 +239,19 @@ def categories(self):
156239
{% endfor %}
157240
</ul>
158241
{% endfor %}
242+
243+
Uses a single DB query with select_related to avoid N+1 queries.
159244
"""
160-
qs = self.entries()
161-
used = []
162-
categories = []
163-
for entry in qs:
164-
category = entry.category
165-
if category not in used:
166-
row = {
167-
'category': category,
168-
'entries': self._entries_for_category(category)
169-
}
170-
categories.append(row)
171-
used.append(category)
172-
return categories
245+
from itertools import groupby
246+
qs = (
247+
Entry.objects.filter(version=self)
248+
.select_related('category', 'version')
249+
.order_by('category__sort_number', 'sequence_number')
250+
)
251+
result = []
252+
for category, entries in groupby(qs, key=lambda e: e.category):
253+
result.append({'category': category, 'entries': list(entries)})
254+
return result
173255

174256
def sponsors(self):
175257
"""Return a list of sponsors current at time of this version release.

django_project/changes/templates/entry/includes/entry_detail.html

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{% load custom_markup %}
2-
{% load thumbnail %}
32
{% load embed_video_tags %}
43

54
<div class="columns">
@@ -60,10 +59,13 @@ <h3><span>Feature:</span> {{ entry.title }}</h3>
6059
<div class="column is-4 has-text-centered">
6160
{% if entry.image_file|is_gif %}
6261
{% if not rst_download %}
63-
<img id="{{ entry.image_file.url }}" class="image"
64-
data-gifffer="{{ entry.image_file.url }}"
65-
src="{{ entry.image_file.url }}"
66-
gifffer-alt="" loading="lazy"/>
62+
<div class="img-lazy-wrapper">
63+
<img id="{{ entry.image_file.url }}" class="image"
64+
data-gifffer="{{ entry.image_file.url }}"
65+
src="{{ entry.image_file.url }}"
66+
gifffer-alt="" loading="lazy"
67+
onload="this.closest('.img-lazy-wrapper').classList.add('loaded')"/>
68+
</div>
6769
<a href="#" class="pop-gif">
6870
Click here for bigger size animation.
6971
</a>
@@ -75,11 +77,14 @@ <h3><span>Feature:</span> {{ entry.title }}</h3>
7577
</a>
7678
{% endif %}
7779
{% else %}
78-
<a href="#" class="pop-image">
79-
{% thumbnail entry.image_file "1000x500" crop="center" format="WEBP" as im %}
80-
<img id="{{ entry.image_file.url }}" class="image is-rounded" src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}" alt="Version image" loading="lazy">
81-
{% endthumbnail %}
82-
</a>
80+
<div class="img-lazy-wrapper">
81+
<a href="#" class="pop-image">
82+
<img id="{{ entry.image_file.url }}" class="image is-rounded"
83+
src="{{ entry.image_file.url }}"
84+
alt="Version image" loading="lazy"
85+
onload="this.closest('.img-lazy-wrapper').classList.add('loaded')">
86+
</a>
87+
</div>
8388
{% endif %}
8489
</div>
8590
{% endif %}

django_project/changes/templates/version/detail-content.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{% load custom_markup %}
2-
{% load thumbnail %}
32

43
{% if not rst_download %}
54
<h1>
@@ -12,9 +11,12 @@ <h1>
1211
<div class="column">
1312
{% if not rst_download %}
1413
<a href="{{ version.image_file.url }}">
15-
{% thumbnail version.image_file "1000x500" crop="center" format="WEBP" as im %}
16-
<img class="image is-rounded" src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}" alt="Version image" loading="lazy">
17-
{% endthumbnail %}
14+
<div class="img-lazy-wrapper">
15+
<img class="image is-rounded"
16+
src="{% if version.image_file_thumbnail %}{{ version.image_file_thumbnail.url }}{% else %}{{ version.image_file.url }}{% endif %}"
17+
alt="Version image" loading="lazy"
18+
onload="this.closest('.img-lazy-wrapper').classList.add('loaded')">
19+
</div>
1820
</a>
1921
{% else %}
2022
<img class="image is-rounded"
@@ -45,7 +47,7 @@ <h3 class="title is-4">
4547
{% for row in version.categories %}
4648
{% if row.entries %}
4749
<div class="buttons is-pulled-right">
48-
{% if user.is_staff or user in project.changelog_manager.all and not rst_download %}
50+
{% if user.is_staff or user in project.changelog_managers.all and not rst_download %}
4951
<a class="button is-light has-tooltip-bottom"
5052
data-tooltip="Order Entries"
5153
href='{% url "entry-order" version_pk=version.pk category_pk=row.category.pk %}'>

django_project/changes/templates/version/detail.html

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,31 @@
1111
{% endblock js_head %}
1212

1313
{% block extra_head %}
14+
<style>
15+
.img-lazy-wrapper {
16+
position: relative;
17+
background: linear-gradient(90deg, hsl(0,0%,93%) 25%, hsl(0,0%,88%) 50%, hsl(0,0%,93%) 75%);
18+
background-size: 200% 100%;
19+
animation: img-shimmer 1.5s ease-in-out infinite;
20+
overflow: hidden;
21+
border-radius: 6px;
22+
min-height: 60px;
23+
}
24+
.img-lazy-wrapper img {
25+
opacity: 0;
26+
}
27+
.img-lazy-wrapper.loaded {
28+
background: none;
29+
animation: none;
30+
}
31+
.img-lazy-wrapper.loaded img {
32+
opacity: 1;
33+
}
34+
@keyframes img-shimmer {
35+
0% { background-position: 200% 0; }
36+
100% { background-position: -200% 0; }
37+
}
38+
</style>
1439
{% endblock %}
1540

1641
{% block page_title %}
@@ -37,7 +62,7 @@ <h1>Entries (all)</h1>
3762
</a>
3863
{% endif %}
3964
{% if version.locked %}
40-
{% if user.is_staff or user == version.project.owner or user in version.project.changelog_managers.all %}
65+
{% if user.is_staff or user == version.project.owner or user in project.changelog_managers.all %}
4166
<a class="button is-light tooltip"
4267
data-tooltip="Unlock this version."
4368
onclick="$('#unlockModal').addClass('is-active')">
@@ -54,7 +79,7 @@ <h1>Entries (all)</h1>
5479
</a>
5580
{% endif %}
5681
{% else %}
57-
{% if user.is_staff or user == version.project.owner or user in version.project.changelog_managers.all %}
82+
{% if user.is_staff or user == version.project.owner or user in project.changelog_managers.all %}
5883
<a class="button is-light tooltip"
5984
data-tooltip="Lock this version."
6085
onclick="$('#lockModal').addClass('is-active')">

django_project/changes/templates/version/includes/version-list-rich-item.html

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% load thumbnail static custom_markup %}
1+
{% load static custom_markup %}
22
<div class="container rich has-right rounded mt-5" {% if version.url %}ondblclick="window.open('{{ version.url }}', '_blank');"{% endif %}>
33
<div class="cont coloring-1">
44
<h3 name="titlePreview">
@@ -34,9 +34,15 @@ <h3 name="titlePreview">
3434
</div>
3535
</div>
3636

37+
{% if version.image_file %}
3738
<div class="rich-right" name="imagePreview">
38-
{% thumbnail version.image_file "400x200" crop="center" as im %}
39-
<img src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}" alt="Version image" style="border-radius:10px;" loading="lazy">
40-
{% endthumbnail %}
39+
<div class="img-lazy-wrapper">
40+
<img src="{% if version.image_file_thumbnail %}{{ version.image_file_thumbnail.url }}{% else %}{{ version.image_file.url }}{% endif %}"
41+
alt="Version image"
42+
style="border-radius:10px; width:100%; display:block;"
43+
loading="lazy"
44+
onload="this.closest('.img-lazy-wrapper').classList.add('loaded')">
45+
</div>
4146
</div>
47+
{% endif %}
4248
</div>

0 commit comments

Comments
 (0)