Skip to content

Commit

Permalink
feat(attachments): u#3728 t#4003 prevent download files from deleted …
Browse files Browse the repository at this point in the history
…attachment
  • Loading branch information
bameda committed Sep 11, 2023
1 parent 9028144 commit e8bca42
Show file tree
Hide file tree
Showing 20 changed files with 136 additions and 62 deletions.
6 changes: 3 additions & 3 deletions python/apps/taiga/src/taiga/attachments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
),
(
Expand Down
4 changes: 2 additions & 2 deletions python/apps/taiga/src/taiga/attachments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions python/apps/taiga/src/taiga/attachments/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
Expand Down
3 changes: 1 addition & 2 deletions python/apps/taiga/src/taiga/attachments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -17,7 +16,7 @@ class AttachmentSerializer(BaseModel):
content_type: str
size: int
created_at: datetime
file: StoragedObjectFileField
file: str

class Config:
orm_mode = True
5 changes: 2 additions & 3 deletions python/apps/taiga/src/taiga/attachments/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)


Expand All @@ -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"],
)


Expand Down
2 changes: 1 addition & 1 deletion python/apps/taiga/src/taiga/attachments/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
16 changes: 0 additions & 16 deletions python/apps/taiga/src/taiga/commons/storage/serializers.py

This file was deleted.

26 changes: 26 additions & 0 deletions python/apps/taiga/src/taiga/commons/urls.py
Original file line number Diff line number Diff line change
@@ -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),
)
)
1 change: 1 addition & 0 deletions python/apps/taiga/src/taiga/routers/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions python/apps/taiga/src/taiga/routers/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 36 additions & 6 deletions python/apps/taiga/src/taiga/stories/attachments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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


##########################################################
Expand All @@ -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)
Expand All @@ -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:
################################################
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions python/apps/taiga/src/taiga/stories/attachments/serializers.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit e8bca42

Please sign in to comment.