From e6c60ff93cc150f8980ab0933029db2ae5d7eff3 Mon Sep 17 00:00:00 2001 From: Alek Ratzloff Date: Wed, 4 May 2022 19:45:36 -0700 Subject: [PATCH] Add image upload support Images can be uploaded, thumbnails are created, they're displayed within the threads themselves. Just like four chans! There is not an upload size limit set yet. Gotta get on that next. Signed-off-by: Alek Ratzloff --- Pipfile | 1 + Pipfile.lock | 96 ++++++++++++------------- board/models.py | 92 +++++++++++++++++++++++- board/static/board/style.css | 18 +++++ board/templates/board/board_detail.html | 2 +- board/templates/board/post_detail.html | 2 +- board/templates/board/post_snippet.html | 24 ++++++- board/templatetags/post_body.py | 10 +++ board/urls.py | 4 ++ board/views.py | 5 +- threadchat/settings.py | 8 +++ 11 files changed, 201 insertions(+), 61 deletions(-) diff --git a/Pipfile b/Pipfile index e77914a..e5fe1ab 100644 --- a/Pipfile +++ b/Pipfile @@ -5,6 +5,7 @@ name = "pypi" [packages] django = "*" +pillow = "*" [dev-packages] mypy = "*" diff --git a/Pipfile.lock b/Pipfile.lock index ee89292..3b98275 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5b45ddb3eaf4d3c3557de0cab9ab514fcebdfe6e6c80120a11f223011f9beec6" + "sha256": "720e8538e0b5a6418b2f8e2973ef59da19390569ba586790a194f490bdabd90b" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "asgiref": { "hashes": [ - "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", - "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" + "sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1", + "sha256:fddeea3c53fa99d0cdb613c3941cc6e52d822491fc2753fba25768fb5bf4e865" ], "markers": "python_version >= '3.7'", - "version": "==3.5.0" + "version": "==3.5.1" }, "django": { "hashes": [ @@ -32,41 +32,49 @@ "index": "pypi", "version": "==4.0.4" }, - "mypy": { + "pillow": { "hashes": [ - "sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d", - "sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8", - "sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de", - "sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038", - "sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed", - "sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334", - "sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff", - "sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2", - "sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22", - "sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2", - "sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2", - "sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605", - "sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb", - "sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519", - "sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0", - "sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc", - "sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b", - "sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f", - "sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075", - "sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef", - "sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb", - "sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a", - "sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b" + "sha256:01ce45deec9df310cbbee11104bae1a2a43308dd9c317f99235b6d3080ddd66e", + "sha256:0c51cb9edac8a5abd069fd0758ac0a8bfe52c261ee0e330f363548aca6893595", + "sha256:17869489de2fce6c36690a0c721bd3db176194af5f39249c1ac56d0bb0fcc512", + "sha256:21dee8466b42912335151d24c1665fcf44dc2ee47e021d233a40c3ca5adae59c", + "sha256:25023a6209a4d7c42154073144608c9a71d3512b648a2f5d4465182cb93d3477", + "sha256:255c9d69754a4c90b0ee484967fc8818c7ff8311c6dddcc43a4340e10cd1636a", + "sha256:35be4a9f65441d9982240e6966c1eaa1c654c4e5e931eaf580130409e31804d4", + "sha256:3f42364485bfdab19c1373b5cd62f7c5ab7cc052e19644862ec8f15bb8af289e", + "sha256:3fddcdb619ba04491e8f771636583a7cc5a5051cd193ff1aa1ee8616d2a692c5", + "sha256:463acf531f5d0925ca55904fa668bb3461c3ef6bc779e1d6d8a488092bdee378", + "sha256:4fe29a070de394e449fd88ebe1624d1e2d7ddeed4c12e0b31624561b58948d9a", + "sha256:55dd1cf09a1fd7c7b78425967aacae9b0d70125f7d3ab973fadc7b5abc3de652", + "sha256:5a3ecc026ea0e14d0ad7cd990ea7f48bfcb3eb4271034657dc9d06933c6629a7", + "sha256:5cfca31ab4c13552a0f354c87fbd7f162a4fafd25e6b521bba93a57fe6a3700a", + "sha256:66822d01e82506a19407d1afc104c3fcea3b81d5eb11485e593ad6b8492f995a", + "sha256:69e5ddc609230d4408277af135c5b5c8fe7a54b2bdb8ad7c5100b86b3aab04c6", + "sha256:6b6d4050b208c8ff886fd3db6690bf04f9a48749d78b41b7a5bf24c236ab0165", + "sha256:7a053bd4d65a3294b153bdd7724dce864a1d548416a5ef61f6d03bf149205160", + "sha256:82283af99c1c3a5ba1da44c67296d5aad19f11c535b551a5ae55328a317ce331", + "sha256:8782189c796eff29dbb37dd87afa4ad4d40fc90b2742704f94812851b725964b", + "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458", + "sha256:97bda660702a856c2c9e12ec26fc6d187631ddfd896ff685814ab21ef0597033", + "sha256:a325ac71914c5c043fa50441b36606e64a10cd262de12f7a179620f579752ff8", + "sha256:a336a4f74baf67e26f3acc4d61c913e378e931817cd1e2ef4dfb79d3e051b481", + "sha256:a598d8830f6ef5501002ae85c7dbfcd9c27cc4efc02a1989369303ba85573e58", + "sha256:a5eaf3b42df2bcda61c53a742ee2c6e63f777d0e085bbc6b2ab7ed57deb13db7", + "sha256:aea7ce61328e15943d7b9eaca87e81f7c62ff90f669116f857262e9da4057ba3", + "sha256:af79d3fde1fc2e33561166d62e3b63f0cc3e47b5a3a2e5fea40d4917754734ea", + "sha256:c24f718f9dd73bb2b31a6201e6db5ea4a61fdd1d1c200f43ee585fc6dcd21b34", + "sha256:c5b0ff59785d93b3437c3703e3c64c178aabada51dea2a7f2c5eccf1bcf565a3", + "sha256:c7110ec1701b0bf8df569a7592a196c9d07c764a0a74f65471ea56816f10e2c8", + "sha256:c870193cce4b76713a2b29be5d8327c8ccbe0d4a49bc22968aa1e680930f5581", + "sha256:c9efef876c21788366ea1f50ecb39d5d6f65febe25ad1d4c0b8dff98843ac244", + "sha256:de344bcf6e2463bb25179d74d6e7989e375f906bcec8cb86edb8b12acbc7dfef", + "sha256:eb1b89b11256b5b6cad5e7593f9061ac4624f7651f7a8eb4dfa37caa1dfaa4d0", + "sha256:ed742214068efa95e9844c2d9129e209ed63f61baa4d54dbf4cf8b5e2d30ccf2", + "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97", + "sha256:fb89397013cf302f282f0fc998bb7abf11d49dcff72c8ecb320f76ea6e2c5717" ], "index": "pypi", - "version": "==0.950" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" + "version": "==9.1.0" }, "sqlparse": { "hashes": [ @@ -75,22 +83,6 @@ ], "markers": "python_version >= '3.5'", "version": "==0.4.2" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708", - "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376" - ], - "markers": "python_version >= '3.7'", - "version": "==4.2.0" } }, "develop": { diff --git a/board/models.py b/board/models.py index d76ebb3..8c18d93 100644 --- a/board/models.py +++ b/board/models.py @@ -1,8 +1,42 @@ +import os +from pathlib import Path from django.db import models -from django.db.models.signals import post_save +from django.db.models import signals +from django.conf import settings +from django.core.files.base import ContentFile from django.dispatch import receiver from django.urls import reverse from django.utils import timezone +from PIL import Image +from io import BytesIO + + +def image_upload(instance, filename): + op_id = instance.op.id if instance.op else instance.id + now = timezone.now() + now_sec = now.strftime("%s.%f") + ext = Path(filename).suffix.lower() + if ext in (".jpeg", ".jpg"): + ext = ".jpg" + + if ext not in (".jpg", ".png", ".gif"): + raise Exception("File type invalid") + + if instance.op: + return f"{instance.board.url}/{instance.op.id}/{now_sec}{ext}" + else: + return f"{instance.board.url}/{now_sec}{ext}" + + +def thumbs_upload(instance, filename): + op_id = instance.op.id if instance.op else instance.id + now = timezone.now() + now_sec = now.strftime("%s.%f") + ext = Path(filename).suffix.lower() + if instance.op: + return f"{instance.board.url}/{instance.op.id}/{now_sec}{ext}" + else: + return f"{instance.board.url}/{now_sec}t{ext}" class Board(models.Model): @@ -39,8 +73,52 @@ class Post(models.Model): created = models.DateTimeField(auto_now_add=True) # Last bump time last_bump = models.DateTimeField(auto_now_add=True) + # Image + image = models.ImageField( + upload_to=image_upload, + null=True, + blank=True, + width_field="image_width", + height_field="image_height", + ) + # Thumbnail + thumbnail = models.ImageField(upload_to=thumbs_upload, editable=False, null=True) + # Original image name + original_image_name = models.CharField(max_length=255, null=True) + # Image width and height + image_width = models.IntegerField(null=True) + image_height = models.IntegerField(null=True) - # TODO : images + def save(self, *args, **kwargs): + if self.image: + self.original_image_name = self.image.name + self.__make_thumbnail() + super(Post, self).save(*args, **kwargs) + + def __make_thumbnail(self): + image = Image.open(self.image) + image.thumbnail(settings.THUMB_SIZE, Image.ANTIALIAS) + + image_path = Path(self.image.name) + thumb_path = Path(image_path.stem + "t" + image_path.suffix) + + ext = thumb_path.suffix.lower() + if ext in (".jpg", ".jpeg"): + FTYPE = "JPEG" + elif ext == ".gif": + FTYPE = "GIF" + elif ext == ".png": + FTYPE = "PNG" + else: + raise Exception("File type invalid") + + thumb_temp = BytesIO() + image.save(thumb_temp, FTYPE) + thumb_temp.seek(0) + + # save=False stops recursion + self.thumbnail.save(thumb_path, ContentFile(thumb_temp.read()), save=False) + thumb_temp.close() def get_absolute_url(self): if self.op is None: @@ -57,8 +135,16 @@ class Post(models.Model): ) -@receiver(post_save, sender=Post) +@receiver(signals.post_save, sender=Post) def post_created(sender, instance, created, **kwargs): if created and instance.op: instance.op.last_bump = timezone.now() instance.op.save() + + +@receiver(signals.post_delete, sender=Post) +def post_deleted(sender, instance, **kwargs): + if instance.image and Path(instance.image.path).is_file(): + os.remove(instance.image.path) + if instance.thumbnail and Path(instance.thumbnail.path).is_file(): + os.remove(instance.thumbnail.path) diff --git a/board/static/board/style.css b/board/static/board/style.css index cdea25f..5e30e70 100644 --- a/board/static/board/style.css +++ b/board/static/board/style.css @@ -16,6 +16,24 @@ hr { /* Posts */ /*.post_body { }*/ +.post_image_info { + font-size: small; + padding-bottom: 5px; +} + +.post_image_thumbnail { + float: left; + padding-right: 5px; +} + +/* +Not sure if I like the in-line style or each post gets its own row style +.post_content:after { + content: ""; + display: table; + clear: both; +} +*/ .post_id { cursor: pointer; diff --git a/board/templates/board/board_detail.html b/board/templates/board/board_detail.html index 99b4082..b2be430 100644 --- a/board/templates/board/board_detail.html +++ b/board/templates/board/board_detail.html @@ -24,7 +24,7 @@

Create a new thread

-
+ {% csrf_token %} {{ form.as_table }} diff --git a/board/templates/board/post_detail.html b/board/templates/board/post_detail.html index dae93ae..848bc16 100644 --- a/board/templates/board/post_detail.html +++ b/board/templates/board/post_detail.html @@ -20,7 +20,7 @@
 
- + {% csrf_token %}
{{ form.as_table }} diff --git a/board/templates/board/post_snippet.html b/board/templates/board/post_snippet.html index 128fcd8..a0d93d8 100644 --- a/board/templates/board/post_snippet.html +++ b/board/templates/board/post_snippet.html @@ -1,6 +1,23 @@ {% load post_body %}
-{# TODO images #} +{# Image #} +{% if post.thumbnail %} +{# Image info #} +
+ File: {{post.original_image_name}} + ({{post.image.size|measure_bytes}}, {{post.image_width}}x{{post.image_height}}) +
+ +{# Image thumbnail #} +
+ + + +
+{% endif %} + +{# Post ID, username, time #} +
#. {{post.id}} {% if post.subject %} @@ -11,11 +28,16 @@ at {{post.created}} {% if reply_link %} [Reply] {% endif %} + +{# "X replies elided" dialog for OPs on the board #} {% if replies_elided > 0 %}
({{replies_elided}} replies elided, click reply to view) {% endif %} + +{# Post body #}

{{post|post_body|safe}}

+
\ No newline at end of file diff --git a/board/templatetags/post_body.py b/board/templatetags/post_body.py index ffcfb15..7438cfa 100644 --- a/board/templatetags/post_body.py +++ b/board/templatetags/post_body.py @@ -91,3 +91,13 @@ def post_body(post: Post) -> str: # Finally, replace linebreaks text = text.replace("\n", "
") return text + + +@register.filter(name="measure_bytes") +def measure_bytes(value: int) -> str: + if value < 1024: + return f"{value} bytes" + elif value < 1024**2: + return f"{round(value / 1024, 1)} KiB" + else: + return f"{round(value / (1024**2), 1)} MiB" diff --git a/board/urls.py b/board/urls.py index e082d18..e9ea480 100644 --- a/board/urls.py +++ b/board/urls.py @@ -1,4 +1,6 @@ from django.urls import path +from django.conf import settings +from django.conf.urls.static import static from board.views import * @@ -8,3 +10,5 @@ urlpatterns = [ path("/page//", BoardView.as_view(), name="board_detail"), path("/post//", PostView.as_view(), name="post_detail"), ] +# TODO - make this conditional so we can serve images up with whatever server we want +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/board/views.py b/board/views.py index c2c8c9a..63c485f 100644 --- a/board/views.py +++ b/board/views.py @@ -25,7 +25,7 @@ def get_client_ip(request): class BoardView(CreateView): model = Post - fields = ["subject", "name", "text"] + fields = ["subject", "name", "text", "image"] slug_field = "url" slug_url_kwarg = "url" @@ -57,13 +57,12 @@ class BoardView(CreateView): form.instance.ip = get_client_ip(self.request) self.object = form.save() - return HttpResponseRedirect(self.get_success_url()) class PostView(CreateView): model = Post - fields = ["name", "text"] + fields = ["name", "text", "image"] slug_field = "url" slug_url_kwarg = "url" diff --git a/threadchat/settings.py b/threadchat/settings.py index 8772378..feb5dee 100644 --- a/threadchat/settings.py +++ b/threadchat/settings.py @@ -124,3 +124,11 @@ STATIC_URL = "static/" # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Media root - where media files are stored on the disk +MEDIA_ROOT = "media/" + +# Media URL - the URL where media is served from +MEDIA_URL = "media/" + +THUMB_SIZE = (200, 200)