diff --git a/core/fields.py b/core/fields.py new file mode 100644 index 00000000..feca650b --- /dev/null +++ b/core/fields.py @@ -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(f".{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 diff --git a/core/utils.py b/core/utils.py index ef748b80..82fc78a7 100644 --- a/core/utils.py +++ b/core/utils.py @@ -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") diff --git a/counter/migrations/0022_alter_product_icon.py b/counter/migrations/0022_alter_product_icon.py new file mode 100644 index 00000000..69ed557b --- /dev/null +++ b/counter/migrations/0022_alter_product_icon.py @@ -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", + ), + ), + ] diff --git a/counter/models.py b/counter/models.py index 539eb052..c068328f 100644 --- a/counter/models.py +++ b/counter/models.py @@ -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 diff --git a/docs/reference/core/model_fields.md b/docs/reference/core/model_fields.md new file mode 100644 index 00000000..de681deb --- /dev/null +++ b/docs/reference/core/model_fields.md @@ -0,0 +1,6 @@ +::: core.fields + handler: python + options: + members: + - ResizedImageFieldFile + - ResizedImageField \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 822174ae..073f36df 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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