mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-25 02:24:26 +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
|
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
|
# 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.http import HttpRequest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from PIL import ExifTags
|
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:
|
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:]
|
return "P" + str(start.year)[-2:]
|
||||||
|
|
||||||
|
|
||||||
def scale_dimension(width, height, long_edge):
|
def resize_image(im: Image, edge: int, img_format: str):
|
||||||
ratio = long_edge / max(width, height)
|
"""Resize an image to fit the given edge length and format."""
|
||||||
return int(width * ratio), int(height * ratio)
|
|
||||||
|
|
||||||
|
|
||||||
def resize_image(im, edge, img_format):
|
|
||||||
(w, h) = im.size
|
(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()
|
img_format = img_format.upper()
|
||||||
content = BytesIO()
|
content = BytesIO()
|
||||||
# use the lanczos filter for antialiasing and discard the alpha channel
|
# 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":
|
if img_format == "JPEG":
|
||||||
# converting an image with an alpha channel to jpeg would cause a crash
|
# converting an image with an alpha channel to jpeg would cause a crash
|
||||||
im = im.convert("RGB")
|
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 accounting.models import CurrencyField
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
|
from core.fields import ResizedImageField
|
||||||
from core.models import Group, Notification, User
|
from core.models import Group, Notification, User
|
||||||
from core.utils import get_start_of_semester
|
from core.utils import get_start_of_semester
|
||||||
from sith.settings import SITH_COUNTER_OFFICES, SITH_MAIN_CLUB
|
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)
|
name = models.CharField(_("name"), max_length=30)
|
||||||
description = models.TextField(_("description"), null=True, blank=True)
|
description = models.TextField(_("description"), null=True, blank=True)
|
||||||
comment = models.TextField(_("comment"), 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
|
# priority holds no real backend logic but helps to handle the order in which
|
||||||
# the items are to be shown to the user
|
# the items are to be shown to the user
|
||||||
@ -250,8 +253,13 @@ class Product(models.Model):
|
|||||||
purchase_price = CurrencyField(_("purchase price"))
|
purchase_price = CurrencyField(_("purchase price"))
|
||||||
selling_price = CurrencyField(_("selling price"))
|
selling_price = CurrencyField(_("selling price"))
|
||||||
special_selling_price = CurrencyField(_("special selling price"))
|
special_selling_price = CurrencyField(_("special selling price"))
|
||||||
icon = models.ImageField(
|
icon = ResizedImageField(
|
||||||
upload_to="products", null=True, blank=True, verbose_name=_("icon")
|
height=70,
|
||||||
|
force_format="WEBP",
|
||||||
|
upload_to="products",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("icon"),
|
||||||
)
|
)
|
||||||
club = models.ForeignKey(
|
club = models.ForeignKey(
|
||||||
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
|
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
|
- reference/com/views.md
|
||||||
- core:
|
- core:
|
||||||
- reference/core/models.md
|
- reference/core/models.md
|
||||||
|
- Champs de modèle: reference/core/model_fields.md
|
||||||
- reference/core/views.md
|
- reference/core/views.md
|
||||||
- reference/core/schemas.md
|
- reference/core/schemas.md
|
||||||
- reference/core/api_permissions.md
|
- reference/core/api_permissions.md
|
||||||
|
Loading…
Reference in New Issue
Block a user