Skip to content

Commit 6b1150c

Browse files
committed
Added blog.ImageUpload to simplify attaching images to entries
1 parent a7440b1 commit 6b1150c

File tree

5 files changed

+183
-3
lines changed

5 files changed

+183
-3
lines changed

blog/admin.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
from pathlib import Path
2+
13
from django.contrib import admin
2-
from django.utils.translation import gettext as _
4+
from django.urls import reverse
5+
from django.utils.html import format_html, format_html_join
6+
from django.utils.translation import gettext as _, gettext_lazy
7+
from sorl.thumbnail import get_thumbnail
38

4-
from .models import Entry, Event
9+
from .models import ContentFormat, Entry, Event, ImageUpload
510

611

712
@admin.register(Entry)
@@ -22,6 +27,13 @@ def formfield_for_dbfield(self, db_field, **kwargs):
2227
"style": "font-family: monospace; width: 810px;",
2328
}
2429
)
30+
formfield.help_text = format_html(
31+
_(
32+
"Want to include an image? "
33+
'<a href="{}" target="_blank">Use the image upload helper!</a>'
34+
),
35+
reverse("admin:blog_imageupload_changelist"),
36+
)
2537
return formfield
2638

2739

@@ -37,3 +49,54 @@ class EventAdmin(admin.ModelAdmin):
3749
"is_published",
3850
)
3951
list_filter = ("is_active",)
52+
53+
54+
@admin.register(ImageUpload)
55+
class ImageUploadAdmin(admin.ModelAdmin):
56+
list_display = (
57+
"title",
58+
"thumbnail",
59+
"uploaded_on",
60+
"link",
61+
"copy_buttons",
62+
)
63+
64+
class Media:
65+
js = ["js/admin_blog_imageupload.js"]
66+
67+
@admin.display(description=gettext_lazy("Thumbnail"))
68+
def thumbnail(self, obj):
69+
thumbnail = get_thumbnail(obj.image, "150x150", quality=90)
70+
return format_html('<img src="{}" alt="{}">', thumbnail.url, obj.alt_text)
71+
72+
@admin.display(description=gettext_lazy("Link"))
73+
def link(self, obj):
74+
url = obj.image.url
75+
filename = Path(obj.image.name).name
76+
return format_html('<a href="{}">{}</a>', url, filename)
77+
78+
def _get_copy_button(self, obj, contentformat):
79+
source = contentformat.img(obj.image.url, obj.alt_text)
80+
return format_html(
81+
'<button type="button" data-clipboard-content="{}">{}</button>',
82+
source,
83+
contentformat.label,
84+
)
85+
86+
@admin.display(description=gettext_lazy("Copy buttons"))
87+
def copy_buttons(self, obj):
88+
buttons = ((self._get_copy_button(obj, cf),) for cf in ContentFormat)
89+
return format_html(
90+
"<ul>{}</ul>",
91+
format_html_join("\n", "<li>{}</li>", buttons),
92+
)
93+
94+
def save_model(self, request, obj, form, change):
95+
obj.uploaded_by = request.user
96+
super().save_model(request, obj, form, change)
97+
98+
def formfield_for_dbfield(self, db_field, **kwargs):
99+
formfield = super().formfield_for_dbfield(db_field, **kwargs)
100+
if db_field.name == "image":
101+
formfield.widget.attrs.update({"accept": "image/*"})
102+
return formfield

blog/migrations/0004_imageupload.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 5.1.5 on 2025-03-27 16:58
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('blog', '0003_entry_content_format_markdown'),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='ImageUpload',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('title', models.CharField(help_text='Not published anywhere, just used internally', max_length=100)),
21+
('image', models.FileField(upload_to='blog/images/%Y/%m/')),
22+
('alt_text', models.TextField(help_text='Make the extra effort, it makes a difference 💖')),
23+
('uploaded_on', models.DateTimeField(auto_now_add=True)),
24+
('uploaded_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
25+
],
26+
options={
27+
'ordering': ('-uploaded_on',),
28+
},
29+
),
30+
]

blog/models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ def to_html(cls, fmt, source):
6464
)
6565
raise ValueError(f"Unsupported format {fmt}")
6666

67+
def img(self, url, alt_text):
68+
"""
69+
Generate the source code for an image in the current format
70+
"""
71+
CF = type(self)
72+
return {
73+
CF.REST: f".. image:: {url}\n :alt: {alt_text}",
74+
CF.HTML: f'<img src="{url}" alt="{alt_text}">',
75+
CF.MARKDOWN: f"![{alt_text}]({url})",
76+
}[self]
77+
78+
79+
class ImageUpload(models.Model):
80+
"""
81+
Make it easier to attach images to blog posts.
82+
"""
83+
84+
title = models.CharField(
85+
max_length=100, help_text="Not published anywhere, just used internally"
86+
)
87+
image = models.FileField(upload_to="blog/images/%Y/%m/")
88+
alt_text = models.TextField(
89+
help_text="Make the extra effort, it makes a difference 💖"
90+
)
91+
uploaded_on = models.DateTimeField(auto_now_add=True)
92+
uploaded_by = models.ForeignKey(
93+
"auth.User", null=True, editable=False, on_delete=models.SET_NULL
94+
)
95+
96+
class Meta:
97+
ordering = ("-uploaded_on",)
98+
6799

68100
class Entry(models.Model):
69101
headline = models.CharField(max_length=200)

blog/tests.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from io import StringIO
44

55
from django.contrib.auth.models import User
6+
from django.core.files.base import ContentFile
67
from django.test import TestCase
78
from django.urls import reverse
89
from django.utils import timezone
910

10-
from .models import ContentFormat, Entry, Event
11+
from .models import ContentFormat, Entry, Event, ImageUpload
1112
from .sitemaps import WeblogSitemap
1213

1314

@@ -235,3 +236,41 @@ def test_sitemap(self):
235236
self.assertEqual(len(urls), 1)
236237
url_info = urls[0]
237238
self.assertEqual(url_info["location"], entry.get_absolute_url())
239+
240+
241+
class ImageUploadTestCase(TestCase):
242+
@classmethod
243+
def setUpTestData(cls):
244+
cls.user = User.objects.create_superuser("test")
245+
246+
def setUp(self):
247+
super().setUp()
248+
self.client.force_login(self.user)
249+
250+
def test_uploaded_by(self):
251+
# Can't test the ModelForm directly because the logic in
252+
# ModelAdmin.save_model()
253+
data = {
254+
"title": "test",
255+
"alt_text": "test",
256+
"image": ContentFile(b".", name="test.png"),
257+
}
258+
response = self.client.post(
259+
reverse("admin:blog_imageupload_add"),
260+
data=data,
261+
)
262+
self.assertEqual(response.status_code, 302)
263+
upload = ImageUpload.objects.get()
264+
self.assertEqual(upload.uploaded_by, self.user)
265+
266+
def test_contentformat_image_tags(self):
267+
for cf, expected in [
268+
(ContentFormat.REST, ".. image:: /test/image.png\n :alt: TEST"),
269+
(ContentFormat.HTML, '<img src="/test/image.png" alt="TEST">'),
270+
(ContentFormat.MARKDOWN, "![TEST](/test/image.png)"),
271+
]:
272+
with self.subTest(contentformat=cf):
273+
self.assertEqual(
274+
cf.img(url="/test/image.png", alt_text="TEST"),
275+
expected,
276+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
document.addEventListener('DOMContentLoaded', function () {
2+
document
3+
.querySelectorAll('button[data-clipboard-content]')
4+
.forEach(function (el) {
5+
el.addEventListener('click', function (e) {
6+
// Copy to clipboard and flash the button for a second
7+
navigator.clipboard.writeText(el.dataset.clipboardContent);
8+
el.style.backgroundColor = 'rebeccapurple';
9+
el.style.color = 'white';
10+
window.setTimeout(function () {
11+
el.style.backgroundColor = '';
12+
el.style.color = '';
13+
}, 1000);
14+
});
15+
});
16+
});

0 commit comments

Comments
 (0)