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 13, 2023
1 parent b947d57 commit ece8c31
Show file tree
Hide file tree
Showing 26 changed files with 465 additions and 85 deletions.
Binary file modified .github/sql-fixtures/fixtures.sql
Binary file not shown.
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
5 changes: 3 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 All @@ -43,6 +43,7 @@ class Attachment(models.BaseModel, CreatedMetaInfoMixin):
verbose_name="object content type",
)
object_id = models.UUIDField(null=False, blank=False, verbose_name="object id")
# NOTE: the content_object should have a project attribute.
content_object = models.GenericForeignKey(
"object_content_type",
"object_id",
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)
31 changes: 17 additions & 14 deletions python/apps/taiga/src/taiga/base/django/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,27 @@
# Copyright (c) 2023-present Kaleidos INC

from django.conf import settings
from django.contrib import admin
from django.urls import path, re_path
from django.urls.resolvers import URLPattern, URLResolver

urlpatterns: list[URLPattern | URLResolver] = []

##############################################
# Default
##############################################


if settings.DEBUG:
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path, re_path

##############################################
# Admin panel
##############################################

urlpatterns += [
path("admin/", admin.site.urls),
]

##############################################
# Static and media files in debug mode
##############################################

if settings.DEBUG:
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
##############################################
# Media files
##############################################

def mediafiles_urlpatterns(prefix: str) -> list[URLPattern]:
"""
Expand All @@ -41,6 +40,10 @@ def mediafiles_urlpatterns(prefix: str) -> list[URLPattern]:
re_path(r"^%s(?P<path>.*)$" % re.escape(prefix.lstrip("/")), serve, {"document_root": settings.MEDIA_ROOT})
]

# Hardcoded only for development server
urlpatterns += staticfiles_urlpatterns(prefix="/static/")
urlpatterns += mediafiles_urlpatterns(prefix="/media/")

##############################################
# Static files
##############################################

urlpatterns += staticfiles_urlpatterns(prefix="/static/")
18 changes: 17 additions & 1 deletion python/apps/taiga/src/taiga/base/utils/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import hashlib
import io
from os import path, urandom
from typing import IO, Any
from typing import IO, Any, Generator

from django.core.files.base import File as DjangoFile
from django.db.models.fields.files import FieldFile # noqa
Expand All @@ -32,6 +32,22 @@ def uploadfile_to_file(file: UploadFile) -> File:
return File(name=file.filename, file=file.file)


def iterfile(file: File, mode: str | None = "rb") -> Generator[bytes, None, None]:
"""
Function to iterate over the content of a Django File object.
This function is useful to iterate over the content of a file so you can stream it.
:param file: a Django File object
:type file: File
:param mode: the mode to open the file
:type mode: str | None
:return a generator
:rtype Generator[bytes, None, None]
"""
with file.open(mode) as f:
yield from f


def get_size(file: IO[Any]) -> int:
"""
Calculate the current size of a file in bytes.
Expand Down
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
43 changes: 37 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,17 +9,19 @@
from uuid import UUID

from fastapi import status
from fastapi.responses import StreamingResponse
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.utils.files import iterfile
from taiga.base.validators import B64UUID, UploadFile
from taiga.exceptions import api as ex
from taiga.exceptions.api.errors import ERROR_403, ERROR_404, ERROR_422
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 +40,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 +49,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 +72,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 +85,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 +110,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 +120,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.file",
summary="Download the story attachment file",
responses=ERROR_404 | ERROR_422,
response_class=StreamingResponse,
)
async def get_story_attachment_file(
project_id: B64UUID,
ref: int,
attachment_id: B64UUID,
filename: str,
) -> StreamingResponse:
"""
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 StreamingResponse(iterfile(file, mode="rb"), media_type=attachment.content_type)


################################################
# 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
Loading

0 comments on commit ece8c31

Please sign in to comment.