From 238f581baab6249105492b68aa62983a2b87a7bd Mon Sep 17 00:00:00 2001 From: Daniel Grams Date: Wed, 23 Jun 2021 16:04:06 +0200 Subject: [PATCH] API: Add image support #199 (#200) --- project/api/event/schemas.py | 14 +++- project/api/fields.py | 14 ++++ project/api/image/schemas.py | 99 ++++++++++++++++++++++-- project/api/organizer/schemas.py | 5 +- project/api/place/schemas.py | 5 +- project/forms/common.py | 29 +++---- project/imageutils.py | 56 ++++++++++++++ project/jinja_filters.py | 11 ++- project/models.py | 31 ++++++-- project/templates/event_date/search.html | 2 +- project/templates/organization/read.html | 2 +- requirements.txt | 1 + tests/api/test_event.py | 83 ++++++++++++++++++++ tests/api/test_image.py | 44 +++++++++++ tests/api/test_organization.py | 3 + tests/api/test_place.py | 32 ++++++++ tests/seeder.py | 2 +- tests/test_imageutils.py | 45 +++++++++++ 18 files changed, 441 insertions(+), 37 deletions(-) create mode 100644 project/imageutils.py create mode 100644 tests/api/test_image.py create mode 100644 tests/test_imageutils.py diff --git a/project/api/event/schemas.py b/project/api/event/schemas.py index 85b1b7c..1c43a3d 100644 --- a/project/api/event/schemas.py +++ b/project/api/event/schemas.py @@ -8,8 +8,12 @@ from project.api.event_category.schemas import ( EventCategoryRefSchema, EventCategoryWriteIdSchema, ) -from project.api.fields import CustomDateTimeField -from project.api.image.schemas import ImageSchema +from project.api.fields import CustomDateTimeField, Owned +from project.api.image.schemas import ( + ImagePatchRequestSchema, + ImagePostRequestSchema, + ImageSchema, +) from project.api.organization.schemas import OrganizationRefSchema from project.api.organizer.schemas import OrganizerRefSchema, OrganizerWriteIdSchema from project.api.place.schemas import ( @@ -260,7 +264,7 @@ class EventWriteSchemaMixin(object): default=50, validate=validate.OneOf([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), metadata={ - "description": "How relevant the event is to your organization. 0 (Little relevant), 5 (Default), 10 (Highlight)." + "description": "How relevant the event is to your organization. 0 (Little relevant), 50 (Default), 100 (Highlight)." }, ) @@ -272,6 +276,8 @@ class EventPostRequestSchema( super().__init__(*args, **kwargs) self.make_post_schema() + photo = Owned(ImagePostRequestSchema) + class EventPatchRequestSchema( EventModelSchema, EventBaseSchemaMixin, EventWriteSchemaMixin @@ -279,3 +285,5 @@ class EventPatchRequestSchema( def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.make_patch_schema() + + photo = Owned(ImagePatchRequestSchema) diff --git a/project/api/fields.py b/project/api/fields.py index 626227d..e181a4e 100644 --- a/project/api/fields.py +++ b/project/api/fields.py @@ -1,4 +1,5 @@ from marshmallow import ValidationError, fields +from marshmallow_sqlalchemy import fields as msfields from project.dateutils import berlin_tz @@ -31,3 +32,16 @@ class CustomDateTimeField(fields.DateTime): result = berlin_tz.localize(result) return result + + +class Owned(msfields.Nested): + def _deserialize(self, *args, **kwargs): + if ( + not self.root.transient + and hasattr(self.schema, "instance") + and self.schema.instance is None + and self.root.instance + and hasattr(self.root.instance, self.name) + ): + self.schema.instance = getattr(self.root.instance, self.name, None) + return super()._deserialize(*args, **kwargs) diff --git a/project/api/image/schemas.py b/project/api/image/schemas.py index 7732b0c..0c896c3 100644 --- a/project/api/image/schemas.py +++ b/project/api/image/schemas.py @@ -1,20 +1,33 @@ +from marshmallow import ValidationError, fields, post_load, validates_schema + from project.api import marshmallow +from project.api.schemas import IdSchemaMixin, SQLAlchemyBaseSchema +from project.imageutils import ( + get_bytes_from_image, + get_image_from_base64_str, + get_image_from_url, + get_mime_type_from_image, + resize_image_to_max, + validate_image, +) from project.models import Image -class ImageIdSchema(marshmallow.SQLAlchemySchema): +class ImageModelSchema(SQLAlchemyBaseSchema): class Meta: model = Image load_instance = True - id = marshmallow.auto_field() + +class ImageIdSchema(ImageModelSchema, IdSchemaMixin): + pass -class ImageBaseSchema(ImageIdSchema): +class ImageBaseSchemaMixin(object): copyright_text = marshmallow.auto_field() -class ImageSchema(ImageBaseSchema): +class ImageSchema(ImageIdSchema, ImageBaseSchemaMixin): image_url = marshmallow.URLFor( "image", values=dict(id="", s=500), @@ -24,5 +37,81 @@ class ImageSchema(ImageBaseSchema): ) -class ImageDumpSchema(ImageBaseSchema): +class ImageDumpSchema(ImageModelSchema, ImageBaseSchemaMixin): pass + + +class ImageWriteSchemaMixin(object): + image_url = fields.String( + required=False, + load_only=True, + allow_none=True, + metadata={ + "description": "URL to image. Either image_url or image_base64 has to be defined." + }, + ) + image_base64 = fields.String( + required=False, + load_only=True, + allow_none=True, + metadata={ + "description": "Base64 encoded image data. Either image_url or image_base64 has to be defined." + }, + ) + + @post_load(pass_original=True) + def post_load_image_data(self, item, original_data, **kwargs): + image_url = original_data.get("image_url") + image_base64 = original_data.get("image_base64") + + if image_url is not None or image_base64 is not None: + # Mal ist item ein dict und mal ein Image, weil post_load die Reihenfolge der Aufrufe nicht garantieren kann + if isinstance(item, dict): + encoding_format, data = self.load_image_data(image_base64, image_url) + item["encoding_format"] = encoding_format + item["data"] = data + + elif isinstance(item, Image): + encoding_format, data = self.load_image_data(image_base64, image_url) + item.encoding_format = encoding_format + item.data = data + + return item + + def load_image_data(self, image_base64, image_url): + image = None + + if image_base64: + image = get_image_from_base64_str(image_base64) + elif image_url: + image = get_image_from_url(image_url) + + if not image: + return None, None + + validate_image(image) + resize_image_to_max(image) + encoding_format = get_mime_type_from_image(image) + data = get_bytes_from_image(image) + return encoding_format, data + + +class ImagePostRequestSchema( + ImageModelSchema, ImageBaseSchemaMixin, ImageWriteSchemaMixin +): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_post_schema() + + @validates_schema + def validate_image(self, data, **kwargs): + if data.get("image_url") is None and data.get("image_base64") is None: + raise ValidationError("Either image_url or image_base64 has to be defined.") + + +class ImagePatchRequestSchema( + ImageModelSchema, ImageBaseSchemaMixin, ImageWriteSchemaMixin +): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_patch_schema() diff --git a/project/api/organizer/schemas.py b/project/api/organizer/schemas.py index 0daf2eb..50928a6 100644 --- a/project/api/organizer/schemas.py +++ b/project/api/organizer/schemas.py @@ -1,6 +1,7 @@ from marshmallow import fields, validate from project.api import marshmallow +from project.api.fields import Owned from project.api.image.schemas import ImageSchema from project.api.location.schemas import ( LocationPatchRequestSchema, @@ -78,7 +79,7 @@ class OrganizerPostRequestSchema(OrganizerModelSchema, OrganizerBaseSchemaMixin) super().__init__(*args, **kwargs) self.make_post_schema() - location = fields.Nested(LocationPostRequestSchema) + location = Owned(LocationPostRequestSchema) class OrganizerPatchRequestSchema(OrganizerModelSchema, OrganizerBaseSchemaMixin): @@ -86,4 +87,4 @@ class OrganizerPatchRequestSchema(OrganizerModelSchema, OrganizerBaseSchemaMixin super().__init__(*args, **kwargs) self.make_patch_schema() - location = fields.Nested(LocationPatchRequestSchema) + location = Owned(LocationPatchRequestSchema) diff --git a/project/api/place/schemas.py b/project/api/place/schemas.py index 0dc9763..3b6438e 100644 --- a/project/api/place/schemas.py +++ b/project/api/place/schemas.py @@ -1,6 +1,7 @@ from marshmallow import fields, validate from project.api import marshmallow +from project.api.fields import Owned from project.api.image.schemas import ImageSchema from project.api.location.schemas import ( LocationPatchRequestSchema, @@ -79,7 +80,7 @@ class PlacePostRequestSchema(PlaceModelSchema, PlaceBaseSchemaMixin): super().__init__(*args, **kwargs) self.make_post_schema() - location = fields.Nested(LocationPostRequestSchema) + location = Owned(LocationPostRequestSchema) class PlacePatchRequestSchema(PlaceModelSchema, PlaceBaseSchemaMixin): @@ -87,4 +88,4 @@ class PlacePatchRequestSchema(PlaceModelSchema, PlaceBaseSchemaMixin): super().__init__(*args, **kwargs) self.make_patch_schema() - location = fields.Nested(LocationPatchRequestSchema) + location = Owned(LocationPatchRequestSchema) diff --git a/project/forms/common.py b/project/forms/common.py index 17ff7c2..b6bd40f 100644 --- a/project/forms/common.py +++ b/project/forms/common.py @@ -1,6 +1,3 @@ -import base64 -import re - from flask import url_for from flask_babelex import lazy_gettext from flask_wtf import FlaskForm @@ -8,6 +5,15 @@ from markupsafe import Markup from wtforms import HiddenField, StringField from wtforms.validators import Optional +from project.imageutils import ( + get_bytes_from_image, + get_data_uri_from_bytes, + get_image_from_base64_str, + get_mime_type_from_image, + resize_image_to_max, + validate_image, +) + class BaseImageForm(FlaskForm): copyright_text = StringField( @@ -22,22 +28,19 @@ class Base64ImageForm(BaseImageForm): super(BaseImageForm, self).process(formdata, obj, data, **kwargs) if self.image_base64.data is None and obj and obj.data: - base64_str = base64.b64encode(obj.data).decode("utf-8") - self.image_base64.data = "data:{};base64,{}".format( - obj.encoding_format, base64_str + self.image_base64.data = get_data_uri_from_bytes( + obj.data, obj.encoding_format ) def populate_obj(self, obj): super(BaseImageForm, self).populate_obj(obj) - match = None if self.image_base64.data: - match = re.match(r"^data:(image/.+);base64,(.*)$", self.image_base64.data) - - if match: - obj.encoding_format = match.group(1) - base64_str = match.group(2) - obj.data = base64.b64decode(base64_str) + image = get_image_from_base64_str(self.image_base64.data) + validate_image(image) + resize_image_to_max(image) + obj.encoding_format = get_mime_type_from_image(image) + obj.data = get_bytes_from_image(image) else: obj.data = None obj.encoding_format = None diff --git a/project/imageutils.py b/project/imageutils.py new file mode 100644 index 0000000..9d8b5a2 --- /dev/null +++ b/project/imageutils.py @@ -0,0 +1,56 @@ +import base64 +import re +from io import BytesIO + +import PIL +import requests + +min_image_size = 320 +max_image_size = 1024 +supported_formats = ["jpeg", "png", "gif"] + + +def get_data_uri_from_bytes(data: bytes, encoding_format: str) -> str: + base64_str = base64.b64encode(data).decode("utf-8") + return "data:{};base64,{}".format(encoding_format, base64_str) + + +def get_image_from_bytes(data: bytes) -> PIL.Image: + return PIL.Image.open(BytesIO(data)) + + +def get_image_from_base64_str(base64_str: str) -> PIL.Image: + image_data = re.sub("^data:image/.+;base64,", "", base64_str) + return get_image_from_bytes(base64.b64decode(image_data)) + + +def get_image_from_url(url: str) -> PIL.Image: + response = requests.get(url) + return get_image_from_bytes(response.content) + + +def get_mime_type_from_image(image: PIL.Image) -> str: + return image.get_format_mimetype() + + +def get_bytes_from_image(image: PIL.Image) -> bytes: + imgByteArr = BytesIO() + image.save(imgByteArr, format=image.format) + return imgByteArr.getvalue() + + +def resize_image_to_max(image: PIL.Image): + if image.width > max_image_size or image.height > max_image_size: + image.thumbnail((max_image_size, max_image_size), PIL.Image.ANTIALIAS) + + +def validate_image(image: PIL.Image): + if image.width < min_image_size or image.height < min_image_size: + raise ValueError( + f"Image is too small ({image.width}x{image.height}px). At least {min_image_size}x{min_image_size}px." + ) + + if image.format.lower() not in supported_formats: + raise ValueError( + f"Image format {image.format} is not supported. Supported formats: {', '.join(supported_formats)}." + ) diff --git a/project/jinja_filters.py b/project/jinja_filters.py index b67cb44..0c356e7 100644 --- a/project/jinja_filters.py +++ b/project/jinja_filters.py @@ -1,6 +1,8 @@ import os from urllib.parse import quote_plus +from flask import url_for + from project import app from project.utils import ( get_event_category_name, @@ -43,7 +45,7 @@ app.jinja_env.filters["location_str"] = lambda l: get_location_str(l) @app.context_processor -def get_manage_menu_options_context_processor(): +def get_context_processors(): def get_manage_menu_options(admin_unit): from project.access import has_access from project.services.event_suggestion import get_event_reviews_badge_query @@ -64,4 +66,9 @@ def get_manage_menu_options_context_processor(): "reference_requests_incoming_badge": reference_requests_incoming_badge, } - return dict(get_manage_menu_options=get_manage_menu_options) + def get_base_url(): + return url_for("home", _external=True).rstrip("/") + + return dict( + get_manage_menu_options=get_manage_menu_options, get_base_url=get_base_url + ) diff --git a/project/models.py b/project/models.py index 22f5000..19a338d 100644 --- a/project/models.py +++ b/project/models.py @@ -299,9 +299,13 @@ class AdminUnit(db.Model, TrackableMixin): ) event_places = relationship("EventPlace", backref=backref("adminunit", lazy=True)) location_id = deferred(db.Column(db.Integer, db.ForeignKey("location.id"))) - location = db.relationship("Location") + location = db.relationship( + "Location", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) logo_id = deferred(db.Column(db.Integer, db.ForeignKey("image.id"))) - logo = db.relationship("Image", uselist=False) + logo = db.relationship( + "Image", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) url = deferred(Column(String(255)), group="detail") email = deferred(Column(Unicode(255)), group="detail") phone = deferred(Column(Unicode(255)), group="detail") @@ -335,6 +339,9 @@ class Location(db.Model, TrackableMixin): longitude = Column(Numeric(19, 16)) coordinate = Column(Geometry(geometry_type="POINT")) + def __init__(self, **kwargs): + super(Location, self).__init__(**kwargs) + def is_empty(self): return ( not self.street @@ -382,9 +389,13 @@ class EventPlace(db.Model, TrackableMixin): id = Column(Integer(), primary_key=True) name = Column(Unicode(255), nullable=False) location_id = db.Column(db.Integer, db.ForeignKey("location.id")) - location = db.relationship("Location", uselist=False) + location = db.relationship( + "Location", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) photo_id = db.Column(db.Integer, db.ForeignKey("image.id")) - photo = db.relationship("Image", uselist=False) + photo = db.relationship( + "Image", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) url = Column(String(255)) description = Column(UnicodeText()) admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=True) @@ -460,9 +471,13 @@ class EventOrganizer(db.Model, TrackableMixin): phone = deferred(Column(Unicode(255)), group="detail") fax = deferred(Column(Unicode(255)), group="detail") location_id = deferred(db.Column(db.Integer, db.ForeignKey("location.id"))) - location = db.relationship("Location") + location = db.relationship( + "Location", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) logo_id = deferred(db.Column(db.Integer, db.ForeignKey("image.id"))) - logo = db.relationship("Image", uselist=False) + logo = db.relationship( + "Image", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=True) @@ -539,7 +554,9 @@ class EventMixin(object): @declared_attr def photo(cls): - return relationship("Image", uselist=False) + return relationship( + "Image", uselist=False, single_parent=True, cascade="all, delete-orphan" + ) class EventSuggestion(db.Model, TrackableMixin, EventMixin): diff --git a/project/templates/event_date/search.html b/project/templates/event_date/search.html index d8db66b..3294a39 100644 --- a/project/templates/event_date/search.html +++ b/project/templates/event_date/search.html @@ -4,7 +4,7 @@ {%- endblock -%} {% block content %} - + {% endblock %} \ No newline at end of file diff --git a/project/templates/organization/read.html b/project/templates/organization/read.html index f8278c6..3eb0da1 100644 --- a/project/templates/organization/read.html +++ b/project/templates/organization/read.html @@ -5,7 +5,7 @@ {% block content_container_attribs %}{% endblock %} {% block content %} - + {% endblock %} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index aa9fac8..0b33c29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -91,6 +91,7 @@ PyYAML==5.4.1 qrcode==6.1 regex==2020.11.13 requests==2.25.0 +requests-mock==1.9.3 requests-oauthlib==1.3.0 rope==0.18.0 six==1.15.0 diff --git a/tests/api/test_event.py b/tests/api/test_event.py index 4e2955f..d21c57d 100644 --- a/tests/api/test_event.py +++ b/tests/api/test_event.py @@ -1,3 +1,6 @@ +import base64 + + def test_read(client, app, db, seeder, utils): user_id, admin_unit_id = seeder.setup_base() event_id = seeder.create_event(admin_unit_id) @@ -400,6 +403,86 @@ def test_patch_referencedEventUpdate_sendsMail(client, seeder, utils, app, mocke utils.assert_send_mail_called(mail_mock, "other@test.de") +def test_patch_photo(client, seeder, utils, app, requests_mock): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + + requests_mock.get( + "https://image.com", content=base64.b64decode(seeder.get_default_image_base64()) + ) + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.patch_json( + url, + {"photo": {"image_url": "https://image.com"}}, + ) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + assert event.photo is not None + assert event.photo.encoding_format == "image/png" + + +def test_patch_photo_copyright(client, db, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + image_id = seeder.upsert_default_image() + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + event.photo_id = image_id + db.session.commit() + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.patch_json( + url, + {"photo": {"copyright_text": "Heiner"}}, + ) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + assert event.photo.id == image_id + assert event.photo.data is not None + assert event.photo.copyright_text == "Heiner" + + +def test_patch_photo_delete(client, db, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + image_id = seeder.upsert_default_image() + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + event.photo_id = image_id + db.session.commit() + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.patch_json( + url, + {"photo": None}, + ) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event, Image + + event = Event.query.get(event_id) + assert event.photo_id is None + + image = Image.query.get(image_id) + assert image is None + + def test_delete(client, seeder, utils, app): user_id, admin_unit_id = seeder.setup_api_access() event_id = seeder.create_event(admin_unit_id) diff --git a/tests/api/test_image.py b/tests/api/test_image.py new file mode 100644 index 0000000..97ea3bb --- /dev/null +++ b/tests/api/test_image.py @@ -0,0 +1,44 @@ +import pytest + + +def test_validate_image(): + from marshmallow import ValidationError + + from project.api.image.schemas import ImagePostRequestSchema + + data = { + "copyright_text": "Horst", + } + + schema = ImagePostRequestSchema() + + with pytest.raises(ValidationError) as e: + schema.load(data) + + assert "Either image_url or image_base64 has to be defined." in str(e.value) + + +def test_post_load_image_data(seeder): + from project.api.image.schemas import ImagePostRequestSchema + + data = { + "image_base64": seeder.get_default_image_upload_base64(), + } + + item = dict() + schema = ImagePostRequestSchema() + schema.post_load_image_data(item, data) + schema.load(data) + + assert item.get("encoding_format") is not None + assert item.get("data") is not None + + +def test_load_image_data(): + from project.api.image.schemas import ImagePostRequestSchema + + schema = ImagePostRequestSchema() + encoding_format, data = schema.load_image_data(None, None) + + assert encoding_format is None + assert data is None diff --git a/tests/api/test_organization.py b/tests/api/test_organization.py index 0fa7311..bc6490d 100644 --- a/tests/api/test_organization.py +++ b/tests/api/test_organization.py @@ -80,6 +80,7 @@ def test_events_post(client, seeder, utils, app): "start": "2021-02-07T11:00:00.000Z", "place": {"id": place_id}, "organizer": {"id": organizer_id}, + "photo": {"image_base64": seeder.get_default_image_upload_base64()}, }, ) utils.assert_response_created(response) @@ -96,6 +97,8 @@ def test_events_post(client, seeder, utils, app): assert event is not None assert event.event_place_id == place_id assert event.organizer_id == organizer_id + assert event.photo is not None + assert event.photo.encoding_format == "image/png" def test_places(client, seeder, utils): diff --git a/tests/api/test_place.py b/tests/api/test_place.py index 72de894..1952de2 100644 --- a/tests/api/test_place.py +++ b/tests/api/test_place.py @@ -53,6 +53,38 @@ def test_patch(client, seeder, utils, app): assert place.description == "Klasse" +def test_patch_location(db, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + place_id = seeder.upsert_default_event_place(admin_unit_id) + + with app.app_context(): + from project.models import EventPlace, Location + + location = Location() + location.postalCode = "12345" + location.city = "City" + + event = EventPlace.query.get(place_id) + event.location = location + db.session.commit() + + location_id = location.id + + url = utils.get_url("api_v1_place", id=place_id) + response = utils.patch_json( + url, + {"location": {"postalCode": "54321"}}, + ) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import EventPlace + + place = EventPlace.query.get(place_id) + assert place.location.id == location_id + assert place.location.postalCode == "54321" + + def test_delete(client, seeder, utils, app): user_id, admin_unit_id = seeder.setup_api_access() place_id = seeder.upsert_default_event_place(admin_unit_id) diff --git a/tests/seeder.py b/tests/seeder.py index e8e50f2..2a5cc42 100644 --- a/tests/seeder.py +++ b/tests/seeder.py @@ -250,7 +250,7 @@ class Seeder(object): return response.json["id"] def get_default_image_base64(self): - return """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""" + return """iVBORw0KGgoAAAANSUhEUgAAAUAAAAFABAMAAAA/vriZAAAAG1BMVEUAAAD///9fX1+fn5+/v7/f398/Pz8fHx9/f38YYyahAAAACXBIWXMAAA7EAAAOxAGVKw4bAAADAklEQVR4nO3XzVPaQBzG8RQw4dhosB6hdexVhPZM6lg9mlrGHkFH7RGmhfYIM53aP7v7lhBeNgqul/b7OZBNzOPu/DbZBc8DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8O97c9I97qlWqd39fO+lzbOBNSJvfO+tl9lU0AyPW3Ek//G3MDo+CSfyqp9E7WTPlhE3ttrhqbdOZmPlnZ4Y5fRSNJuyKDeqi/626DA+tGTUjT9rg3UyG/PVpPiR+HgrW0EiB6w6r9jKcac++511Mk8VZw9PQ9SgvKuaSeETVXmVNR+d2dy0l7ZenHvesKP7nRRFqrtZ89GZzc0GWHkpzuq6305RJDfAR2c2F891ZiY8ncRqTx9fz0XkjfaMY/5O1twS09U1AzM1GuljKZrL9A+LMo7dXGZN8SwFZiSlbX1Ub6l4vE7zEb87KMq4dR3NOmvUPd+sFelRl7AU5V9Qv5kb7qqMw9G1TqL67DSZdRKkE69KmCug32onH7zijDvTMLyYDbC6t6ozUcJ8AathWDvyHsg4NN6vZSMcnq/qTJRw/gkMruJPXnHGqe/Zox33VnY22osWtoggrj+QcSrdocpypCs6C5LLxchWuuLZMk6lO1Rzkusk90aWwqX1rZT+1ZZxyuxQVd3N8prW+KjXwjxzlzXzHAOc6t0h0RfL2bYlXuHRUgnNkGyZ5xigKYbX7KmD3ME08QoHSyXUA7Rm3BqqMjTN9tqf6EO626o1cLGE5qmzZRyb1j359d+cmRfUFMVsIoslrKqnzZpxK6jJj3hiTkuq1+zbi9lEFkool2d7xpmtuvy8kX2PZi+gmrdGuvQN9SYSmG9UntpDfFVQa8aZSu3o7vZXWJdTePZHuldXf4v9r2fu8c0q/sOcxxcH46+xHJo94851InZ+2UEQamoi98XFiS3ivxO3fRmslXmC4Op2xS+x8UHRz7Pxwd3Kq8/0kw4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4L/0FiGF8UcQrzsIAAAAASUVORK5CYII=""" def get_default_image_upload_base64(self): base64_str = self.get_default_image_base64() diff --git a/tests/test_imageutils.py b/tests/test_imageutils.py new file mode 100644 index 0000000..8b5eb48 --- /dev/null +++ b/tests/test_imageutils.py @@ -0,0 +1,45 @@ +import pytest + + +def test_resize_image_to_max(): + import PIL + + from project.imageutils import max_image_size, resize_image_to_max + + image = PIL.Image.new("RGB", (max_image_size + 1, max_image_size + 1)) + resize_image_to_max(image) + + assert image.width == max_image_size + assert image.height == max_image_size + + +def test_validate_image_too_small(): + import PIL + + from project.imageutils import min_image_size, validate_image + + image = PIL.Image.new("RGB", (min_image_size - 1, min_image_size - 1)) + + with pytest.raises(ValueError) as e: + validate_image(image) + + assert "too small" in str(e.value) + + +def test_validate_image_unsupported_format(): + from io import BytesIO + + import PIL + + from project.imageutils import get_image_from_bytes, min_image_size, validate_image + + image = PIL.Image.new("RGB", (min_image_size, min_image_size)) + imgByteArr = BytesIO() + image.save(imgByteArr, format="TIFF") + + tif_image = get_image_from_bytes(imgByteArr.getvalue()) + + with pytest.raises(ValueError) as e: + validate_image(tif_image) + + assert "not supported" in str(e.value)