mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-25 09:11:13 +00:00
Merge pull request #824 from ae-utbm/compress-product-images
auto compress product icons
This commit is contained in:
commit
bf96d8a10c
@ -43,6 +43,18 @@ class CurrencyField(models.DecimalField):
|
||||
return None
|
||||
|
||||
|
||||
if settings.TESTING:
|
||||
from model_bakery import baker
|
||||
|
||||
baker.generators.add(
|
||||
CurrencyField,
|
||||
lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2),
|
||||
)
|
||||
else: # pragma: no cover
|
||||
# baker is only used in tests, so we don't need coverage for this part
|
||||
pass
|
||||
|
||||
|
||||
# Accounting classes
|
||||
|
||||
|
||||
|
125
core/fields.py
Normal file
125
core/fields.py
Normal file
@ -0,0 +1,125 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db import models
|
||||
from django.db.models.fields.files import ImageFieldFile
|
||||
from PIL import Image
|
||||
|
||||
from core.utils import resize_image_explicit
|
||||
|
||||
|
||||
class ResizedImageFieldFile(ImageFieldFile):
|
||||
def get_resized_dimensions(self, image: Image.Image) -> tuple[int, int]:
|
||||
"""Get the dimensions of the resized image.
|
||||
|
||||
If the width and height are given, they are used.
|
||||
If only one is given, the other is calculated to keep the same ratio.
|
||||
|
||||
Returns:
|
||||
Tuple of width and height
|
||||
"""
|
||||
width = self.field.width
|
||||
height = self.field.height
|
||||
if width is not None and height is not None:
|
||||
return self.field.width, self.field.height
|
||||
if width is None:
|
||||
width = int(image.width * height / image.height)
|
||||
elif height is None:
|
||||
height = int(image.height * width / image.width)
|
||||
return width, height
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Get the name of the resized image.
|
||||
|
||||
If the field has a force_format attribute,
|
||||
the extension of the file will be changed to match it.
|
||||
Otherwise, the name is left unchanged.
|
||||
|
||||
Raises:
|
||||
ValueError: If the image format is unknown
|
||||
"""
|
||||
if not self.field.force_format:
|
||||
return self.name
|
||||
formats = {val: key for key, val in Image.registered_extensions().items()}
|
||||
new_format = self.field.force_format
|
||||
if new_format in formats:
|
||||
extension = formats[new_format]
|
||||
else:
|
||||
raise ValueError(f"Unknown format {new_format}")
|
||||
return str(Path(self.file.name).with_suffix(extension))
|
||||
|
||||
def save(self, name, content, save=True): # noqa FBT002
|
||||
content.file.seek(0)
|
||||
img = Image.open(content.file)
|
||||
width, height = self.get_resized_dimensions(img)
|
||||
img_format = self.field.force_format or img.format
|
||||
new_content = resize_image_explicit(img, (width, height), img_format)
|
||||
name = self.get_name()
|
||||
return super().save(name, new_content, save)
|
||||
|
||||
|
||||
class ResizedImageField(models.ImageField):
|
||||
"""A field that automatically resizes images to a given size.
|
||||
|
||||
This field is useful for profile pictures or product icons, for example.
|
||||
|
||||
The final size of the image is determined by the width and height parameters :
|
||||
|
||||
- If both are given, the image will be resized
|
||||
to fit in a rectangle of width x height
|
||||
- If only one is given, the other will be calculated to keep the same ratio
|
||||
|
||||
If the force_format parameter is given, the image will be converted to this format.
|
||||
|
||||
Examples:
|
||||
To resize an image with a height of 100px, without changing the ratio,
|
||||
and a format of WEBP :
|
||||
|
||||
```python
|
||||
class Product(models.Model):
|
||||
icon = ResizedImageField(height=100, force_format="WEBP")
|
||||
```
|
||||
|
||||
To explicitly resize an image to 100x100px (but possibly change the ratio) :
|
||||
|
||||
```python
|
||||
class Product(models.Model):
|
||||
icon = ResizedImageField(width=100, height=100)
|
||||
```
|
||||
|
||||
Raises:
|
||||
FieldError: If neither width nor height is given
|
||||
|
||||
Args:
|
||||
width: If given, the width of the resized image
|
||||
height: If given, the height of the resized image
|
||||
force_format: If given, the image will be converted to this format
|
||||
"""
|
||||
|
||||
attr_class = ResizedImageFieldFile
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
force_format: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if width is None and height is None:
|
||||
raise FieldError(
|
||||
f"{self.__class__.__name__} requires "
|
||||
"width, height or both, but got neither"
|
||||
)
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.force_format = force_format
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def deconstruct(self):
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
if self.width is not None:
|
||||
kwargs["width"] = self.width
|
||||
if self.height is not None:
|
||||
kwargs["height"] = self.height
|
||||
kwargs["force_format"] = self.force_format
|
||||
return name, path, args, kwargs
|
@ -25,7 +25,7 @@ from django.core.files.base import ContentFile
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Resampling
|
||||
from PIL.Image import Image, Resampling
|
||||
|
||||
|
||||
def get_start_of_semester(today: Optional[date] = None) -> date:
|
||||
@ -82,18 +82,20 @@ def get_semester_code(d: Optional[date] = None) -> str:
|
||||
return "P" + str(start.year)[-2:]
|
||||
|
||||
|
||||
def scale_dimension(width, height, long_edge):
|
||||
ratio = long_edge / max(width, height)
|
||||
return int(width * ratio), int(height * ratio)
|
||||
|
||||
|
||||
def resize_image(im, edge, img_format):
|
||||
def resize_image(im: Image, edge: int, img_format: str):
|
||||
"""Resize an image to fit the given edge length and format."""
|
||||
(w, h) = im.size
|
||||
(width, height) = scale_dimension(w, h, long_edge=edge)
|
||||
ratio = edge / max(w, h)
|
||||
(width, height) = int(w * ratio), int(h * ratio)
|
||||
return resize_image_explicit(im, (width, height), img_format)
|
||||
|
||||
|
||||
def resize_image_explicit(im: Image, size: tuple[int, int], img_format: str):
|
||||
"""Resize an image to the given size and format."""
|
||||
img_format = img_format.upper()
|
||||
content = BytesIO()
|
||||
# use the lanczos filter for antialiasing and discard the alpha channel
|
||||
im = im.resize((width, height), Resampling.LANCZOS)
|
||||
im = im.resize((size[0], size[1]), Resampling.LANCZOS)
|
||||
if img_format == "JPEG":
|
||||
# converting an image with an alpha channel to jpeg would cause a crash
|
||||
im = im.convert("RGB")
|
||||
|
37
counter/migrations/0022_alter_product_icon.py
Normal file
37
counter/migrations/0022_alter_product_icon.py
Normal file
@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.2.16 on 2024-09-14 18:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
import core.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("counter", "0021_rename_check_cashregistersummaryitem_is_checked"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="icon",
|
||||
field=core.fields.ResizedImageField(
|
||||
blank=True,
|
||||
height=70,
|
||||
force_format="WEBP",
|
||||
null=True,
|
||||
upload_to="products",
|
||||
verbose_name="icon",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="producttype",
|
||||
name="icon",
|
||||
field=core.fields.ResizedImageField(
|
||||
blank=True,
|
||||
force_format="WEBP",
|
||||
height=70,
|
||||
null=True,
|
||||
upload_to="products",
|
||||
),
|
||||
),
|
||||
]
|
@ -37,6 +37,7 @@ from django_countries.fields import CountryField
|
||||
|
||||
from accounting.models import CurrencyField
|
||||
from club.models import Club
|
||||
from core.fields import ResizedImageField
|
||||
from core.models import Group, Notification, User
|
||||
from core.utils import get_start_of_semester
|
||||
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
||||
@ -208,7 +209,9 @@ class ProductType(models.Model):
|
||||
name = models.CharField(_("name"), max_length=30)
|
||||
description = models.TextField(_("description"), null=True, blank=True)
|
||||
comment = models.TextField(_("comment"), null=True, blank=True)
|
||||
icon = models.ImageField(upload_to="products", null=True, blank=True)
|
||||
icon = ResizedImageField(
|
||||
height=70, force_format="WEBP", upload_to="products", null=True, blank=True
|
||||
)
|
||||
|
||||
# priority holds no real backend logic but helps to handle the order in which
|
||||
# the items are to be shown to the user
|
||||
@ -250,8 +253,13 @@ class Product(models.Model):
|
||||
purchase_price = CurrencyField(_("purchase price"))
|
||||
selling_price = CurrencyField(_("selling price"))
|
||||
special_selling_price = CurrencyField(_("special selling price"))
|
||||
icon = models.ImageField(
|
||||
upload_to="products", null=True, blank=True, verbose_name=_("icon")
|
||||
icon = ResizedImageField(
|
||||
height=70,
|
||||
force_format="WEBP",
|
||||
upload_to="products",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("icon"),
|
||||
)
|
||||
club = models.ForeignKey(
|
||||
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
||||
|
0
counter/tests/__init__.py
Normal file
0
counter/tests/__init__.py
Normal file
33
counter/tests/test_product.py
Normal file
33
counter/tests/test_product.py
Normal file
@ -0,0 +1,33 @@
|
||||
from io import BytesIO
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from model_bakery import baker
|
||||
from PIL import Image
|
||||
|
||||
from counter.models import Product, ProductType
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("model", [Product, ProductType])
|
||||
def test_resize_product_icon(model):
|
||||
"""Test that the product icon is resized when saved."""
|
||||
# Product and ProductType icons have a height of 70px
|
||||
# so this image should be resized to 50x70
|
||||
img = Image.new("RGB", (100, 140))
|
||||
content = BytesIO()
|
||||
img.save(content, format="JPEG")
|
||||
name = str(uuid4())
|
||||
|
||||
product = baker.make(
|
||||
model,
|
||||
icon=SimpleUploadedFile(
|
||||
f"{name}.jpg", content.getvalue(), content_type="image/jpeg"
|
||||
),
|
||||
)
|
||||
|
||||
assert product.icon.width == 50
|
||||
assert product.icon.height == 70
|
||||
assert product.icon.name == f"products/{name}.webp"
|
||||
assert Image.open(product.icon).format == "WEBP"
|
6
docs/reference/core/model_fields.md
Normal file
6
docs/reference/core/model_fields.md
Normal file
@ -0,0 +1,6 @@
|
||||
::: core.fields
|
||||
handler: python
|
||||
options:
|
||||
members:
|
||||
- ResizedImageFieldFile
|
||||
- ResizedImageField
|
@ -92,6 +92,7 @@ nav:
|
||||
- reference/com/views.md
|
||||
- core:
|
||||
- reference/core/models.md
|
||||
- Champs de modèle: reference/core/model_fields.md
|
||||
- reference/core/views.md
|
||||
- reference/core/schemas.md
|
||||
- reference/core/api_permissions.md
|
||||
|
Loading…
Reference in New Issue
Block a user