From 45de67c85579b37462ca0936a3429add7a742c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Wed, 13 Sep 2023 11:22:27 +0200 Subject: [PATCH] Return URL when uploading an icon * Resize icon before uploading it #47 --- .pre-commit-config.yaml | 4 +- app/api/endpoints/workflow_credentials.py | 2 +- app/api/endpoints/workflow_version.py | 10 ++++- app/api/utils.py | 37 +++++++++++++--- app/tests/api/test_workflow_version.py | 51 ++++++++++++++++++++++- requirements-dev.txt | 2 +- requirements.txt | 3 +- 7 files changed, 95 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c0e4d5..c187c08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,13 +15,13 @@ repos: - id: check-merge-conflict - id: check-ast - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black files: app args: [--check] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.0.287' + rev: 'v0.0.288' hooks: - id: ruff - repo: https://github.com/PyCQA/isort diff --git a/app/api/endpoints/workflow_credentials.py b/app/api/endpoints/workflow_credentials.py index c4c62cb..a8d1090 100644 --- a/app/api/endpoints/workflow_credentials.py +++ b/app/api/endpoints/workflow_credentials.py @@ -53,7 +53,7 @@ async def delete_workflow_credentials( await CRUDWorkflow.update_credentials(db, workflow.workflow_id, token=None, username=None) -@router.put("", status_code=status.HTTP_200_OK, summary="Delete the credentials of a workflow") +@router.put("", status_code=status.HTTP_200_OK, summary="Update the credentials of a workflow") async def update_workflow_credentials( credentials: WorkflowCredentialsIn, workflow: CurrentWorkflow, diff --git a/app/api/endpoints/workflow_version.py b/app/api/endpoints/workflow_version.py index 6d75cab..4c667ae 100644 --- a/app/api/endpoints/workflow_version.py +++ b/app/api/endpoints/workflow_version.py @@ -16,6 +16,7 @@ from app.api.dependencies import ( S3Service, ) from app.api.utils import delete_remote_icon, upload_icon +from app.core.config import settings from app.crud import CRUDWorkflowVersion from app.git_repository import build_repository from app.schemas.workflow_version import WorkflowVersion as WorkflowVersionSchema @@ -307,7 +308,7 @@ async def upload_workflow_version_icon( icon: UploadFile = File(..., description="Optional Icon for the Workflow."), ) -> str: """ - Upload an icon for the workflow version.\n + Upload an icon for the workflow version and returns the new icon URL.\n Permission "workflow:update" required. \f Parameters @@ -328,6 +329,11 @@ async def upload_workflow_version_icon( Async database session to perform query on. Dependency Injection. icon : fastapi.UploadFile New Icon for the workflow version. HTML Form. + + Returns + ------- + icon_url : str + URL where the icon can be downloaded """ await authorization("update") if current_user.uid != workflow.developer_id: @@ -338,7 +344,7 @@ async def upload_workflow_version_icon( # Delete old icon if possible if old_slug is not None: background_tasks.add_task(delete_remote_icon, s3=s3, db=db, icon_slug=old_slug) - return icon_slug + return str(settings.OBJECT_GATEWAY_URI) + "/".join([settings.ICON_BUCKET, icon_slug]) @router.delete( diff --git a/app/api/utils.py b/app/api/utils.py index 3a6a362..101c0e7 100644 --- a/app/api/utils.py +++ b/app/api/utils.py @@ -2,7 +2,7 @@ import json import re from io import BytesIO from tempfile import SpooledTemporaryFile -from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, BinaryIO, Dict, Optional, Sequence, Union from uuid import uuid4 import botocore.client @@ -10,6 +10,7 @@ from clowmdb.models import WorkflowExecution, WorkflowMode from fastapi import BackgroundTasks, HTTPException, UploadFile, status from httpx import AsyncClient, ConnectError, ConnectTimeout from mako.template import Template +from PIL import Image, UnidentifiedImageError from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings @@ -49,14 +50,40 @@ def upload_icon(s3: S3ServiceResource, background_tasks: BackgroundTasks, icon: icon_slug : str Slug of the icon in the bucket """ - file_ending = icon.filename.split(".")[-1] # type: ignore[union-attr] - icon_slug = f"{uuid4().hex}.{file_ending}" - obj = s3.Bucket(name=settings.ICON_BUCKET).Object(key=icon_slug) + try: + Image.open(icon.file) + except UnidentifiedImageError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="icon needs to be an image") + icon_slug = f"{uuid4().hex}.png" # Write the icon to the icon bucket in a background task - background_tasks.add_task(obj.upload_fileobj, Fileobj=icon.file, ExtraArgs={"ContentType": icon.content_type}) + background_tasks.add_task(process_and_upload_icon, s3=s3, icon_slug=icon_slug, icon_buffer=icon.file) return icon_slug +def process_and_upload_icon(s3: S3ServiceResource, icon_slug: str, icon_buffer: BinaryIO) -> None: + """ + Process the icon and upload it to the S3 Icon Bucket + + Parameters + ---------- + s3 : boto3_type_annotations.s3.ServiceResource + S3 Service to perform operations on buckets in Ceph. + icon_slug : str + Slug of the icon + icon_buffer : typing.BinaryIO + Binary stream containing the image + """ + im = Image.open(icon_buffer) + im.thumbnail((64, 64)) # Crop to 64x64 image + thumbnail_buffer = BytesIO() + im.save(thumbnail_buffer, "PNG") # save in buffer as PNG image + thumbnail_buffer.seek(0) + # Upload to bucket + s3.Bucket(name=settings.ICON_BUCKET).Object(key=icon_slug).upload_fileobj( + Fileobj=thumbnail_buffer, ExtraArgs={"ContentType": "image/png"} + ) + + async def delete_remote_icon(s3: S3ServiceResource, db: AsyncSession, icon_slug: str) -> None: """ Delete icon in S3 Bucket if there are no other workflow versions that depend on it diff --git a/app/tests/api/test_workflow_version.py b/app/tests/api/test_workflow_version.py index c601d70..fb1dbb6 100644 --- a/app/tests/api/test_workflow_version.py +++ b/app/tests/api/test_workflow_version.py @@ -5,6 +5,7 @@ import pytest from clowmdb.models import WorkflowMode, WorkflowVersion from fastapi import status from httpx import AsyncClient +from PIL import Image from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -361,7 +362,10 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes): db : sqlalchemy.ext.asyncio.AsyncSession. Async database session to perform query on. """ - files = {"icon": ("RickRoll.txt", BytesIO(b"Never gonna give you up"), "plain/text")} + img_buffer = BytesIO() + Image.linear_gradient(mode="L").save(img_buffer, "PNG") + img_buffer.seek(0) + files = {"icon": ("RickRoll.png", img_buffer, "image/png")} response = await client.post( "/".join( [ @@ -376,7 +380,8 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes): files=files, ) assert response.status_code == status.HTTP_201_CREATED - icon_slug = response.json() + icon_url = response.json() + icon_slug = icon_url.split("/")[-1] assert icon_slug in mock_s3_service.Bucket(settings.ICON_BUCKET).objects.all_keys() db_version = await db.scalar( select(WorkflowVersion).where(WorkflowVersion.git_commit_hash == random_workflow_version.git_commit_hash) @@ -387,6 +392,48 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes): # Clean up mock_s3_service.Bucket(settings.ICON_BUCKET).Object(icon_slug).delete() + @pytest.mark.asyncio + async def test_upload_new_icon_as_text( + self, + client: AsyncClient, + random_user: UserWithAuthHeader, + random_workflow_version: WorkflowVersion, + mock_s3_service: MockS3ServiceResource, + db: AsyncSession, + ) -> None: + """ + Test for uploading a textfile as a new icon for a workflow version + + Parameters + ---------- + client : httpx.AsyncClient + HTTP Client to perform the request on. + random_user : app.tests.utils.user.UserWithAuthHeader + Random user for testing. + random_workflow_version : clowmdb.models.WorkflowVersion + Random workflow version for testing. + mock_s3_service : app.tests.mocks.mock_s3_resource.MockS3ServiceResource + Mock S3 Service to manipulate objects. + db : sqlalchemy.ext.asyncio.AsyncSession. + Async database session to perform query on. + """ + + files = {"icon": ("RickRoll.png", BytesIO(b"Never gonna give you up"), "text/plain")} + response = await client.post( + "/".join( + [ + self.base_path, + str(random_workflow_version.workflow_id), + "versions", + random_workflow_version.git_commit_hash, + "icon", + ] + ), + headers=random_user.auth_headers, + files=files, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + @pytest.mark.asyncio async def test_upload_new_icon_as_non_developer( self, diff --git a/requirements-dev.txt b/requirements-dev.txt index 31f20b7..810ec95 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,7 +5,7 @@ pytest-cov>=4.1.0,<4.2.0 coverage[toml]>=7.3.0,<7.4.0 # Linters ruff -black>=23.07.0,<23.08.0 +black>=23.09.0,<23.10.0 isort>=5.12.0,<5.13.0 mypy>=1.5.0,<1.6.0 # stubs for mypy diff --git a/requirements.txt b/requirements.txt index 305fcb3..2dfa1b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,8 +18,9 @@ authlib>=1.2.0,<1.3.0 boto3>=1.28.0,<1.29.0 # Miscellaneous tenacity>=8.2.0,<8.3.0 -httpx>=0.24.0,<0.25.0 +httpx>=0.25.0,<0.26.0 itsdangerous jsonschema>=4.0.0,<5.0.0 mako python-dotenv +Pillow>=10.0.0,<10.1.0 -- GitLab