From 859c3613bfaa51c87ac822eea843545c54427df9 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:20:55 +0100 Subject: [PATCH 01/21] Compress images --- app/types/content_type.py | 10 ++++++++ app/utils/tools.py | 52 ++++++++++++++++++++++++++++++++++++++- tests/test_utils.py | 30 ++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/app/types/content_type.py b/app/types/content_type.py index e82c6356dd..1e5dd5208e 100644 --- a/app/types/content_type.py +++ b/app/types/content_type.py @@ -10,3 +10,13 @@ class ContentType(str, Enum): png = "image/png" webp = "image/webp" pdf = "application/pdf" + + +class PillowImageFormat(str, Enum): + """ + Accepted image formats for Pillow + """ + + jpg = "JPEG" + png = "PNG" + webp = "WEBP" diff --git a/app/utils/tools.py b/app/utils/tools.py index 4cdb1613e8..9af2b3537d 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -7,6 +7,7 @@ import unicodedata from collections.abc import Callable, Sequence from inspect import iscoroutinefunction +from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Any, TypeVar from uuid import UUID @@ -19,6 +20,7 @@ from fastapi.templating import Jinja2Templates from jellyfish import jaro_winkler_similarity from jinja2 import Environment, FileSystemLoader, select_autoescape +from PIL import Image, ImageEnhance, ImageFilter, ImageOps from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from weasyprint import CSS, HTML @@ -31,7 +33,7 @@ from app.core.users.models_users import CoreUser from app.core.utils import security from app.types import core_data -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.exceptions import ( CoreDataNotFoundError, FileDoesNotExistError, @@ -485,6 +487,54 @@ async def save_pdf_first_page_as_image( ) +async def compress_image( + file_bytes: bytes, + height: int | None = None, + width: int | None = None, + quality: int = 85, + output_format: PillowImageFormat = PillowImageFormat.webp, +) -> bytes: + """ + Resize, crop and compress an image using Pillow. + + - If `height` or `width` is None, the original image dimension will be used. + - The image aspect ratio will be preserved. + - The resulting image will be centered if cropping is needed. + + Don't forget to take into account the output format when saving the image. + """ + image = Image.open(BytesIO(file_bytes)) + + if height is None: + height = image.height + if width is None: + width = image.width + + # Preserve aspect ratio + ratio = min(width / image.width, height / image.height) + new_size = (int(image.width * ratio), int(image.height * ratio)) + resized_image = image.resize(new_size) + + # We may want to crop the image, the resulting image will be centered + left = (resized_image.width - width) // 2 + top = (resized_image.height - height) // 2 + right = (resized_image.width + width) // 2 + bottom = (resized_image.height + height) // 2 + + cropped_image = resized_image.crop( + ( + left, + top, + right, + bottom, + ), + ) + + output = BytesIO() + cropped_image.save(output, format=output_format, quality=quality) + return output.getvalue() + + def get_random_string(length: int = 5) -> str: return "".join( secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(length) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0474e994e9..562abd4636 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,16 +1,20 @@ import shutil import uuid +from io import BytesIO from pathlib import Path import pytest import pytest_asyncio from fastapi import HTTPException, UploadFile +from PIL import Image from starlette.datastructures import Headers from app.core.core_endpoints import models_core +from app.types.content_type import PillowImageFormat from app.types.core_data import BaseCoreData from app.types.exceptions import CoreDataNotFoundError, FileNameIsNotAnUUIDError from app.utils.tools import ( + compress_image, delete_file_from_data, get_core_data, get_file_from_data, @@ -237,6 +241,32 @@ def test_delete_file_raise_a_value_error_if_filename_isnt_an_uuid() -> None: ) +@pytest.mark.parametrize( + ("height", "width"), + [ + (100, 100), + (300, 300), + (50, 100), + (100, 50), + ], +) +async def test_compress(height: int, width: int) -> None: + with Path("assets/images/default_profile_picture.png").open("rb") as file: + file_bytes = file.read() + res = await compress_image( + file_bytes, + height=height, + width=width, + quality=70, + output_format=PillowImageFormat.webp, + ) + + res_image = Image.open(BytesIO(res)) + assert res_image.height == height + assert res_image.width == width + assert res_image.format == "WEBP" + + async def test_save_pdf_first_page_as_image() -> None: valid_uuid = str(uuid.uuid4()) From 51d0f7888a1af4e88bc9b425dc8605d65801418a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:04:54 +0100 Subject: [PATCH 02/21] Utils: ensure_file_properties --- app/utils/tools.py | 79 ++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/app/utils/tools.py b/app/utils/tools.py index 9af2b3537d..ace7ca78c9 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -164,35 +164,21 @@ async def is_user_id_valid(user_id: str, db: AsyncSession) -> bool: return await cruds_users.get_user_by_id(db=db, user_id=user_id) is not None -async def save_file_as_data( +async def ensure_file_properties( upload_file: UploadFile, - directory: str, - filename: str | UUID, - max_file_size: int = 1024 * 1024 * 2, # 2 MB accepted_content_types: list[ContentType] | None = None, -): + max_file_size: int = 1024 * 1024 * 5, # 5 MB +) -> None: """ - Save an image or pdf file to the data folder. - - - The file will be saved in the `data` folder: "data/{directory}/{filename}.ext" - - Maximum size is 2MB by default, it can be changed using `max_file_size` (in bytes) parameter. + Ensure that the provided file respects the properties: + - Maximum size is 5 MB by default, it can be changed using `max_file_size` (in bytes) parameter. - `accepted_content_types` is a list of accepted content types. By default, all format are accepted. Use: `["image/jpeg", "image/png", "image/webp"]` to accept only images. - - Filename should be an uuid. - - The file extension will be inferred from the provided content file. - There should only be one file with the same filename, thus, saving a new file will remove the existing even if its extension was different. - Currently, compatible extensions are defined in the enum `ContentType` - An HTTP Exception will be raised if an error occurres. + An HTTP Exception will be raised if an error occurs. - The filename should be a uuid. - - WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. + The file will not be saved nor modified. """ - if isinstance(filename, UUID): - filename = str(filename) - if accepted_content_types is None: # Accept only images by default accepted_content_types = [ @@ -202,12 +188,6 @@ async def save_file_as_data( ContentType.pdf, ] - if not uuid_regex.match(filename): - hyperion_error_logger.error( - f"save_file_as_data: security issue, the filename is not a valid UUID: {filename}.", - ) - raise FileNameIsNotAnUUIDError() - if upload_file.content_type not in accepted_content_types: raise HTTPException( status_code=400, @@ -226,6 +206,48 @@ async def save_file_as_data( # We go back to the beginning of the file to save it on the disk await upload_file.seek(0) + +async def save_file_as_data( + upload_file: UploadFile, + directory: str, + filename: str | UUID, + max_file_size: int = 1024 * 1024 * 2, # 2 MB + accepted_content_types: list[ContentType] | None = None, +): + """ + Save an image or pdf file to the data folder. + + - The file will be saved in the `data` folder: "data/{directory}/{filename}.ext" + - Maximum size is 2MB by default, it can be changed using `max_file_size` (in bytes) parameter. + - `accepted_content_types` is a list of accepted content types. By default, all format are accepted. + Use: `["image/jpeg", "image/png", "image/webp"]` to accept only images. + - Filename should be an uuid. + + The file extension will be inferred from the provided content file. + There should only be one file with the same filename, thus, saving a new file will remove the existing even if its extension was different. + Currently, compatible extensions are defined in the enum `ContentType` + + An HTTP Exception will be raised if an error occurs. + + The filename should be a uuid. + + WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. + """ + if isinstance(filename, UUID): + filename = str(filename) + + if not uuid_regex.match(filename): + hyperion_error_logger.error( + f"save_file_as_data: security issue, the filename is not a valid UUID: {filename}.", + ) + raise FileNameIsNotAnUUIDError() + + await ensure_file_properties( + upload_file=upload_file, + accepted_content_types=accepted_content_types, + max_file_size=max_file_size, + ) + extension = ContentType(upload_file.content_type).name # Remove the existing file if any and create the new one @@ -503,7 +525,8 @@ async def compress_image( Don't forget to take into account the output format when saving the image. """ - image = Image.open(BytesIO(file_bytes)) + # We want to add an Alpha layer so that cropping does not produce black borders + image = Image.open(BytesIO(file_bytes)).convert("RGBA") if height is None: height = image.height From ef60e143e8213c72dfd7d802f6a6b2e473c3c53c Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:05:15 +0100 Subject: [PATCH 03/21] Process profile pictures and association logos --- .../associations/endpoints_associations.py | 32 +++++++++++++++---- app/core/users/endpoints_users.py | 26 ++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py index c88f9d7e32..68e755e8ca 100644 --- a/app/core/associations/endpoints_associations.py +++ b/app/core/associations/endpoints_associations.py @@ -17,9 +17,16 @@ is_user, is_user_in, ) -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import CoreModule -from app.utils.tools import get_file_from_data, save_file_as_data +from app.utils.tools import ( + compress_image, + ensure_file_properties, + ensure_maximal_file_size, + get_file_from_data, + save_bytes_as_data, + save_file_as_data, +) router = APIRouter(tags=["Associations"]) @@ -173,16 +180,29 @@ async def create_association_logo( if not association: raise HTTPException(status_code=404, detail="Association not found") - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="associations/logos", - filename=association_id, - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="associations/logos", + filename=association_id, + extension=ContentType.webp, ) diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 898aadae35..83df8c5890 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -42,15 +42,18 @@ is_user_super_admin, ) from app.types import standard_responses -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.exceptions import UserWithEmailAlreadyExistError from app.types.module import CoreModule from app.types.s3_access import S3Access from app.utils.communication.notifications import NotificationManager from app.utils.mail.mailworker import send_email from app.utils.tools import ( + compress_image, create_and_send_email_migration, + ensure_file_properties, get_file_from_data, + save_bytes_as_data, save_file_as_data, sort_user, ) @@ -1114,16 +1117,29 @@ async def create_current_user_profile_picture( **The user must be authenticated to use this endpoint** """ - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="profile-pictures", - filename=user.id, - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="profile-pictures", + filename=user.id, + extension=ContentType.webp, ) return standard_responses.Result(success=True) From 1f6034211cf253b0ae8491ac3cf60004806d89db Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:05:44 +0100 Subject: [PATCH 04/21] Lint --- app/core/associations/endpoints_associations.py | 2 -- app/core/users/endpoints_users.py | 1 - app/utils/tools.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py index 68e755e8ca..a00d4c0c4f 100644 --- a/app/core/associations/endpoints_associations.py +++ b/app/core/associations/endpoints_associations.py @@ -22,10 +22,8 @@ from app.utils.tools import ( compress_image, ensure_file_properties, - ensure_maximal_file_size, get_file_from_data, save_bytes_as_data, - save_file_as_data, ) router = APIRouter(tags=["Associations"]) diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 83df8c5890..40b2631b92 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -54,7 +54,6 @@ ensure_file_properties, get_file_from_data, save_bytes_as_data, - save_file_as_data, sort_user, ) diff --git a/app/utils/tools.py b/app/utils/tools.py index ace7ca78c9..5bf1e1af23 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -20,7 +20,7 @@ from fastapi.templating import Jinja2Templates from jellyfish import jaro_winkler_similarity from jinja2 import Environment, FileSystemLoader, select_autoescape -from PIL import Image, ImageEnhance, ImageFilter, ImageOps +from PIL import Image from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from weasyprint import CSS, HTML From 8462bac0a792f451567b63ed7dd628528b0434e0 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:44:15 +0100 Subject: [PATCH 05/21] Test compression --- tests/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index 562abd4636..3834266a00 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -248,6 +248,8 @@ def test_delete_file_raise_a_value_error_if_filename_isnt_an_uuid() -> None: (300, 300), (50, 100), (100, 50), + (100, None), + (None, 100), ], ) async def test_compress(height: int, width: int) -> None: From b84b7f1b4f8049da570e143bd35e87fa5907817e Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:52:18 +0100 Subject: [PATCH 06/21] Compress modules images --- app/core/groups/endpoints_groups.py | 26 +++++++++++--- app/modules/advert/endpoints_advert.py | 27 +++++++++++--- app/modules/calendar/endpoints_calendar.py | 31 +++++++++++++++- app/modules/campaign/endpoints_campaign.py | 27 +++++++++++--- app/modules/cinema/endpoints_cinema.py | 36 +++++++++++++++++-- app/modules/phonebook/endpoints_phonebook.py | 28 ++++++++++++--- app/modules/raffle/endpoints_raffle.py | 27 +++++++++++--- .../endpoints_recommendation.py | 32 +++++++++++++---- 8 files changed, 200 insertions(+), 34 deletions(-) diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index 8949fd240d..2ae3abbc0d 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -23,12 +23,15 @@ is_user, is_user_in, ) -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import CoreModule from app.utils.communication.notifications import NotificationManager from app.utils.tools import ( + compress_image, + ensure_file_properties, get_file_from_data, is_user_member_of_any_group, + save_bytes_as_data, save_file_as_data, ) @@ -374,16 +377,29 @@ async def create_group_logo( if not group: raise HTTPException(status_code=404, detail="Group not found") - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="groups/logos", - filename=group_id, - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="groups/logos", + filename=group_id, + extension=ContentType.webp, ) diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index 5b001c4917..c5c0c03fd9 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -24,13 +24,16 @@ schemas_advert, ) from app.modules.advert.factory_advert import AdvertFactory -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import Module from app.utils.communication.notifications import NotificationManager, NotificationTool from app.utils.tools import ( + compress_image, + ensure_file_properties, get_file_from_data, is_user_member_of_an_association, is_user_member_of_an_association_id, + save_bytes_as_data, save_file_as_data, ) @@ -323,14 +326,28 @@ async def create_advert_image( detail=f"Unauthorized to manage {advert.advertiser_id} adverts", ) - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="adverts", - filename=advert_id, - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="adverts", + filename=advert_id, + extension=ContentType.webp, ) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index 45d7526376..4bcf3d4970 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -32,15 +32,18 @@ ) from app.modules.calendar.factory_calendar import CalendarFactory from app.modules.calendar.types_calendar import Decision -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.exceptions import NewlyAddedObjectInDbNotFoundError from app.types.module import Module from app.utils.communication.notifications import NotificationManager, NotificationTool from app.utils.tools import ( + compress_image, delete_file_from_data, + ensure_file_properties, get_file_from_data, is_user_member_of_an_association, is_user_member_of_any_group, + save_bytes_as_data, save_file_as_data, ) @@ -241,6 +244,32 @@ async def create_event_image( ], ) + await ensure_file_properties( + upload_file=image, + accepted_content_types=[ + ContentType.jpg, + ContentType.png, + ContentType.webp, + ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="events", + filename=event_id, + extension=ContentType.webp, + ) + @module.router.post( "/calendar/events/", diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index 8f632898ee..bc7bbf631d 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -25,11 +25,14 @@ from app.modules.campaign.factory_campaign import CampaignFactory from app.modules.campaign.types_campaign import ListType, StatusType from app.types import standard_responses -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import Module from app.utils.tools import ( + compress_image, + ensure_file_properties, get_file_from_data, is_user_member_of_any_group, + save_bytes_as_data, save_file_as_data, ) @@ -816,16 +819,30 @@ async def create_campaigns_logo( detail="The list does not exist.", ) - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="campaigns", - filename=str(list_id), - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="campaigns", + filename=str(list_id), + extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/cinema/endpoints_cinema.py b/app/modules/cinema/endpoints_cinema.py index 96d15e49a1..d47df87aec 100644 --- a/app/modules/cinema/endpoints_cinema.py +++ b/app/modules/cinema/endpoints_cinema.py @@ -22,7 +22,7 @@ from app.modules.cinema import cruds_cinema, schemas_cinema from app.modules.cinema.factory_cinema import CinemaFactory from app.types import standard_responses -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import Module from app.types.scheduler import Scheduler from app.utils.communication.date_manager import ( @@ -31,7 +31,13 @@ get_previous_sunday, ) from app.utils.communication.notifications import NotificationTool -from app.utils.tools import get_file_from_data, save_file_as_data +from app.utils.tools import ( + compress_image, + ensure_file_properties, + get_file_from_data, + save_bytes_as_data, + save_file_as_data, +) root = "cinema" cinema_topic = Topic( @@ -226,6 +232,32 @@ async def create_campaigns_logo( ], ) + await ensure_file_properties( + upload_file=image, + accepted_content_types=[ + ContentType.jpg, + ContentType.png, + ContentType.webp, + ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="cinemasessions", + filename=str(session_id), + extension=ContentType.webp, + ) + return standard_responses.Result(success=True) diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index ee62ca0a0b..c24fd306a8 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -17,11 +17,14 @@ from app.modules.phonebook.factory_phonebook import PhonebookFactory from app.modules.phonebook.types_phonebook import RoleTags from app.types import standard_responses -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import Module from app.utils.tools import ( + compress_image, + ensure_file_properties, get_file_from_data, is_user_member_of_any_group, + save_bytes_as_data, save_file_as_data, ) @@ -690,17 +693,32 @@ async def create_association_logo( detail="The Association does not exist.", ) - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="associations", - filename=association_id, - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="associations", + filename=association_id, + extension=ContentType.webp, + ) + return standard_responses.Result(success=True) diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 500f106ae1..49ce53cd57 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -20,12 +20,15 @@ from app.modules.raffle import cruds_raffle, models_raffle, schemas_raffle from app.modules.raffle.types_raffle import RaffleStatusType from app.types import standard_responses -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import Module from app.utils.redis import locker_get, locker_set from app.utils.tools import ( + compress_image, + ensure_file_properties, get_file_from_data, is_user_member_of_any_group, + save_bytes_as_data, save_file_as_data, ) @@ -228,16 +231,30 @@ async def create_current_raffle_logo( detail=f"Raffle {raffle_id} is not in Creation Mode", ) - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="raffle-pictures", - filename=str(raffle_id), - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="raffle-pictures", + filename=str(raffle_id), + extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index c51dfdd0fd..8860763371 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -19,9 +19,15 @@ ) from app.modules.recommendation.factory_recommendation import RecommendationFactory from app.types import standard_responses -from app.types.content_type import ContentType +from app.types.content_type import ContentType, PillowImageFormat from app.types.module import Module -from app.utils.tools import get_file_from_data, save_file_as_data +from app.utils.tools import ( + compress_image, + ensure_file_properties, + get_file_from_data, + save_bytes_as_data, + save_file_as_data, +) router = APIRouter() @@ -184,16 +190,30 @@ async def create_recommendation_image( if not recommendation: raise HTTPException(status_code=404, detail="The recommendation does not exist") - await save_file_as_data( + await ensure_file_properties( upload_file=image, - directory="recommendations", - filename=str(recommendation_id), - max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], + max_file_size=1024 * 1024 * 5, # 5 MB + ) + + file_bytes = await compress_image( + file_bytes=await image.read(), + # TODO: change size + height=300, + width=300, + quality=85, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory="recommendations", + filename=str(recommendation_id), + extension=ContentType.webp, ) return standard_responses.Result(success=True) From 58d2efdbe38a2e3527dfe10561f00a64e58bcf14 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:54:35 +0100 Subject: [PATCH 07/21] Lint --- app/core/groups/endpoints_groups.py | 1 - app/modules/advert/endpoints_advert.py | 1 - app/modules/campaign/endpoints_campaign.py | 1 - app/modules/phonebook/endpoints_phonebook.py | 1 - app/modules/recommendation/endpoints_recommendation.py | 1 - 5 files changed, 5 deletions(-) diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index 2ae3abbc0d..11e7c107e6 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -32,7 +32,6 @@ get_file_from_data, is_user_member_of_any_group, save_bytes_as_data, - save_file_as_data, ) router = APIRouter(tags=["Groups"]) diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index c5c0c03fd9..d34ff48493 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -34,7 +34,6 @@ is_user_member_of_an_association, is_user_member_of_an_association_id, save_bytes_as_data, - save_file_as_data, ) root = "advert" diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index bc7bbf631d..cd62b0d080 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -33,7 +33,6 @@ get_file_from_data, is_user_member_of_any_group, save_bytes_as_data, - save_file_as_data, ) module = Module( diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index c24fd306a8..f8298ba140 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -25,7 +25,6 @@ get_file_from_data, is_user_member_of_any_group, save_bytes_as_data, - save_file_as_data, ) module = Module( diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index 8860763371..9955419a60 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -26,7 +26,6 @@ ensure_file_properties, get_file_from_data, save_bytes_as_data, - save_file_as_data, ) router = APIRouter() From fec63a294150c7ee96f00bcd84cc2f50832055d2 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:22:30 +0100 Subject: [PATCH 08/21] compress_and_save_image_file --- .../associations/endpoints_associations.py | 22 ++---- app/core/groups/endpoints_groups.py | 22 ++---- app/core/users/endpoints_users.py | 22 ++---- app/modules/advert/endpoints_advert.py | 22 ++---- app/modules/calendar/endpoints_calendar.py | 22 ++---- app/modules/campaign/endpoints_campaign.py | 22 ++---- app/modules/cinema/endpoints_cinema.py | 31 +-------- app/modules/phonebook/endpoints_phonebook.py | 22 ++---- app/modules/raffle/endpoints_raffle.py | 22 ++---- .../endpoints_recommendation.py | 22 ++---- app/types/content_type.py | 15 ++++ app/utils/tools.py | 69 +++++++++++++++++++ tests/test_utils.py | 25 ++++++- 13 files changed, 156 insertions(+), 182 deletions(-) diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py index a00d4c0c4f..17901b0b17 100644 --- a/app/core/associations/endpoints_associations.py +++ b/app/core/associations/endpoints_associations.py @@ -17,13 +17,11 @@ is_user, is_user_in, ) -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import CoreModule from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, - save_bytes_as_data, ) router = APIRouter(tags=["Associations"]) @@ -178,29 +176,19 @@ async def create_association_logo( if not association: raise HTTPException(status_code=404, detail="Association not found") - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="associations/logos", + filename=association_id, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="associations/logos", - filename=association_id, - extension=ContentType.webp, ) diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index 11e7c107e6..570b109488 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -23,15 +23,13 @@ is_user, is_user_in, ) -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import CoreModule from app.utils.communication.notifications import NotificationManager from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, is_user_member_of_any_group, - save_bytes_as_data, ) router = APIRouter(tags=["Groups"]) @@ -376,29 +374,19 @@ async def create_group_logo( if not group: raise HTTPException(status_code=404, detail="Group not found") - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="groups/logos", + filename=group_id, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="groups/logos", - filename=group_id, - extension=ContentType.webp, ) diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 40b2631b92..6c38f1ca53 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -42,18 +42,16 @@ is_user_super_admin, ) from app.types import standard_responses -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.exceptions import UserWithEmailAlreadyExistError from app.types.module import CoreModule from app.types.s3_access import S3Access from app.utils.communication.notifications import NotificationManager from app.utils.mail.mailworker import send_email from app.utils.tools import ( - compress_image, + compress_and_save_image_file, create_and_send_email_migration, - ensure_file_properties, get_file_from_data, - save_bytes_as_data, sort_user, ) @@ -1116,29 +1114,19 @@ async def create_current_user_profile_picture( **The user must be authenticated to use this endpoint** """ - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="profile-pictures", + filename=user.id, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="profile-pictures", - filename=user.id, - extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index d34ff48493..57fb5d5830 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -24,16 +24,14 @@ schemas_advert, ) from app.modules.advert.factory_advert import AdvertFactory -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import Module from app.utils.communication.notifications import NotificationManager, NotificationTool from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, is_user_member_of_an_association, is_user_member_of_an_association_id, - save_bytes_as_data, ) root = "advert" @@ -325,28 +323,18 @@ async def create_advert_image( detail=f"Unauthorized to manage {advert.advertiser_id} adverts", ) - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="adverts", + filename=advert_id, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="adverts", - filename=advert_id, - extension=ContentType.webp, ) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index 4bcf3d4970..62341bc177 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -32,18 +32,16 @@ ) from app.modules.calendar.factory_calendar import CalendarFactory from app.modules.calendar.types_calendar import Decision -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.exceptions import NewlyAddedObjectInDbNotFoundError from app.types.module import Module from app.utils.communication.notifications import NotificationManager, NotificationTool from app.utils.tools import ( - compress_image, + compress_and_save_image_file, delete_file_from_data, - ensure_file_properties, get_file_from_data, is_user_member_of_an_association, is_user_member_of_any_group, - save_bytes_as_data, save_file_as_data, ) @@ -244,30 +242,20 @@ async def create_event_image( ], ) - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="events", + filename=event_id, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="events", - filename=event_id, - extension=ContentType.webp, ) diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index cd62b0d080..b563bf51a7 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -25,14 +25,12 @@ from app.modules.campaign.factory_campaign import CampaignFactory from app.modules.campaign.types_campaign import ListType, StatusType from app.types import standard_responses -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import Module from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, is_user_member_of_any_group, - save_bytes_as_data, ) module = Module( @@ -818,30 +816,20 @@ async def create_campaigns_logo( detail="The list does not exist.", ) - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="campaigns", + filename=str(list_id), accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="campaigns", - filename=str(list_id), - extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/cinema/endpoints_cinema.py b/app/modules/cinema/endpoints_cinema.py index d47df87aec..4ce344f58d 100644 --- a/app/modules/cinema/endpoints_cinema.py +++ b/app/modules/cinema/endpoints_cinema.py @@ -22,7 +22,7 @@ from app.modules.cinema import cruds_cinema, schemas_cinema from app.modules.cinema.factory_cinema import CinemaFactory from app.types import standard_responses -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import Module from app.types.scheduler import Scheduler from app.utils.communication.date_manager import ( @@ -32,11 +32,8 @@ ) from app.utils.communication.notifications import NotificationTool from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, - save_bytes_as_data, - save_file_as_data, ) root = "cinema" @@ -220,42 +217,20 @@ async def create_campaigns_logo( detail="The session does not exist.", ) - await save_file_as_data( + await compress_and_save_image_file( upload_file=image, directory="cinemasessions", filename=str(session_id), - max_file_size=4 * 1024 * 1024, - accepted_content_types=[ - ContentType.jpg, - ContentType.png, - ContentType.webp, - ], - ) - - await ensure_file_properties( - upload_file=image, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="cinemasessions", - filename=str(session_id), - extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index f8298ba140..f1359eb174 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -17,14 +17,12 @@ from app.modules.phonebook.factory_phonebook import PhonebookFactory from app.modules.phonebook.types_phonebook import RoleTags from app.types import standard_responses -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import Module from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, is_user_member_of_any_group, - save_bytes_as_data, ) module = Module( @@ -692,30 +690,20 @@ async def create_association_logo( detail="The Association does not exist.", ) - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="associations", + filename=association_id, accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="associations", - filename=association_id, - extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 49ce53cd57..78639c6848 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -20,15 +20,13 @@ from app.modules.raffle import cruds_raffle, models_raffle, schemas_raffle from app.modules.raffle.types_raffle import RaffleStatusType from app.types import standard_responses -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import Module from app.utils.redis import locker_get, locker_set from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, is_user_member_of_any_group, - save_bytes_as_data, save_file_as_data, ) @@ -231,30 +229,20 @@ async def create_current_raffle_logo( detail=f"Raffle {raffle_id} is not in Creation Mode", ) - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="raffle-pictures", + filename=str(raffle_id), accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="raffle-pictures", - filename=str(raffle_id), - extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index 9955419a60..a9e180b001 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -19,13 +19,11 @@ ) from app.modules.recommendation.factory_recommendation import RecommendationFactory from app.types import standard_responses -from app.types.content_type import ContentType, PillowImageFormat +from app.types.content_type import ContentType from app.types.module import Module from app.utils.tools import ( - compress_image, - ensure_file_properties, + compress_and_save_image_file, get_file_from_data, - save_bytes_as_data, ) router = APIRouter() @@ -189,30 +187,20 @@ async def create_recommendation_image( if not recommendation: raise HTTPException(status_code=404, detail="The recommendation does not exist") - await ensure_file_properties( + await compress_and_save_image_file( upload_file=image, + directory="recommendations", + filename=str(recommendation_id), accepted_content_types=[ ContentType.jpg, ContentType.png, ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - ) - - file_bytes = await compress_image( - file_bytes=await image.read(), # TODO: change size height=300, width=300, quality=85, - output_format=PillowImageFormat.webp, - ) - - await save_bytes_as_data( - file_bytes=file_bytes, - directory="recommendations", - filename=str(recommendation_id), - extension=ContentType.webp, ) return standard_responses.Result(success=True) diff --git a/app/types/content_type.py b/app/types/content_type.py index 1e5dd5208e..65aa8bd826 100644 --- a/app/types/content_type.py +++ b/app/types/content_type.py @@ -11,6 +11,21 @@ class ContentType(str, Enum): webp = "image/webp" pdf = "application/pdf" + @property + def extension(self) -> str: + """ + Get the file extension corresponding to the content type + """ + if self == ContentType.jpg: + return "jpg" + if self == ContentType.png: + return "png" + if self == ContentType.webp: + return "webp" + if self == ContentType.pdf: + return "pdf" + raise ValueError(f"Unknown content type: {self}") + class PillowImageFormat(str, Enum): """ diff --git a/app/utils/tools.py b/app/utils/tools.py index 5bf1e1af23..a3ea461a49 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -558,6 +558,75 @@ async def compress_image( return output.getvalue() +async def compress_and_save_image_file( + upload_file: UploadFile, + directory: str, + filename: str | UUID, + accepted_content_types: list[ContentType] | None = None, + max_file_size: int = 1024 * 1024 * 5, # 5 MB + height: int | None = None, + width: int | None = None, + quality: int = 85, +): + """ + Save a compressed webp version of an input image in the data folder. + + The filename should be a uuid. + No verifications will be made about the content of the file, it is up to the caller to ensure the content is valid and safe. + + - The file will be saved in the `data` folder: "data/{directory}/{filename}.webp" + - An original copy of the file will be saved in "data/{directory}/original/{filename}.{upload_file extension}" + + Ensure that the provided file respects the properties: + - Maximum size is 5 MB by default, it can be changed using `max_file_size` (in bytes) parameter. + - `accepted_content_types` is a list of accepted content types. By default, all format are accepted. + Use: `["image/jpeg", "image/png", "image/webp"]` to accept only images. + + The image will be resized, cropped and compressed using Pillow. + + - If `height` or `width` is None, the original image dimension will be used. + - The image aspect ratio will be preserved. + - The resulting image will be centered if cropping is needed. + + An HTTP Exception will be raised if an error occurs. + + WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. + """ + await ensure_file_properties( + upload_file=upload_file, + accepted_content_types=accepted_content_types, + max_file_size=max_file_size, + ) + + original_file_bytes = await upload_file.read() + + original_directory = ( + f"{directory}{'/' if not directory.endswith('/') else ''}original" + ) + + await save_bytes_as_data( + file_bytes=original_file_bytes, + directory=original_directory, + filename=filename, + extension=ContentType(upload_file.content_type).extension, + ) + + file_bytes = await compress_image( + file_bytes=original_file_bytes, + height=height, + width=width, + quality=quality, + output_format=PillowImageFormat.webp, + ) + + await save_bytes_as_data( + file_bytes=file_bytes, + directory=directory, + filename=filename, + extension=ContentType.webp, + ) + + def get_random_string(length: int = 5) -> str: return "".join( secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(length) diff --git a/tests/test_utils.py b/tests/test_utils.py index 3834266a00..72df83b4d5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,10 +10,11 @@ from starlette.datastructures import Headers from app.core.core_endpoints import models_core -from app.types.content_type import PillowImageFormat +from app.types.content_type import ContentType, PillowImageFormat from app.types.core_data import BaseCoreData from app.types.exceptions import CoreDataNotFoundError, FileNameIsNotAnUUIDError from app.utils.tools import ( + compress_and_save_image_file, compress_image, delete_file_from_data, get_core_data, @@ -269,6 +270,28 @@ async def test_compress(height: int, width: int) -> None: assert res_image.format == "WEBP" +async def test_compress_and_save_image_file() -> None: + valid_uuid = str(uuid.uuid4()) + with Path("assets/images/default_profile_picture.png").open("rb") as file: + await compress_and_save_image_file( + upload_file=UploadFile( + file, + headers=Headers({"content-type": "image/png"}), + ), + directory="test/compressed", + filename=valid_uuid, + accepted_content_types=[ + ContentType.png, + ], + max_file_size=1024 * 1024 * 5, # 5 MB + height=300, + width=300, + quality=85, + ) + assert Path(f"data/test/compressed/{valid_uuid}.webp").is_file() + assert Path(f"data/test/compressed/original/{valid_uuid}.png").is_file() + + async def test_save_pdf_first_page_as_image() -> None: valid_uuid = str(uuid.uuid4()) From 97e93ba2e3b2937c8e42253fd5e04c757d111dad Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 11:25:32 +0100 Subject: [PATCH 09/21] Custom exception --- app/types/content_type.py | 4 +++- app/types/exceptions.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/types/content_type.py b/app/types/content_type.py index 65aa8bd826..4f530ac977 100644 --- a/app/types/content_type.py +++ b/app/types/content_type.py @@ -1,5 +1,7 @@ from enum import Enum +from app.types.exceptions import UnknownContentTypeExtensionError + class ContentType(str, Enum): """ @@ -24,7 +26,7 @@ def extension(self) -> str: return "webp" if self == ContentType.pdf: return "pdf" - raise ValueError(f"Unknown content type: {self}") + raise UnknownContentTypeExtensionError(content_type=self.value) class PillowImageFormat(str, Enum): diff --git a/app/types/exceptions.py b/app/types/exceptions.py index 0d5dee2ea8..625975fcbd 100644 --- a/app/types/exceptions.py +++ b/app/types/exceptions.py @@ -217,3 +217,10 @@ def __init__(self, object_name: str): super().__init__( f"Newly added object {object_name} not found in the database", ) + + +class UnknownContentTypeExtensionError(Exception): + def __init__(self, content_type: str): + super().__init__( + f"Unknown content type extension for content type: {content_type}", + ) From 3d6fcde600331b2381769292585bde0b1bdadc61 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:57:30 +0100 Subject: [PATCH 10/21] Fix size --- app/modules/advert/endpoints_advert.py | 5 ++--- app/modules/calendar/endpoints_calendar.py | 5 ++--- app/modules/campaign/endpoints_campaign.py | 1 - app/modules/cinema/endpoints_cinema.py | 5 ++--- app/modules/phonebook/endpoints_phonebook.py | 5 +---- app/modules/raffle/endpoints_raffle.py | 4 ---- app/modules/recommendation/endpoints_recommendation.py | 1 - 7 files changed, 7 insertions(+), 19 deletions(-) diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index 57fb5d5830..b8792e3f7a 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -333,8 +333,7 @@ async def create_advert_image( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size - height=300, - width=300, + height=315, + width=851, quality=85, ) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index 62341bc177..c9f547f577 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -252,9 +252,8 @@ async def create_event_image( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size - height=300, - width=300, + height=315, + width=851, quality=85, ) diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index b563bf51a7..4b9e2500bd 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -826,7 +826,6 @@ async def create_campaigns_logo( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size height=300, width=300, quality=85, diff --git a/app/modules/cinema/endpoints_cinema.py b/app/modules/cinema/endpoints_cinema.py index 4ce344f58d..3c38e63ec1 100644 --- a/app/modules/cinema/endpoints_cinema.py +++ b/app/modules/cinema/endpoints_cinema.py @@ -227,9 +227,8 @@ async def create_campaigns_logo( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size - height=300, - width=300, + height=750, + width=500, quality=85, ) diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index f1359eb174..ae8be82d2c 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -653,6 +653,7 @@ async def delete_membership( await cruds_phonebook.delete_membership(membership_id, db) +# TODO: is this endpoint used? @module.router.post( "/phonebook/associations/{association_id}/picture", response_model=standard_responses.Result, @@ -700,10 +701,6 @@ async def create_association_logo( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size - height=300, - width=300, - quality=85, ) return standard_responses.Result(success=True) diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 78639c6848..0a61ea99e5 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -239,10 +239,6 @@ async def create_current_raffle_logo( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size - height=300, - width=300, - quality=85, ) return standard_responses.Result(success=True) diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index a9e180b001..cac35e4324 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -197,7 +197,6 @@ async def create_recommendation_image( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB - # TODO: change size height=300, width=300, quality=85, From 1c39bb577e7bab8e51d4e79b5b3a5943dea1857d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:58:05 +0100 Subject: [PATCH 11/21] Remove await --- app/core/associations/endpoints_associations.py | 2 +- app/core/groups/endpoints_groups.py | 2 +- app/core/users/endpoints_users.py | 2 +- app/modules/advert/endpoints_advert.py | 2 +- app/modules/calendar/endpoints_calendar.py | 2 +- app/modules/campaign/endpoints_campaign.py | 2 +- app/modules/cinema/endpoints_cinema.py | 2 +- app/modules/phonebook/endpoints_phonebook.py | 2 +- app/modules/raffle/endpoints_raffle.py | 2 +- app/modules/recommendation/endpoints_recommendation.py | 2 +- app/utils/tools.py | 4 ++-- tests/test_utils.py | 4 ++-- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py index 17901b0b17..e4d879749f 100644 --- a/app/core/associations/endpoints_associations.py +++ b/app/core/associations/endpoints_associations.py @@ -176,7 +176,7 @@ async def create_association_logo( if not association: raise HTTPException(status_code=404, detail="Association not found") - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="associations/logos", filename=association_id, diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index 570b109488..8a6b47b6a5 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -374,7 +374,7 @@ async def create_group_logo( if not group: raise HTTPException(status_code=404, detail="Group not found") - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="groups/logos", filename=group_id, diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 6c38f1ca53..0e1a3bbce0 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -1114,7 +1114,7 @@ async def create_current_user_profile_picture( **The user must be authenticated to use this endpoint** """ - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="profile-pictures", filename=user.id, diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index b8792e3f7a..91301601df 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -323,7 +323,7 @@ async def create_advert_image( detail=f"Unauthorized to manage {advert.advertiser_id} adverts", ) - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="adverts", filename=advert_id, diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index c9f547f577..fee6f1481d 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -242,7 +242,7 @@ async def create_event_image( ], ) - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="events", filename=event_id, diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index 4b9e2500bd..3ba7f2efd0 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -816,7 +816,7 @@ async def create_campaigns_logo( detail="The list does not exist.", ) - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="campaigns", filename=str(list_id), diff --git a/app/modules/cinema/endpoints_cinema.py b/app/modules/cinema/endpoints_cinema.py index 3c38e63ec1..41398f9376 100644 --- a/app/modules/cinema/endpoints_cinema.py +++ b/app/modules/cinema/endpoints_cinema.py @@ -217,7 +217,7 @@ async def create_campaigns_logo( detail="The session does not exist.", ) - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="cinemasessions", filename=str(session_id), diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index ae8be82d2c..eebf14bdc4 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -691,7 +691,7 @@ async def create_association_logo( detail="The Association does not exist.", ) - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="associations", filename=association_id, diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 0a61ea99e5..b5bcfb3e11 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -229,7 +229,7 @@ async def create_current_raffle_logo( detail=f"Raffle {raffle_id} is not in Creation Mode", ) - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="raffle-pictures", filename=str(raffle_id), diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index cac35e4324..350ff26428 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -187,7 +187,7 @@ async def create_recommendation_image( if not recommendation: raise HTTPException(status_code=404, detail="The recommendation does not exist") - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=image, directory="recommendations", filename=str(recommendation_id), diff --git a/app/utils/tools.py b/app/utils/tools.py index a3ea461a49..bb928f96ff 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -509,7 +509,7 @@ async def save_pdf_first_page_as_image( ) -async def compress_image( +def compress_image( file_bytes: bytes, height: int | None = None, width: int | None = None, @@ -611,7 +611,7 @@ async def compress_and_save_image_file( extension=ContentType(upload_file.content_type).extension, ) - file_bytes = await compress_image( + file_bytes = compress_image( file_bytes=original_file_bytes, height=height, width=width, diff --git a/tests/test_utils.py b/tests/test_utils.py index 72df83b4d5..ca51764cc1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -256,7 +256,7 @@ def test_delete_file_raise_a_value_error_if_filename_isnt_an_uuid() -> None: async def test_compress(height: int, width: int) -> None: with Path("assets/images/default_profile_picture.png").open("rb") as file: file_bytes = file.read() - res = await compress_image( + res = compress_image( file_bytes, height=height, width=width, @@ -273,7 +273,7 @@ async def test_compress(height: int, width: int) -> None: async def test_compress_and_save_image_file() -> None: valid_uuid = str(uuid.uuid4()) with Path("assets/images/default_profile_picture.png").open("rb") as file: - await compress_and_save_image_file( + compress_and_save_image_file( upload_file=UploadFile( file, headers=Headers({"content-type": "image/png"}), From 6ea985b3263e214f4e519de6ba8b05f5664d016f Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:59:47 +0100 Subject: [PATCH 12/21] Migration --- migrations/versions/49-compress_images.py | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 migrations/versions/49-compress_images.py diff --git a/migrations/versions/49-compress_images.py b/migrations/versions/49-compress_images.py new file mode 100644 index 0000000000..9881afa616 --- /dev/null +++ b/migrations/versions/49-compress_images.py @@ -0,0 +1,117 @@ +"""empty message + +Create Date: 2025-12-08 11:28:24.682540 +""" + +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from app.types.content_type import PillowImageFormat +from app.utils.tools import compress_image + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "1ec573d854a1" +down_revision: str | None = "ecd89212ca0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +data_sources: dict[str, dict[str, int]] = { + "associations/logos": { + "height": 300, + "width": 300, + "quality": 85, + }, + "groups/logos": { + "height": 300, + "width": 300, + "quality": 85, + }, + "profile-pictures": { + "height": 300, + "width": 300, + "quality": 85, + }, + "adverts": { + "height": 315, + "width": 851, + "quality": 85, + }, + "events": { + "height": 315, + "width": 851, + "quality": 85, + }, + "campaigns": { + "height": 300, + "width": 300, + "quality": 85, + }, + "cinemasessions": { + "height": 750, + "width": 500, + "quality": 85, + }, + "recommendations": { + "height": 300, + "width": 300, + "quality": 85, + }, +} + + +def upgrade() -> None: + for data_folder, params in data_sources.items(): + print("__________________________________________") # noqa: T201 + print(f"Processing folder: {data_folder}") # noqa: T201 + height = params.get("height") + width = params.get("width") + quality = params.get("quality", 85) + for filename in Path("data/" + data_folder).iterdir(): + print(" - ", filename) # noqa: T201 + if filename.suffix in (".png", ".jpg", ".webp"): + with Path(f"{data_folder}/{filename}").open("rb") as file: + file_bytes = file.read() + + # Save the original file + with Path(f"{Path(data_folder)}/original/{filename}").open( + "wb", + ) as out_file: + out_file.write(file_bytes) + + # Compress and save the image + res = compress_image( + file_bytes, + height=height, + width=width, + quality=quality, + output_format=PillowImageFormat.webp, + ) + + with Path(f"{data_folder}/{filename.stem}.webp").open( + "wb", + ) as out_file: + out_file.write(res) + + +def downgrade() -> None: + pass + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass From 8f4041dc6c1663499d7ad99431b994ef20709b9b Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:08:48 +0100 Subject: [PATCH 13/21] fixup --- app/modules/calendar/endpoints_calendar.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index fee6f1481d..ae71127af5 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -230,21 +230,9 @@ async def create_event_image( detail="You are not allowed to access this event", ) - await save_file_as_data( - upload_file=image, - directory="event", - filename=event_id, - max_file_size=4 * 1024 * 1024, - accepted_content_types=[ - ContentType.jpg, - ContentType.png, - ContentType.webp, - ], - ) - compress_and_save_image_file( upload_file=image, - directory="events", + directory="event", filename=event_id, accepted_content_types=[ ContentType.jpg, From 000e47017ef659ffc779cbd48bd9b1d8bddd0a3a Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:16:28 +0100 Subject: [PATCH 14/21] Await and fit --- app/core/associations/endpoints_associations.py | 2 +- app/core/groups/endpoints_groups.py | 2 +- app/core/users/endpoints_users.py | 2 +- app/modules/advert/endpoints_advert.py | 3 ++- app/modules/calendar/endpoints_calendar.py | 3 ++- app/modules/campaign/endpoints_campaign.py | 2 +- app/modules/cinema/endpoints_cinema.py | 3 ++- app/modules/phonebook/endpoints_phonebook.py | 2 +- app/modules/raffle/endpoints_raffle.py | 3 ++- app/modules/recommendation/endpoints_recommendation.py | 2 +- app/utils/tools.py | 9 +++++++++ 11 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/core/associations/endpoints_associations.py b/app/core/associations/endpoints_associations.py index e4d879749f..17901b0b17 100644 --- a/app/core/associations/endpoints_associations.py +++ b/app/core/associations/endpoints_associations.py @@ -176,7 +176,7 @@ async def create_association_logo( if not association: raise HTTPException(status_code=404, detail="Association not found") - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="associations/logos", filename=association_id, diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index 8a6b47b6a5..570b109488 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -374,7 +374,7 @@ async def create_group_logo( if not group: raise HTTPException(status_code=404, detail="Group not found") - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="groups/logos", filename=group_id, diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 0e1a3bbce0..6c38f1ca53 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -1114,7 +1114,7 @@ async def create_current_user_profile_picture( **The user must be authenticated to use this endpoint** """ - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="profile-pictures", filename=user.id, diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index 91301601df..b0440fb5d8 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -323,7 +323,7 @@ async def create_advert_image( detail=f"Unauthorized to manage {advert.advertiser_id} adverts", ) - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="adverts", filename=advert_id, @@ -336,4 +336,5 @@ async def create_advert_image( height=315, width=851, quality=85, + fit=True, ) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index ae71127af5..f420415afc 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -230,7 +230,7 @@ async def create_event_image( detail="You are not allowed to access this event", ) - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="event", filename=event_id, @@ -243,6 +243,7 @@ async def create_event_image( height=315, width=851, quality=85, + fit=True, ) diff --git a/app/modules/campaign/endpoints_campaign.py b/app/modules/campaign/endpoints_campaign.py index 3ba7f2efd0..4b9e2500bd 100644 --- a/app/modules/campaign/endpoints_campaign.py +++ b/app/modules/campaign/endpoints_campaign.py @@ -816,7 +816,7 @@ async def create_campaigns_logo( detail="The list does not exist.", ) - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="campaigns", filename=str(list_id), diff --git a/app/modules/cinema/endpoints_cinema.py b/app/modules/cinema/endpoints_cinema.py index 41398f9376..c781275c16 100644 --- a/app/modules/cinema/endpoints_cinema.py +++ b/app/modules/cinema/endpoints_cinema.py @@ -217,7 +217,7 @@ async def create_campaigns_logo( detail="The session does not exist.", ) - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="cinemasessions", filename=str(session_id), @@ -230,6 +230,7 @@ async def create_campaigns_logo( height=750, width=500, quality=85, + fit=True, ) return standard_responses.Result(success=True) diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index eebf14bdc4..ae8be82d2c 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -691,7 +691,7 @@ async def create_association_logo( detail="The Association does not exist.", ) - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="associations", filename=association_id, diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index b5bcfb3e11..ba8337ab57 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -229,7 +229,7 @@ async def create_current_raffle_logo( detail=f"Raffle {raffle_id} is not in Creation Mode", ) - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="raffle-pictures", filename=str(raffle_id), @@ -239,6 +239,7 @@ async def create_current_raffle_logo( ContentType.webp, ], max_file_size=1024 * 1024 * 5, # 5 MB + fit=True, ) return standard_responses.Result(success=True) diff --git a/app/modules/recommendation/endpoints_recommendation.py b/app/modules/recommendation/endpoints_recommendation.py index 350ff26428..cac35e4324 100644 --- a/app/modules/recommendation/endpoints_recommendation.py +++ b/app/modules/recommendation/endpoints_recommendation.py @@ -187,7 +187,7 @@ async def create_recommendation_image( if not recommendation: raise HTTPException(status_code=404, detail="The recommendation does not exist") - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=image, directory="recommendations", filename=str(recommendation_id), diff --git a/app/utils/tools.py b/app/utils/tools.py index bb928f96ff..1c0115bcd0 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -515,6 +515,7 @@ def compress_image( width: int | None = None, quality: int = 85, output_format: PillowImageFormat = PillowImageFormat.webp, + fit: bool = False, ) -> bytes: """ Resize, crop and compress an image using Pillow. @@ -523,6 +524,9 @@ def compress_image( - The image aspect ratio will be preserved. - The resulting image will be centered if cropping is needed. + If `fit` is True, the image will be enlarged to fit the whole area defined by `height` and `width`. + Otherwise, the image will be reduced to fit inside the area defined by `height` and `width` without cropping. + Don't forget to take into account the output format when saving the image. """ # We want to add an Alpha layer so that cropping does not produce black borders @@ -567,6 +571,7 @@ async def compress_and_save_image_file( height: int | None = None, width: int | None = None, quality: int = 85, + fit: bool = False, ): """ Save a compressed webp version of an input image in the data folder. @@ -588,6 +593,9 @@ async def compress_and_save_image_file( - The image aspect ratio will be preserved. - The resulting image will be centered if cropping is needed. + If `fit` is True, the image will be enlarged to fit the whole area defined by `height` and `width`. + Otherwise, the image will be reduced to fit inside the area defined by `height` and `width` without cropping. + An HTTP Exception will be raised if an error occurs. WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. @@ -617,6 +625,7 @@ async def compress_and_save_image_file( width=width, quality=quality, output_format=PillowImageFormat.webp, + fit=fit, ) await save_bytes_as_data( From 231dd171fb6b4d2553e51d80f0db29c476bfc67d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:20:52 +0100 Subject: [PATCH 15/21] Fit --- app/utils/tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/utils/tools.py b/app/utils/tools.py index 1c0115bcd0..f396241add 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -538,7 +538,10 @@ def compress_image( width = image.width # Preserve aspect ratio - ratio = min(width / image.width, height / image.height) + if fit: + ratio = max(width / image.width, height / image.height) + else: + ratio = min(width / image.width, height / image.height) new_size = (int(image.width * ratio), int(image.height * ratio)) resized_image = image.resize(new_size) From 5192bc325253a20135f57606b00d510dbffbb6a0 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:22:31 +0100 Subject: [PATCH 16/21] Migration --- app/modules/calendar/endpoints_calendar.py | 1 - migrations/versions/49-compress_images.py | 66 +++++++++++++--------- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index f420415afc..9a58d231a4 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -42,7 +42,6 @@ get_file_from_data, is_user_member_of_an_association, is_user_member_of_any_group, - save_file_as_data, ) module = Module( diff --git a/migrations/versions/49-compress_images.py b/migrations/versions/49-compress_images.py index 9881afa616..755e61c3e2 100644 --- a/migrations/versions/49-compress_images.py +++ b/migrations/versions/49-compress_images.py @@ -41,11 +41,13 @@ "height": 315, "width": 851, "quality": 85, + "fit": 1, }, - "events": { + "event": { "height": 315, "width": 851, "quality": 85, + "fit": 1, }, "campaigns": { "height": 300, @@ -56,6 +58,7 @@ "height": 750, "width": 500, "quality": 85, + "fit": 1, }, "recommendations": { "height": 300, @@ -72,31 +75,42 @@ def upgrade() -> None: height = params.get("height") width = params.get("width") quality = params.get("quality", 85) - for filename in Path("data/" + data_folder).iterdir(): - print(" - ", filename) # noqa: T201 - if filename.suffix in (".png", ".jpg", ".webp"): - with Path(f"{data_folder}/{filename}").open("rb") as file: - file_bytes = file.read() - - # Save the original file - with Path(f"{Path(data_folder)}/original/{filename}").open( - "wb", - ) as out_file: - out_file.write(file_bytes) - - # Compress and save the image - res = compress_image( - file_bytes, - height=height, - width=width, - quality=quality, - output_format=PillowImageFormat.webp, - ) - - with Path(f"{data_folder}/{filename.stem}.webp").open( - "wb", - ) as out_file: - out_file.write(res) + fit = bool(params.get("fit", 0)) + if Path("data/" + data_folder).exists(): + for file_path in Path("data/" + data_folder).iterdir(): + print(" - ", file_path) # noqa: T201 + if file_path.suffix in (".png", ".jpg", ".webp"): + with Path(file_path).open("rb") as file: + file_bytes = file.read() + + Path(f"data/{data_folder}/original/").mkdir( + parents=True, + exist_ok=True, + ) + + # Save the original file + with Path(f"data/{data_folder}/original/{file_path.name}").open( + "wb", + ) as out_file: + out_file.write(file_bytes) + + # Compress and save the image + res = compress_image( + file_bytes, + height=height, + width=width, + quality=quality, + output_format=PillowImageFormat.webp, + fit=fit, + ) + + # Delete the original file + Path(f"data/{data_folder}/{file_path.name}").unlink() + + with Path(f"data/{data_folder}/{file_path.stem}.webp").open( + "wb", + ) as out_file: + out_file.write(res) def downgrade() -> None: From 945a51ea824b3ec3f7f13b5a6e0f942823ff7b62 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:24:42 +0100 Subject: [PATCH 17/21] fixup --- tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index ca51764cc1..e4e494492d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -273,7 +273,7 @@ async def test_compress(height: int, width: int) -> None: async def test_compress_and_save_image_file() -> None: valid_uuid = str(uuid.uuid4()) with Path("assets/images/default_profile_picture.png").open("rb") as file: - compress_and_save_image_file( + await compress_and_save_image_file( upload_file=UploadFile( file, headers=Headers({"content-type": "image/png"}), From e0bf4f3117723ef54216267ec853ec9a904f890f Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:38:51 +0100 Subject: [PATCH 18/21] fix test --- tests/test_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index e4e494492d..0bbd330273 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -265,8 +265,10 @@ async def test_compress(height: int, width: int) -> None: ) res_image = Image.open(BytesIO(res)) - assert res_image.height == height - assert res_image.width == width + if height is not None: + assert res_image.height == height + if width is not None: + assert res_image.width == width assert res_image.format == "WEBP" From 190e8b7773881bf5d9bf34fc473c12f6a581c5be Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:47:47 +0100 Subject: [PATCH 19/21] fixup --- tests/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0bbd330273..1495f8d364 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -290,8 +290,8 @@ async def test_compress_and_save_image_file() -> None: width=300, quality=85, ) - assert Path(f"data/test/compressed/{valid_uuid}.webp").is_file() - assert Path(f"data/test/compressed/original/{valid_uuid}.png").is_file() + assert Path(f"data/test/compressed/{valid_uuid}.webp").exists() + assert Path(f"data/test/compressed/original/{valid_uuid}.png").exists() async def test_save_pdf_first_page_as_image() -> None: From e7168c34ef5ffd29ed80b84769da3f5b1f5a1f7c Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:12:08 +0100 Subject: [PATCH 20/21] Fix str --- app/types/content_type.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/types/content_type.py b/app/types/content_type.py index 4f530ac977..18c5230b45 100644 --- a/app/types/content_type.py +++ b/app/types/content_type.py @@ -28,6 +28,9 @@ def extension(self) -> str: return "pdf" raise UnknownContentTypeExtensionError(content_type=self.value) + def __str__(self): + return self.extension + class PillowImageFormat(str, Enum): """ From 0ed2031926dc9c1262b3f081f20d516beb27b9a3 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:46:50 +0100 Subject: [PATCH 21/21] Remove comment --- app/modules/phonebook/endpoints_phonebook.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/modules/phonebook/endpoints_phonebook.py b/app/modules/phonebook/endpoints_phonebook.py index ae8be82d2c..b2a4838bd4 100644 --- a/app/modules/phonebook/endpoints_phonebook.py +++ b/app/modules/phonebook/endpoints_phonebook.py @@ -653,7 +653,6 @@ async def delete_membership( await cruds_phonebook.delete_membership(membership_id, db) -# TODO: is this endpoint used? @module.router.post( "/phonebook/associations/{association_id}/picture", response_model=standard_responses.Result,