Skip to content
Snippets Groups Projects
Verified Commit 45de67c8 authored by Daniel Göbel's avatar Daniel Göbel
Browse files

Return URL when uploading an icon

* Resize icon before uploading it

#47
parent 192077af
Branches
No related tags found
No related merge requests found
...@@ -15,13 +15,13 @@ repos: ...@@ -15,13 +15,13 @@ repos:
- id: check-merge-conflict - id: check-merge-conflict
- id: check-ast - id: check-ast
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.7.0 rev: 23.9.1
hooks: hooks:
- id: black - id: black
files: app files: app
args: [--check] args: [--check]
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.287' rev: 'v0.0.288'
hooks: hooks:
- id: ruff - id: ruff
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
......
...@@ -53,7 +53,7 @@ async def delete_workflow_credentials( ...@@ -53,7 +53,7 @@ async def delete_workflow_credentials(
await CRUDWorkflow.update_credentials(db, workflow.workflow_id, token=None, username=None) 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( async def update_workflow_credentials(
credentials: WorkflowCredentialsIn, credentials: WorkflowCredentialsIn,
workflow: CurrentWorkflow, workflow: CurrentWorkflow,
......
...@@ -16,6 +16,7 @@ from app.api.dependencies import ( ...@@ -16,6 +16,7 @@ from app.api.dependencies import (
S3Service, S3Service,
) )
from app.api.utils import delete_remote_icon, upload_icon from app.api.utils import delete_remote_icon, upload_icon
from app.core.config import settings
from app.crud import CRUDWorkflowVersion from app.crud import CRUDWorkflowVersion
from app.git_repository import build_repository from app.git_repository import build_repository
from app.schemas.workflow_version import WorkflowVersion as WorkflowVersionSchema from app.schemas.workflow_version import WorkflowVersion as WorkflowVersionSchema
...@@ -307,7 +308,7 @@ async def upload_workflow_version_icon( ...@@ -307,7 +308,7 @@ async def upload_workflow_version_icon(
icon: UploadFile = File(..., description="Optional Icon for the Workflow."), icon: UploadFile = File(..., description="Optional Icon for the Workflow."),
) -> str: ) -> 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. Permission "workflow:update" required.
\f \f
Parameters Parameters
...@@ -328,6 +329,11 @@ async def upload_workflow_version_icon( ...@@ -328,6 +329,11 @@ async def upload_workflow_version_icon(
Async database session to perform query on. Dependency Injection. Async database session to perform query on. Dependency Injection.
icon : fastapi.UploadFile icon : fastapi.UploadFile
New Icon for the workflow version. HTML Form. New Icon for the workflow version. HTML Form.
Returns
-------
icon_url : str
URL where the icon can be downloaded
""" """
await authorization("update") await authorization("update")
if current_user.uid != workflow.developer_id: if current_user.uid != workflow.developer_id:
...@@ -338,7 +344,7 @@ async def upload_workflow_version_icon( ...@@ -338,7 +344,7 @@ async def upload_workflow_version_icon(
# Delete old icon if possible # Delete old icon if possible
if old_slug is not None: if old_slug is not None:
background_tasks.add_task(delete_remote_icon, s3=s3, db=db, icon_slug=old_slug) 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( @router.delete(
......
...@@ -2,7 +2,7 @@ import json ...@@ -2,7 +2,7 @@ import json
import re import re
from io import BytesIO from io import BytesIO
from tempfile import SpooledTemporaryFile 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 from uuid import uuid4
import botocore.client import botocore.client
...@@ -10,6 +10,7 @@ from clowmdb.models import WorkflowExecution, WorkflowMode ...@@ -10,6 +10,7 @@ from clowmdb.models import WorkflowExecution, WorkflowMode
from fastapi import BackgroundTasks, HTTPException, UploadFile, status from fastapi import BackgroundTasks, HTTPException, UploadFile, status
from httpx import AsyncClient, ConnectError, ConnectTimeout from httpx import AsyncClient, ConnectError, ConnectTimeout
from mako.template import Template from mako.template import Template
from PIL import Image, UnidentifiedImageError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
...@@ -49,14 +50,40 @@ def upload_icon(s3: S3ServiceResource, background_tasks: BackgroundTasks, icon: ...@@ -49,14 +50,40 @@ def upload_icon(s3: S3ServiceResource, background_tasks: BackgroundTasks, icon:
icon_slug : str icon_slug : str
Slug of the icon in the bucket Slug of the icon in the bucket
""" """
file_ending = icon.filename.split(".")[-1] # type: ignore[union-attr] try:
icon_slug = f"{uuid4().hex}.{file_ending}" Image.open(icon.file)
obj = s3.Bucket(name=settings.ICON_BUCKET).Object(key=icon_slug) 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 # 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 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: 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 Delete icon in S3 Bucket if there are no other workflow versions that depend on it
......
...@@ -5,6 +5,7 @@ import pytest ...@@ -5,6 +5,7 @@ import pytest
from clowmdb.models import WorkflowMode, WorkflowVersion from clowmdb.models import WorkflowMode, WorkflowVersion
from fastapi import status from fastapi import status
from httpx import AsyncClient from httpx import AsyncClient
from PIL import Image
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
...@@ -361,7 +362,10 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes): ...@@ -361,7 +362,10 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes):
db : sqlalchemy.ext.asyncio.AsyncSession. db : sqlalchemy.ext.asyncio.AsyncSession.
Async database session to perform query on. 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( response = await client.post(
"/".join( "/".join(
[ [
...@@ -376,7 +380,8 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes): ...@@ -376,7 +380,8 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes):
files=files, files=files,
) )
assert response.status_code == status.HTTP_201_CREATED 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() assert icon_slug in mock_s3_service.Bucket(settings.ICON_BUCKET).objects.all_keys()
db_version = await db.scalar( db_version = await db.scalar(
select(WorkflowVersion).where(WorkflowVersion.git_commit_hash == random_workflow_version.git_commit_hash) select(WorkflowVersion).where(WorkflowVersion.git_commit_hash == random_workflow_version.git_commit_hash)
...@@ -387,6 +392,48 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes): ...@@ -387,6 +392,48 @@ class TestWorkflowVersionIconRoutes(_TestWorkflowVersionRoutes):
# Clean up # Clean up
mock_s3_service.Bucket(settings.ICON_BUCKET).Object(icon_slug).delete() 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 @pytest.mark.asyncio
async def test_upload_new_icon_as_non_developer( async def test_upload_new_icon_as_non_developer(
self, self,
......
...@@ -5,7 +5,7 @@ pytest-cov>=4.1.0,<4.2.0 ...@@ -5,7 +5,7 @@ pytest-cov>=4.1.0,<4.2.0
coverage[toml]>=7.3.0,<7.4.0 coverage[toml]>=7.3.0,<7.4.0
# Linters # Linters
ruff ruff
black>=23.07.0,<23.08.0 black>=23.09.0,<23.10.0
isort>=5.12.0,<5.13.0 isort>=5.12.0,<5.13.0
mypy>=1.5.0,<1.6.0 mypy>=1.5.0,<1.6.0
# stubs for mypy # stubs for mypy
......
...@@ -18,8 +18,9 @@ authlib>=1.2.0,<1.3.0 ...@@ -18,8 +18,9 @@ authlib>=1.2.0,<1.3.0
boto3>=1.28.0,<1.29.0 boto3>=1.28.0,<1.29.0
# Miscellaneous # Miscellaneous
tenacity>=8.2.0,<8.3.0 tenacity>=8.2.0,<8.3.0
httpx>=0.24.0,<0.25.0 httpx>=0.25.0,<0.26.0
itsdangerous itsdangerous
jsonschema>=4.0.0,<5.0.0 jsonschema>=4.0.0,<5.0.0
mako mako
python-dotenv python-dotenv
Pillow>=10.0.0,<10.1.0
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment