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 <alekratz@gmail.com>
This commit is contained in:
2022-05-04 19:45:36 -07:00
parent 7b99830e65
commit e6c60ff93c
11 changed files with 201 additions and 61 deletions

View File

@@ -5,6 +5,7 @@ name = "pypi"
[packages] [packages]
django = "*" django = "*"
pillow = "*"
[dev-packages] [dev-packages]
mypy = "*" mypy = "*"

96
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5b45ddb3eaf4d3c3557de0cab9ab514fcebdfe6e6c80120a11f223011f9beec6" "sha256": "720e8538e0b5a6418b2f8e2973ef59da19390569ba586790a194f490bdabd90b"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -18,11 +18,11 @@
"default": { "default": {
"asgiref": { "asgiref": {
"hashes": [ "hashes": [
"sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0", "sha256:45a429524fba18aba9d512498b19d220c4d628e75b40cf5c627524dbaebc5cc1",
"sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9" "sha256:fddeea3c53fa99d0cdb613c3941cc6e52d822491fc2753fba25768fb5bf4e865"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.5.0" "version": "==3.5.1"
}, },
"django": { "django": {
"hashes": [ "hashes": [
@@ -32,41 +32,49 @@
"index": "pypi", "index": "pypi",
"version": "==4.0.4" "version": "==4.0.4"
}, },
"mypy": { "pillow": {
"hashes": [ "hashes": [
"sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d", "sha256:01ce45deec9df310cbbee11104bae1a2a43308dd9c317f99235b6d3080ddd66e",
"sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8", "sha256:0c51cb9edac8a5abd069fd0758ac0a8bfe52c261ee0e330f363548aca6893595",
"sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de", "sha256:17869489de2fce6c36690a0c721bd3db176194af5f39249c1ac56d0bb0fcc512",
"sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038", "sha256:21dee8466b42912335151d24c1665fcf44dc2ee47e021d233a40c3ca5adae59c",
"sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed", "sha256:25023a6209a4d7c42154073144608c9a71d3512b648a2f5d4465182cb93d3477",
"sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334", "sha256:255c9d69754a4c90b0ee484967fc8818c7ff8311c6dddcc43a4340e10cd1636a",
"sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff", "sha256:35be4a9f65441d9982240e6966c1eaa1c654c4e5e931eaf580130409e31804d4",
"sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2", "sha256:3f42364485bfdab19c1373b5cd62f7c5ab7cc052e19644862ec8f15bb8af289e",
"sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22", "sha256:3fddcdb619ba04491e8f771636583a7cc5a5051cd193ff1aa1ee8616d2a692c5",
"sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2", "sha256:463acf531f5d0925ca55904fa668bb3461c3ef6bc779e1d6d8a488092bdee378",
"sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2", "sha256:4fe29a070de394e449fd88ebe1624d1e2d7ddeed4c12e0b31624561b58948d9a",
"sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605", "sha256:55dd1cf09a1fd7c7b78425967aacae9b0d70125f7d3ab973fadc7b5abc3de652",
"sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb", "sha256:5a3ecc026ea0e14d0ad7cd990ea7f48bfcb3eb4271034657dc9d06933c6629a7",
"sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519", "sha256:5cfca31ab4c13552a0f354c87fbd7f162a4fafd25e6b521bba93a57fe6a3700a",
"sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0", "sha256:66822d01e82506a19407d1afc104c3fcea3b81d5eb11485e593ad6b8492f995a",
"sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc", "sha256:69e5ddc609230d4408277af135c5b5c8fe7a54b2bdb8ad7c5100b86b3aab04c6",
"sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b", "sha256:6b6d4050b208c8ff886fd3db6690bf04f9a48749d78b41b7a5bf24c236ab0165",
"sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f", "sha256:7a053bd4d65a3294b153bdd7724dce864a1d548416a5ef61f6d03bf149205160",
"sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075", "sha256:82283af99c1c3a5ba1da44c67296d5aad19f11c535b551a5ae55328a317ce331",
"sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef", "sha256:8782189c796eff29dbb37dd87afa4ad4d40fc90b2742704f94812851b725964b",
"sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb", "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458",
"sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a", "sha256:97bda660702a856c2c9e12ec26fc6d187631ddfd896ff685814ab21ef0597033",
"sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b" "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", "index": "pypi",
"version": "==0.950" "version": "==9.1.0"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
],
"version": "==0.4.3"
}, },
"sqlparse": { "sqlparse": {
"hashes": [ "hashes": [
@@ -75,22 +83,6 @@
], ],
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.5'",
"version": "==0.4.2" "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": { "develop": {

View File

@@ -1,8 +1,42 @@
import os
from pathlib import Path
from django.db import models 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.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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): class Board(models.Model):
@@ -39,8 +73,52 @@ class Post(models.Model):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
# Last bump time # Last bump time
last_bump = models.DateTimeField(auto_now_add=True) 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): def get_absolute_url(self):
if self.op is None: 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): def post_created(sender, instance, created, **kwargs):
if created and instance.op: if created and instance.op:
instance.op.last_bump = timezone.now() instance.op.last_bump = timezone.now()
instance.op.save() 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)

View File

@@ -16,6 +16,24 @@ hr {
/* Posts */ /* Posts */
/*.post_body { }*/ /*.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 { .post_id {
cursor: pointer; cursor: pointer;

View File

@@ -24,7 +24,7 @@
<h2>Create a new thread</h2> <h2>Create a new thread</h2>
</div> </div>
<div class="row"> <div class="row">
<form method="post" action="{% url 'board:board_detail' url=board.url %}"> <form method="post" action="{% url 'board:board_detail' url=board.url %}" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<table> <table>
{{ form.as_table }} {{ form.as_table }}

View File

@@ -20,7 +20,7 @@
<div class="row"> <div class="row">
<div class="column">&nbsp;</div> <div class="column">&nbsp;</div>
<div class="column"> <div class="column">
<form method="post" action="{% url 'board:post_detail' url=board.url id=post.id %}"> <form method="post" action="{% url 'board:post_detail' url=board.url id=post.id %}" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<table> <table>
{{ form.as_table }} {{ form.as_table }}

View File

@@ -1,6 +1,23 @@
{% load post_body %} {% load post_body %}
<div id="p{{post.id}}"> <div id="p{{post.id}}">
{# TODO images #} {# Image #}
{% if post.thumbnail %}
{# Image info #}
<div class="post_image_info">
File: <a href="{{post.image.url}}" target="_blank">{{post.original_image_name}}</a>
({{post.image.size|measure_bytes}}, {{post.image_width}}x{{post.image_height}})
</div>
{# Image thumbnail #}
<div class="post_image_thumbnail">
<a href="{{post.image.url}}" target="_blank">
<img src="{{post.thumbnail.url}}">
</a>
</div>
{% endif %}
{# Post ID, username, time #}
<div class="post_content">
<a href="#p{{post.id}}">#.</a> <a href="#p{{post.id}}">#.</a>
<span class="post_id">{{post.id}}</span> <span class="post_id">{{post.id}}</span>
{% if post.subject %} {% if post.subject %}
@@ -11,11 +28,16 @@ at {{post.created}}
{% if reply_link %} {% if reply_link %}
[<a href="{{post.get_absolute_url}}">Reply</a>] [<a href="{{post.get_absolute_url}}">Reply</a>]
{% endif %} {% endif %}
{# "X replies elided" dialog for OPs on the board #}
{% if replies_elided > 0 %} {% if replies_elided > 0 %}
<br/> <br/>
<span class="replies_elided"> <span class="replies_elided">
({{replies_elided}} replies elided, click reply to view) ({{replies_elided}} replies elided, click reply to view)
</span> </span>
{% endif %} {% endif %}
{# Post body #}
<p class="post_body">{{post|post_body|safe}}</p> <p class="post_body">{{post|post_body|safe}}</p>
</div>
</div> </div>

View File

@@ -91,3 +91,13 @@ def post_body(post: Post) -> str:
# Finally, replace linebreaks # Finally, replace linebreaks
text = text.replace("\n", "<br/>") text = text.replace("\n", "<br/>")
return text 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"

View File

@@ -1,4 +1,6 @@
from django.urls import path from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
from board.views import * from board.views import *
@@ -8,3 +10,5 @@ urlpatterns = [
path("<slug:url>/page/<int:page>/", BoardView.as_view(), name="board_detail"), path("<slug:url>/page/<int:page>/", BoardView.as_view(), name="board_detail"),
path("<slug:url>/post/<int:id>/", PostView.as_view(), name="post_detail"), path("<slug:url>/post/<int:id>/", 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)

View File

@@ -25,7 +25,7 @@ def get_client_ip(request):
class BoardView(CreateView): class BoardView(CreateView):
model = Post model = Post
fields = ["subject", "name", "text"] fields = ["subject", "name", "text", "image"]
slug_field = "url" slug_field = "url"
slug_url_kwarg = "url" slug_url_kwarg = "url"
@@ -57,13 +57,12 @@ class BoardView(CreateView):
form.instance.ip = get_client_ip(self.request) form.instance.ip = get_client_ip(self.request)
self.object = form.save() self.object = form.save()
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
class PostView(CreateView): class PostView(CreateView):
model = Post model = Post
fields = ["name", "text"] fields = ["name", "text", "image"]
slug_field = "url" slug_field = "url"
slug_url_kwarg = "url" slug_url_kwarg = "url"

View File

@@ -124,3 +124,11 @@ STATIC_URL = "static/"
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 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)