Skip to content

Commit

Permalink
feat(Proof): generate image thumbnail (size 400) on create (#486)
Browse files Browse the repository at this point in the history
  • Loading branch information
raphodn authored Oct 3, 2024
1 parent 7e0588b commit 9f72de6
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 15 deletions.
7 changes: 7 additions & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@
CORS_ALLOW_CREDENTIALS = True


# Pillow
# https://pillow.readthedocs.io/
# ------------------------------------------------------------------------------

THUMBNAIL_SIZE = (400, 400)


# Django REST Framework (DRF) & django-filters & drf-spectacular
# https://www.django-rest-framework.org/
# https://django-filter.readthedocs.io/
Expand Down
2 changes: 2 additions & 0 deletions open_prices/api/proofs/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ def test_proof_create(self):
headers={"Authorization": f"Bearer {self.user_session.token}"},
)
self.assertEqual(response.status_code, 201)
self.assertTrue(response.data["file_path"] is not None)
self.assertTrue(response.data["image_thumb_path"] is None) # .bin
self.assertEqual(response.data["currency"], "EUR")
self.assertEqual(response.data["price_count"], 0) # ignored
self.assertEqual(response.data["owner"], self.user_session.user.user_id)
Expand Down
8 changes: 6 additions & 2 deletions open_prices/api/proofs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ def upload(self, request: Request) -> Response:
{"file": ["This field is required."]},
status=status.HTTP_400_BAD_REQUEST,
)
file_path, mimetype = store_file(request.data.get("file"))
file_path, mimetype, image_thumb_path = store_file(request.data.get("file"))
proof_create_data = {
"file_path": file_path,
"mimetype": mimetype,
"image_thumb_path": image_thumb_path,
**{key: request.data.get(key) for key in Proof.CREATE_FIELDS},
}
# validate
Expand All @@ -76,6 +77,9 @@ def upload(self, request: Request) -> Response:
# get source
self.source = self.request.GET.get("app_name", "API")
# save
proof = serializer.save(owner=self.request.user.user_id, source=self.source)
proof = serializer.save(
owner=self.request.user.user_id,
source=self.source,
)
# return full proof
return Response(ProofFullSerializer(proof).data, status=status.HTTP_201_CREATED)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 5.1 on 2024-10-02 07:26

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("proofs", "0003_alter_proof_type"),
]

operations = [
migrations.AddField(
model_name="proof",
name="image_thumb_path",
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name="proof",
name="file_path",
field=models.CharField(blank=True, null=True),
),
]
19 changes: 17 additions & 2 deletions open_prices/proofs/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.conf import settings
from django.core.validators import ValidationError
from django.db import models
from django.db.models import Count
Expand All @@ -20,7 +21,7 @@ def with_stats(self):


class Proof(models.Model):
FILE_FIELDS = ["file_path", "mimetype"]
FILE_FIELDS = ["file_path", "mimetype", "image_thumb_path"]
UPDATE_FIELDS = ["type", "currency", "date"]
CREATE_FIELDS = UPDATE_FIELDS + ["location_osm_id", "location_osm_type"]
DUPLICATE_PRICE_FIELDS = [
Expand All @@ -30,10 +31,12 @@ class Proof(models.Model):
"currency",
] # "owner"

file_path = models.CharField()
file_path = models.CharField(blank=True, null=True)
mimetype = models.CharField(blank=True, null=True)
type = models.CharField(max_length=20, choices=proof_constants.TYPE_CHOICES)

image_thumb_path = models.CharField(blank=True, null=True)

location_osm_id = models.PositiveBigIntegerField(blank=True, null=True)
location_osm_type = models.CharField(
max_length=10,
Expand Down Expand Up @@ -130,6 +133,18 @@ def save(self, *args, **kwargs):
self.set_location()
super().save(*args, **kwargs)

@property
def file_path_full(self):
if self.file_path:
return str(settings.IMAGES_DIR / self.file_path)
return None

@property
def image_thumb_path_full(self):
if self.image_thumb_path:
return str(settings.IMAGES_DIR / self.image_thumb_path)
return None

@property
def is_type_single_shop(self):
return self.type in proof_constants.TYPE_SINGLE_SHOP_LIST
Expand Down
68 changes: 58 additions & 10 deletions open_prices/proofs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from mimetypes import guess_extension

from django.conf import settings
from PIL import Image, ImageOps


def get_file_extension_and_mimetype(file) -> tuple[str, str]:
Expand All @@ -27,6 +28,52 @@ def get_file_extension_and_mimetype(file) -> tuple[str, str]:
return extension, mimetype


def generate_full_path(current_dir, file_stem, extension):
"""
Generate the full path of the file.
Example: /path/to/img/0001/dWQ5Hjm1H6.png
"""
return current_dir / f"{file_stem}{extension}"


def generate_relative_path(current_dir_id_str, file_stem, extension):
"""
Generate the relative path of the file.
Example: 0001/dWQ5Hjm1H6.png
"""
return f"{current_dir_id_str}/{file_stem}{extension}"


def generate_thumbnail(
current_dir,
current_dir_id_str,
file_stem,
extension,
mimetype,
thumbnail_size=settings.THUMBNAIL_SIZE,
):
"""Generate a thumbnail for the image at the given path."""
image_thumb_path = None
if mimetype.startswith("image"):
file_full_path = generate_full_path(current_dir, file_stem, extension)
with Image.open(file_full_path) as img:
img_thumb = img.copy()
# set any rotation info
img_thumb = ImageOps.exif_transpose(img)
# transform into a thumbnail
img_thumb.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
image_thumb_full_path = generate_full_path(
current_dir, f"{file_stem}.{settings.THUMBNAIL_SIZE[0]}", extension
)
img_thumb.save(image_thumb_full_path) # exif will be stripped
image_thumb_path = generate_relative_path(
current_dir_id_str,
f"{file_stem}.{settings.THUMBNAIL_SIZE[0]}",
extension,
)
return image_thumb_path


def store_file(file):
"""
Create a file in the images directory with a random name and the
Expand All @@ -36,15 +83,12 @@ def store_file(file):
:return: the file path and the mimetype
"""
# Generate a random name for the file
# This name will be used to display the image to the client, so it
# shouldn't be discoverable
# This name will be used to display the image to the client, so it shouldn't be discoverable # noqa
file_stem = "".join(random.choices(string.ascii_letters + string.digits, k=10))
extension, mimetype = get_file_extension_and_mimetype(file)
# We store the images in directories containing up to 1000 images
# Once we reach 1000 images, we create a new directory by increasing
# the directory ID
# This is used to prevent the base image directory from containing too many
# files
# Once we reach 1000 images, we create a new directory by increasing the directory ID # noqa
# This is used to prevent the base image directory from containing too many files # noqa
images_dir = settings.IMAGES_DIR
current_dir_id = max(
(int(p.name) for p in images_dir.iterdir() if p.is_dir() and p.name.isdigit()),
Expand All @@ -57,10 +101,14 @@ def store_file(file):
current_dir_id += 1
current_dir = images_dir / str(current_dir_id)
current_dir.mkdir(exist_ok=True, parents=True)
full_file_path = current_dir / f"{file_stem}{extension}"
file_full_path = generate_full_path(current_dir, file_stem, extension)
# write the content of the file to the new file
with full_file_path.open("wb") as f:
with file_full_path.open("wb") as f:
f.write(file.file.read())
# create a thumbnail
image_thumb_path = generate_thumbnail(
current_dir, current_dir_id_str, file_stem, extension, mimetype
)
# Build file_path
file_path = f"{current_dir_id_str}/{file_stem}{extension}"
return (file_path, mimetype)
file_path = generate_relative_path(current_dir_id_str, file_stem, extension)
return (file_path, mimetype, image_thumb_path)
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ gunicorn = "^22.0.0"
django-cors-headers = "^4.4.0"
sentry-sdk = {extras = ["django"], version = "^2.13.0"}
django-solo = "^2.3.0"
pillow = "^10.4.0"

[tool.poetry.group.dev.dependencies]
black = "~23.12.1"
Expand Down

0 comments on commit 9f72de6

Please sign in to comment.