diff --git a/python/apps/taiga/src/taiga/attachments/admin.py b/python/apps/taiga/src/taiga/attachments/admin.py index 6d7d8aaf3..ef0c3b236 100644 --- a/python/apps/taiga/src/taiga/attachments/admin.py +++ b/python/apps/taiga/src/taiga/attachments/admin.py @@ -17,8 +17,8 @@ class AttachmentInline(admin.GenericTabularInline): model = Attachment ct_field = "object_content_type" ct_fk_field = "object_id" - fields = ("name", "file", "content_type", "size") - readonly_fields = ("name", "file", "content_type", "size") + fields = ("name", "storaged_object", "content_type", "size") + readonly_fields = ("name", "storaged_object", "content_type", "size") show_change_link = True def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool: @@ -37,7 +37,7 @@ class AttachmentAdmin(admin.ModelAdmin[Attachment]): "fields": ( ("id", "b64id"), ("name", "size", "content_type"), - "file", + "storaged_object", ("created_at", "created_by"), ("object_content_type", "object_id"), "content_object_link", diff --git a/python/apps/taiga/src/taiga/attachments/migrations/0001_initial.py b/python/apps/taiga/src/taiga/attachments/migrations/0001_initial.py index c16e84159..f46dde87d 100644 --- a/python/apps/taiga/src/taiga/attachments/migrations/0001_initial.py +++ b/python/apps/taiga/src/taiga/attachments/migrations/0001_initial.py @@ -60,12 +60,12 @@ class Migration(migrations.Migration): ), ), ( - "file", + "storaged_object", models.ForeignKey( on_delete=django.db.models.deletion.RESTRICT, related_name="attachments", to="storage.storagedobject", - verbose_name="file", + verbose_name="storage object", ), ), ( diff --git a/python/apps/taiga/src/taiga/attachments/models.py b/python/apps/taiga/src/taiga/attachments/models.py index cba7e1c4a..93c3ef149 100644 --- a/python/apps/taiga/src/taiga/attachments/models.py +++ b/python/apps/taiga/src/taiga/attachments/models.py @@ -11,13 +11,13 @@ class Attachment(models.BaseModel, CreatedMetaInfoMixin): - file = models.ForeignKey( + storaged_object = models.ForeignKey( "storage.StoragedObject", null=False, blank=False, on_delete=models.RESTRICT, related_name="attachments", - verbose_name="file", + verbose_name="storaged_object", ) name = models.TextField( null=False, diff --git a/python/apps/taiga/src/taiga/attachments/repositories.py b/python/apps/taiga/src/taiga/attachments/repositories.py index d9288264e..e21c09c00 100644 --- a/python/apps/taiga/src/taiga/attachments/repositories.py +++ b/python/apps/taiga/src/taiga/attachments/repositories.py @@ -20,7 +20,7 @@ ########################################################## -DEFAULT_QUERYSET = Attachment.objects.select_related("file").all() +DEFAULT_QUERYSET = Attachment.objects.select_related("storaged_object").all() class AttachmentFilters(TypedDict, total=False): @@ -81,10 +81,10 @@ async def create_attachment( storaged_object = await storage_repositories.create_storaged_object(uploadfile_to_file(file)) return await Attachment.objects.acreate( - file=storaged_object, + storaged_object=storaged_object, name=file.filename or "unknown", size=get_size(file.file), - content_type=file.content_type or "unknown", + content_type=file.content_type or "application/octet-stream", content_object=object, created_by=created_by, ) diff --git a/python/apps/taiga/src/taiga/attachments/serializers.py b/python/apps/taiga/src/taiga/attachments/serializers.py index 1387e114b..eaabf4aa0 100644 --- a/python/apps/taiga/src/taiga/attachments/serializers.py +++ b/python/apps/taiga/src/taiga/attachments/serializers.py @@ -8,7 +8,6 @@ from datetime import datetime from taiga.base.serializers import UUIDB64, BaseModel -from taiga.commons.storage.serializers import StoragedObjectFileField class AttachmentSerializer(BaseModel): @@ -17,7 +16,7 @@ class AttachmentSerializer(BaseModel): content_type: str size: int created_at: datetime - file: StoragedObjectFileField + file: str class Config: orm_mode = True diff --git a/python/apps/taiga/src/taiga/attachments/services.py b/python/apps/taiga/src/taiga/attachments/services.py index 2c0c81d49..2d8827918 100644 --- a/python/apps/taiga/src/taiga/attachments/services.py +++ b/python/apps/taiga/src/taiga/attachments/services.py @@ -47,6 +47,7 @@ async def list_attachments( ) -> list[Attachment]: return await attachments_repositories.list_attachments( filters={"content_object": content_object}, + prefetch_related=["content_object", "project"], ) @@ -58,9 +59,7 @@ async def list_attachments( async def get_attachment(id: UUID, content_object: Model) -> Attachment | None: return await attachments_repositories.get_attachment( filters={"id": id, "content_object": content_object}, - prefetch_related=[ - "content_object", - ], + prefetch_related=["content_object", "project"], ) diff --git a/python/apps/taiga/src/taiga/attachments/signals.py b/python/apps/taiga/src/taiga/attachments/signals.py index 391100262..baa063670 100644 --- a/python/apps/taiga/src/taiga/attachments/signals.py +++ b/python/apps/taiga/src/taiga/attachments/signals.py @@ -16,4 +16,4 @@ def mark_attachment_file_to_delete(sender: Model, instance: Attachment, **kwargs """ Mark the store object (with the file) of the attachment as deleted. """ - storage_repositories.mark_storaged_object_as_deleted(storaged_object=instance.file) + storage_repositories.mark_storaged_object_as_deleted(storaged_object=instance.storaged_object) diff --git a/python/apps/taiga/src/taiga/commons/storage/serializers.py b/python/apps/taiga/src/taiga/commons/storage/serializers.py deleted file mode 100644 index bcba3451a..000000000 --- a/python/apps/taiga/src/taiga/commons/storage/serializers.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# -# Copyright (c) 2023-present Kaleidos INC - -from pydantic import AnyHttpUrl, AnyUrl, BaseConfig -from pydantic.fields import ModelField -from taiga.commons.storage.models import StoragedObject - - -class StoragedObjectFileField(AnyHttpUrl): - @classmethod - def validate(cls, value: StoragedObject, field: ModelField, config: BaseConfig) -> AnyUrl: - return value.file.url diff --git a/python/apps/taiga/src/taiga/commons/urls.py b/python/apps/taiga/src/taiga/commons/urls.py new file mode 100644 index 000000000..159b62393 --- /dev/null +++ b/python/apps/taiga/src/taiga/commons/urls.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2023-present Kaleidos INC + +from typing import Any +from urllib.parse import urljoin + +from requests.utils import requote_uri + + +def reverse(name: str, **path_params: Any) -> str: + """ + Reverse a route by name + """ + from taiga.conf import settings + from taiga.wsgi import app + + return requote_uri( + urljoin( + settings.BACKEND_URL, + app.url_path_for(name, **path_params), + ) + ) diff --git a/python/apps/taiga/src/taiga/routers/loader.py b/python/apps/taiga/src/taiga/routers/loader.py index e92dfe686..188c8e786 100644 --- a/python/apps/taiga/src/taiga/routers/loader.py +++ b/python/apps/taiga/src/taiga/routers/loader.py @@ -50,5 +50,6 @@ def load_routes(api: FastAPI) -> None: api.include_router(routes.workflows) api.include_router(routes.stories) api.include_router(routes.story_attachments) + api.include_router(routes.unauth_story_attachments) api.include_router(routes.story_comments) api.include_router(routes.system) diff --git a/python/apps/taiga/src/taiga/routers/routes.py b/python/apps/taiga/src/taiga/routers/routes.py index b842c7366..3ad4cad44 100644 --- a/python/apps/taiga/src/taiga/routers/routes.py +++ b/python/apps/taiga/src/taiga/routers/routes.py @@ -113,6 +113,7 @@ # /projects/{id}/stories/{ref}/attachments story_attachments = AuthAPIRouter(tags=["story attachments"]) +unauth_story_attachments = APIRouter(tags=["story attachments"]) tags_metadata.append( { "name": "story attachments", diff --git a/python/apps/taiga/src/taiga/stories/attachments/api.py b/python/apps/taiga/src/taiga/stories/attachments/api.py index 40384bf27..a6a277b66 100644 --- a/python/apps/taiga/src/taiga/stories/attachments/api.py +++ b/python/apps/taiga/src/taiga/stories/attachments/api.py @@ -9,9 +9,9 @@ from uuid import UUID from fastapi import status +from fastapi.responses import FileResponse from taiga.attachments import services as attachments_services from taiga.attachments.models import Attachment -from taiga.attachments.serializers import AttachmentSerializer from taiga.base.api import AuthRequest from taiga.base.api.permissions import check_permissions from taiga.base.validators import B64UUID, UploadFile @@ -20,6 +20,7 @@ from taiga.permissions import HasPerm from taiga.routers import routes from taiga.stories.attachments import events +from taiga.stories.attachments.serializers import StoryAttachmentSerializer from taiga.stories.stories.api import get_story_or_404 from taiga.stories.stories.models import Story @@ -38,7 +39,7 @@ name="project.story.attachments.create", summary="Attach a file to a story", responses=ERROR_403 | ERROR_404 | ERROR_422, - response_model=AttachmentSerializer, + response_model=StoryAttachmentSerializer, ) async def create_story_attachments( project_id: B64UUID, @@ -47,7 +48,7 @@ async def create_story_attachments( file: UploadFile, ) -> Attachment: """ - Create and attachment asociate to a story + Create an attachment asociate to a story """ story = await get_story_or_404(project_id, ref) await check_permissions(permissions=CREATE_STORY_ATTACHMENT, user=request.user, obj=story) @@ -70,7 +71,7 @@ async def create_story_attachments( "/projects/{project_id}/stories/{ref}/attachments", name="project.story.attachments.list", summary="List story attachments", - response_model=list[AttachmentSerializer], + response_model=list[StoryAttachmentSerializer], responses=ERROR_403 | ERROR_404 | ERROR_422, ) async def list_story_attachment( @@ -83,9 +84,10 @@ async def list_story_attachment( """ story = await get_story_or_404(project_id=project_id, ref=ref) await check_permissions(permissions=LIST_STORY_ATTACHMENTS, user=request.user, obj=story) - return await attachments_services.list_attachments( + attachments = await attachments_services.list_attachments( content_object=story, ) + return attachments ########################################################## @@ -107,7 +109,7 @@ async def delete_story_attachment( request: AuthRequest, ) -> None: """ - Delete a attachment + Delete a story attachment """ story = await get_story_or_404(project_id=project_id, ref=ref) attachment = await get_story_attachment_or_404(attachment_id=attachment_id, story=story) @@ -117,6 +119,34 @@ async def delete_story_attachment( await attachments_services.delete_attachment(attachment=attachment, event_on_delete=event_on_delete) +########################################################## +# download story attachment file +########################################################## + + +@routes.unauth_story_attachments.get( + "/projects/{project_id}/stories/{ref}/attachments/{attachment_id}/file/{filename}", + name="project.story.attachments.download", + summary="Download the story attachment file", + responses=ERROR_404 | ERROR_422, + response_class=FileResponse, +) +async def download_story_attachment_file( + project_id: B64UUID, + ref: int, + attachment_id: B64UUID, + filename: str, +) -> FileResponse: + """ + Download a story attachment file + """ + story = await get_story_or_404(project_id=project_id, ref=ref) + attachment = await get_story_attachment_or_404(attachment_id=attachment_id, story=story) + file = attachment.storaged_object.file + + return FileResponse(path=file.path, media_type=attachment.content_type, filename=attachment.name) + + ################################################ # misc: ################################################ diff --git a/python/apps/taiga/src/taiga/stories/attachments/events/content.py b/python/apps/taiga/src/taiga/stories/attachments/events/content.py index 7d5d8fb13..9a308dc29 100644 --- a/python/apps/taiga/src/taiga/stories/attachments/events/content.py +++ b/python/apps/taiga/src/taiga/stories/attachments/events/content.py @@ -6,15 +6,15 @@ # Copyright (c) 2023-present Kaleidos INC -from taiga.attachments.serializers import AttachmentSerializer from taiga.base.serializers import BaseModel +from taiga.stories.attachments.serializers import StoryAttachmentSerializer class CreateStoryAttachmentContent(BaseModel): ref: int - attachment: AttachmentSerializer + attachment: StoryAttachmentSerializer class DeleteStoryAttachmentContent(BaseModel): ref: int - attachment: AttachmentSerializer + attachment: StoryAttachmentSerializer diff --git a/python/apps/taiga/src/taiga/stories/attachments/serializers.py b/python/apps/taiga/src/taiga/stories/attachments/serializers.py new file mode 100644 index 000000000..b3b7498ee --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/attachments/serializers.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2023-present Kaleidos INC + +from typing import Any, Type + +from pydantic import AnyHttpUrl +from taiga.attachments.serializers import AttachmentSerializer +from taiga.commons.urls import reverse + + +class StoryAttachmentSerializer(AttachmentSerializer): + file: AnyHttpUrl + + @classmethod + def from_orm(cls: Type["StoryAttachmentSerializer"], obj: Any) -> "StoryAttachmentSerializer": + if not isinstance(obj, list): + obj.file = reverse( + "project.story.attachments.download", + project_id=obj.content_object.project.b64id, + ref=obj.content_object.ref, + attachment_id=obj.b64id, + filename=obj.name, + ) + return super().from_orm(obj) diff --git a/python/apps/taiga/tests/integration/taiga/attachments/test_signals.py b/python/apps/taiga/tests/integration/taiga/attachments/test_signals.py index adb874cef..68b631156 100644 --- a/python/apps/taiga/tests/integration/taiga/attachments/test_signals.py +++ b/python/apps/taiga/tests/integration/taiga/attachments/test_signals.py @@ -24,14 +24,14 @@ def test_mark_attachment_file_to_delete_is_connected(): async def test_mark_attachment_file_to_delete_when_delete_first_level_related_model(): story = await f.create_story() attachment = await f.create_attachment(content_object=story) - file = attachment.file + storaged_object = attachment.storaged_object - assert file.deleted_at is None + assert storaged_object.deleted_at is None assert await attachments_repositories.delete_attachments(filters={"id": attachment.id}) - await db_utils.refresh_model_from_db(file) + await db_utils.refresh_model_from_db(storaged_object) - assert file.deleted_at + assert storaged_object.deleted_at async def test_mark_attachment_file_to_delete_when_delete_n_level_related_object(): @@ -44,20 +44,20 @@ async def test_mark_attachment_file_to_delete_when_delete_n_level_related_object attachment12 = await f.create_attachment(content_object=story1) attachment21 = await f.create_attachment(content_object=story2) - file11 = attachment11.file - file12 = attachment12.file - file21 = attachment21.file + storaged_object11 = attachment11.storaged_object + storaged_object12 = attachment12.storaged_object + storaged_object21 = attachment21.storaged_object - assert file11.deleted_at is None - assert file12.deleted_at is None - assert file21.deleted_at is None + assert storaged_object11.deleted_at is None + assert storaged_object12.deleted_at is None + assert storaged_object21.deleted_at is None assert await workspaces_repositories.delete_workspaces(filters={"id": workspace.id}) - await db_utils.refresh_model_from_db(file11) - await db_utils.refresh_model_from_db(file12) - await db_utils.refresh_model_from_db(file21) + await db_utils.refresh_model_from_db(storaged_object11) + await db_utils.refresh_model_from_db(storaged_object12) + await db_utils.refresh_model_from_db(storaged_object21) - assert file11.deleted_at - assert file12.deleted_at - assert file21.deleted_at + assert storaged_object11.deleted_at + assert storaged_object12.deleted_at + assert storaged_object21.deleted_at diff --git a/python/apps/taiga/tests/integration/taiga/commons/storage/test_repositories.py b/python/apps/taiga/tests/integration/taiga/commons/storage/test_repositories.py index dc29e5282..d41074863 100644 --- a/python/apps/taiga/tests/integration/taiga/commons/storage/test_repositories.py +++ b/python/apps/taiga/tests/integration/taiga/commons/storage/test_repositories.py @@ -93,7 +93,7 @@ async def test_delete_storaged_object_that_has_been_used(): storage = storaged_object.file.storage story = await f.create_story() - await f.create_attachment(content_object=story, file=storaged_object) + await f.create_attachment(content_object=story, storaged_object=storaged_object) assert len(await repositories.list_storaged_objects()) == 1 assert storage.exists(file_path) diff --git a/python/apps/taiga/tests/unit/taiga/attachments/test_services.py b/python/apps/taiga/tests/unit/taiga/attachments/test_services.py index b64d02c93..fac3b5ba7 100644 --- a/python/apps/taiga/tests/unit/taiga/attachments/test_services.py +++ b/python/apps/taiga/tests/unit/taiga/attachments/test_services.py @@ -95,6 +95,7 @@ async def test_list_attachments(): ) fake_attachments_repositories.list_attachments.assert_awaited_once_with( filters=filters, + prefetch_related=["content_object", "project"], ) assert len(attachments_list) == 3 @@ -114,7 +115,7 @@ async def test_get_attachment(): await services.get_attachment(id=attachment_id, content_object=story) fake_attachments_repositories.get_attachment.assert_awaited_once_with( filters={"id": attachment_id, "content_object": story}, - prefetch_related=["content_object"], + prefetch_related=["content_object", "project"], ) diff --git a/python/apps/taiga/tests/utils/factories/attachments.py b/python/apps/taiga/tests/utils/factories/attachments.py index c1b3c6723..df6b887dc 100644 --- a/python/apps/taiga/tests/utils/factories/attachments.py +++ b/python/apps/taiga/tests/utils/factories/attachments.py @@ -11,7 +11,7 @@ class AttachmentFactory(Factory): - file = factory.SubFactory("tests.utils.factories.StoragedObjectFactory") + storaged_object = factory.SubFactory("tests.utils.factories.StoragedObjectFactory") name = factory.Sequence(lambda n: f"test-file-{n}.png") content_type = "image/png" size = 145 diff --git a/python/requirements/devel.in b/python/requirements/devel.in index 69e565e02..d5246e32a 100644 --- a/python/requirements/devel.in +++ b/python/requirements/devel.in @@ -11,4 +11,5 @@ types-babel # babel types-python-slugify # python-slugify types-redis # redis types-redis # redis -types-Pillow # Pillow/PIL +types-requests # requests +types-Pillow # Pillow/PIL \ No newline at end of file diff --git a/python/requirements/devel.txt b/python/requirements/devel.txt index b4652eebd..4e20ec297 100644 --- a/python/requirements/devel.txt +++ b/python/requirements/devel.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # -# pip-compile --resolver=backtracking requirements/devel.in +# pip-compile requirements/devel.in # asgiref==3.7.2 # via django @@ -72,8 +72,12 @@ types-pyyaml==6.0.12.11 # via django-stubs types-redis==4.6.0.3 # via -r requirements/devel.in +types-requests==2.31.0.2 + # via -r requirements/devel.in types-setuptools==68.0.0.3 # via types-babel +types-urllib3==1.26.25.14 + # via types-requests typing-extensions==4.7.1 # via # django-stubs