chewy-attachment 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chewy_attachment/__init__.py +3 -0
- chewy_attachment/core/__init__.py +21 -0
- chewy_attachment/core/exceptions.py +38 -0
- chewy_attachment/core/permissions.py +136 -0
- chewy_attachment/core/schemas.py +61 -0
- chewy_attachment/core/storage.py +158 -0
- chewy_attachment/core/utils.py +56 -0
- chewy_attachment/django_app/__init__.py +3 -0
- chewy_attachment/django_app/admin.py +16 -0
- chewy_attachment/django_app/apps.py +11 -0
- chewy_attachment/django_app/migrations/0001_initial.py +33 -0
- chewy_attachment/django_app/migrations/__init__.py +0 -0
- chewy_attachment/django_app/models.py +64 -0
- chewy_attachment/django_app/permissions.py +36 -0
- chewy_attachment/django_app/serializers.py +35 -0
- chewy_attachment/django_app/tests/__init__.py +0 -0
- chewy_attachment/django_app/tests/conftest.py +56 -0
- chewy_attachment/django_app/tests/test_views.py +207 -0
- chewy_attachment/django_app/urls.py +14 -0
- chewy_attachment/django_app/views.py +202 -0
- chewy_attachment/fastapi_app/__init__.py +5 -0
- chewy_attachment/fastapi_app/crud.py +93 -0
- chewy_attachment/fastapi_app/dependencies.py +190 -0
- chewy_attachment/fastapi_app/models.py +51 -0
- chewy_attachment/fastapi_app/router.py +134 -0
- chewy_attachment/fastapi_app/schemas.py +31 -0
- chewy_attachment/fastapi_app/tests/__init__.py +0 -0
- chewy_attachment/fastapi_app/tests/conftest.py +113 -0
- chewy_attachment/fastapi_app/tests/test_router.py +182 -0
- chewy_attachment-0.1.0.dist-info/METADATA +384 -0
- chewy_attachment-0.1.0.dist-info/RECORD +33 -0
- chewy_attachment-0.1.0.dist-info/WHEEL +4 -0
- chewy_attachment-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""pytest configuration for Django tests"""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import django
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def pytest_configure():
|
|
10
|
+
"""Configure Django settings for tests"""
|
|
11
|
+
if settings.configured:
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
test_dir = Path(__file__).parent.absolute()
|
|
15
|
+
storage_root = test_dir / "test_storage"
|
|
16
|
+
|
|
17
|
+
settings.configure(
|
|
18
|
+
DEBUG=True,
|
|
19
|
+
DATABASES={
|
|
20
|
+
"default": {
|
|
21
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
22
|
+
"NAME": ":memory:",
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
INSTALLED_APPS=[
|
|
26
|
+
"django.contrib.contenttypes",
|
|
27
|
+
"django.contrib.auth",
|
|
28
|
+
"django.contrib.sessions",
|
|
29
|
+
"rest_framework",
|
|
30
|
+
"chewy_attachment.django_app",
|
|
31
|
+
],
|
|
32
|
+
ROOT_URLCONF="chewy_attachment.django_app.urls",
|
|
33
|
+
REST_FRAMEWORK={
|
|
34
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [],
|
|
35
|
+
"DEFAULT_PERMISSION_CLASSES": [],
|
|
36
|
+
},
|
|
37
|
+
CHEWY_ATTACHMENT={
|
|
38
|
+
"STORAGE_ROOT": storage_root,
|
|
39
|
+
},
|
|
40
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
41
|
+
USE_TZ=True,
|
|
42
|
+
SECRET_KEY="test-secret-key-for-testing-only",
|
|
43
|
+
BASE_DIR=test_dir,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
django.setup()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
50
|
+
"""Clean up test storage after tests"""
|
|
51
|
+
import shutil
|
|
52
|
+
|
|
53
|
+
test_dir = Path(__file__).parent.absolute()
|
|
54
|
+
storage_root = test_dir / "test_storage"
|
|
55
|
+
if storage_root.exists():
|
|
56
|
+
shutil.rmtree(storage_root)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Unit tests for Django views"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from django.contrib.auth import get_user_model
|
|
8
|
+
from django.test import TestCase, override_settings
|
|
9
|
+
from rest_framework import status
|
|
10
|
+
from rest_framework.test import APIClient
|
|
11
|
+
|
|
12
|
+
from chewy_attachment.django_app.models import Attachment
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TEST_STORAGE = Path(__file__).parent / "test_storage"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@override_settings(CHEWY_ATTACHMENT={"STORAGE_ROOT": TEST_STORAGE})
|
|
19
|
+
class TestAttachmentViews(TestCase):
|
|
20
|
+
"""Test cases for attachment API views"""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def setUpClass(cls):
|
|
24
|
+
super().setUpClass()
|
|
25
|
+
TEST_STORAGE.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def tearDownClass(cls):
|
|
29
|
+
super().tearDownClass()
|
|
30
|
+
if TEST_STORAGE.exists():
|
|
31
|
+
shutil.rmtree(TEST_STORAGE)
|
|
32
|
+
|
|
33
|
+
def setUp(self):
|
|
34
|
+
"""Set up test fixtures"""
|
|
35
|
+
self.client = APIClient()
|
|
36
|
+
User = get_user_model()
|
|
37
|
+
|
|
38
|
+
self.user1 = User.objects.create_user(
|
|
39
|
+
username="testuser1",
|
|
40
|
+
password="testpass123",
|
|
41
|
+
)
|
|
42
|
+
self.user2 = User.objects.create_user(
|
|
43
|
+
username="testuser2",
|
|
44
|
+
password="testpass123",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.test_file_content = b"Hello, this is test file content!"
|
|
48
|
+
self.test_file_name = "test.txt"
|
|
49
|
+
|
|
50
|
+
def tearDown(self):
|
|
51
|
+
"""Clean up after each test"""
|
|
52
|
+
Attachment.objects.all().delete()
|
|
53
|
+
|
|
54
|
+
def _create_test_file(self):
|
|
55
|
+
"""Create a test file for upload"""
|
|
56
|
+
return io.BytesIO(self.test_file_content)
|
|
57
|
+
|
|
58
|
+
def _upload_file(self, user, is_public=False):
|
|
59
|
+
"""Helper to upload a file"""
|
|
60
|
+
self.client.force_authenticate(user=user)
|
|
61
|
+
file = self._create_test_file()
|
|
62
|
+
file.name = self.test_file_name
|
|
63
|
+
|
|
64
|
+
response = self.client.post(
|
|
65
|
+
"/files/",
|
|
66
|
+
{"file": file, "is_public": is_public},
|
|
67
|
+
format="multipart",
|
|
68
|
+
)
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
def test_upload_file_success(self):
|
|
72
|
+
"""Test successful file upload"""
|
|
73
|
+
self.client.force_authenticate(user=self.user1)
|
|
74
|
+
file = self._create_test_file()
|
|
75
|
+
file.name = self.test_file_name
|
|
76
|
+
|
|
77
|
+
response = self.client.post(
|
|
78
|
+
"/files/",
|
|
79
|
+
{"file": file, "is_public": False},
|
|
80
|
+
format="multipart",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
|
84
|
+
self.assertEqual(response.data["original_name"], self.test_file_name)
|
|
85
|
+
self.assertEqual(response.data["size"], len(self.test_file_content))
|
|
86
|
+
self.assertEqual(response.data["owner_id"], str(self.user1.id))
|
|
87
|
+
self.assertFalse(response.data["is_public"])
|
|
88
|
+
|
|
89
|
+
self.assertEqual(Attachment.objects.count(), 1)
|
|
90
|
+
attachment = Attachment.objects.first()
|
|
91
|
+
self.assertEqual(attachment.original_name, self.test_file_name)
|
|
92
|
+
|
|
93
|
+
def test_upload_file_unauthenticated_fails(self):
|
|
94
|
+
"""Test upload fails without authentication"""
|
|
95
|
+
file = self._create_test_file()
|
|
96
|
+
file.name = self.test_file_name
|
|
97
|
+
|
|
98
|
+
response = self.client.post(
|
|
99
|
+
"/files/",
|
|
100
|
+
{"file": file},
|
|
101
|
+
format="multipart",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])
|
|
105
|
+
|
|
106
|
+
def test_get_file_info_by_owner(self):
|
|
107
|
+
"""Test owner can get file info"""
|
|
108
|
+
upload_response = self._upload_file(self.user1, is_public=False)
|
|
109
|
+
file_id = upload_response.data["id"]
|
|
110
|
+
|
|
111
|
+
self.client.force_authenticate(user=self.user1)
|
|
112
|
+
response = self.client.get(f"/files/{file_id}/")
|
|
113
|
+
|
|
114
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
115
|
+
self.assertEqual(response.data["id"], file_id)
|
|
116
|
+
|
|
117
|
+
def test_get_private_file_info_by_non_owner_fails(self):
|
|
118
|
+
"""Test non-owner cannot get private file info"""
|
|
119
|
+
upload_response = self._upload_file(self.user1, is_public=False)
|
|
120
|
+
file_id = upload_response.data["id"]
|
|
121
|
+
|
|
122
|
+
self.client.force_authenticate(user=self.user2)
|
|
123
|
+
response = self.client.get(f"/files/{file_id}/")
|
|
124
|
+
|
|
125
|
+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
126
|
+
|
|
127
|
+
def test_get_public_file_info_anonymous(self):
|
|
128
|
+
"""Test anonymous user can get public file info"""
|
|
129
|
+
upload_response = self._upload_file(self.user1, is_public=True)
|
|
130
|
+
file_id = upload_response.data["id"]
|
|
131
|
+
|
|
132
|
+
self.client.force_authenticate(user=None)
|
|
133
|
+
response = self.client.get(f"/files/{file_id}/")
|
|
134
|
+
|
|
135
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
136
|
+
self.assertEqual(response.data["id"], file_id)
|
|
137
|
+
|
|
138
|
+
def test_delete_file_by_owner(self):
|
|
139
|
+
"""Test owner can delete file"""
|
|
140
|
+
upload_response = self._upload_file(self.user1)
|
|
141
|
+
file_id = upload_response.data["id"]
|
|
142
|
+
|
|
143
|
+
self.client.force_authenticate(user=self.user1)
|
|
144
|
+
response = self.client.delete(f"/files/{file_id}/")
|
|
145
|
+
|
|
146
|
+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
|
147
|
+
self.assertEqual(Attachment.objects.count(), 0)
|
|
148
|
+
|
|
149
|
+
def test_delete_file_by_non_owner_fails(self):
|
|
150
|
+
"""Test non-owner cannot delete file"""
|
|
151
|
+
upload_response = self._upload_file(self.user1)
|
|
152
|
+
file_id = upload_response.data["id"]
|
|
153
|
+
|
|
154
|
+
self.client.force_authenticate(user=self.user2)
|
|
155
|
+
response = self.client.delete(f"/files/{file_id}/")
|
|
156
|
+
|
|
157
|
+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
158
|
+
self.assertEqual(Attachment.objects.count(), 1)
|
|
159
|
+
|
|
160
|
+
def test_delete_public_file_by_non_owner_fails(self):
|
|
161
|
+
"""Test non-owner cannot delete even public file"""
|
|
162
|
+
upload_response = self._upload_file(self.user1, is_public=True)
|
|
163
|
+
file_id = upload_response.data["id"]
|
|
164
|
+
|
|
165
|
+
self.client.force_authenticate(user=self.user2)
|
|
166
|
+
response = self.client.delete(f"/files/{file_id}/")
|
|
167
|
+
|
|
168
|
+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
169
|
+
|
|
170
|
+
def test_download_file_by_owner(self):
|
|
171
|
+
"""Test owner can download file"""
|
|
172
|
+
upload_response = self._upload_file(self.user1)
|
|
173
|
+
file_id = upload_response.data["id"]
|
|
174
|
+
|
|
175
|
+
self.client.force_authenticate(user=self.user1)
|
|
176
|
+
response = self.client.get(f"/files/{file_id}/content/")
|
|
177
|
+
|
|
178
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
179
|
+
self.assertEqual(b"".join(response.streaming_content), self.test_file_content)
|
|
180
|
+
|
|
181
|
+
def test_download_private_file_by_non_owner_fails(self):
|
|
182
|
+
"""Test non-owner cannot download private file"""
|
|
183
|
+
upload_response = self._upload_file(self.user1, is_public=False)
|
|
184
|
+
file_id = upload_response.data["id"]
|
|
185
|
+
|
|
186
|
+
self.client.force_authenticate(user=self.user2)
|
|
187
|
+
response = self.client.get(f"/files/{file_id}/content/")
|
|
188
|
+
|
|
189
|
+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
|
190
|
+
|
|
191
|
+
def test_download_public_file_anonymous(self):
|
|
192
|
+
"""Test anonymous user can download public file"""
|
|
193
|
+
upload_response = self._upload_file(self.user1, is_public=True)
|
|
194
|
+
file_id = upload_response.data["id"]
|
|
195
|
+
|
|
196
|
+
self.client.force_authenticate(user=None)
|
|
197
|
+
response = self.client.get(f"/files/{file_id}/content/")
|
|
198
|
+
|
|
199
|
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
200
|
+
self.assertEqual(b"".join(response.streaming_content), self.test_file_content)
|
|
201
|
+
|
|
202
|
+
def test_get_nonexistent_file_returns_404(self):
|
|
203
|
+
"""Test 404 for nonexistent file"""
|
|
204
|
+
self.client.force_authenticate(user=self.user1)
|
|
205
|
+
response = self.client.get("/files/00000000-0000-0000-0000-000000000000/")
|
|
206
|
+
|
|
207
|
+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""URL configuration for ChewyAttachment Django app"""
|
|
2
|
+
|
|
3
|
+
from django.urls import include, path
|
|
4
|
+
|
|
5
|
+
from rest_framework.routers import DefaultRouter
|
|
6
|
+
|
|
7
|
+
from .views import AttachmentViewSet
|
|
8
|
+
|
|
9
|
+
router = DefaultRouter()
|
|
10
|
+
router.register(r"files", AttachmentViewSet, basename="attachment")
|
|
11
|
+
|
|
12
|
+
urlpatterns = [
|
|
13
|
+
path("", include(router.urls)),
|
|
14
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""DRF views for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.http import FileResponse, Http404
|
|
5
|
+
|
|
6
|
+
from rest_framework import status, viewsets
|
|
7
|
+
from rest_framework.decorators import action
|
|
8
|
+
from rest_framework.response import Response
|
|
9
|
+
from rest_framework.views import APIView
|
|
10
|
+
|
|
11
|
+
from ..core.permissions import PermissionChecker, load_permission_class
|
|
12
|
+
from ..core.storage import FileStorageEngine
|
|
13
|
+
from ..core.utils import generate_uuid
|
|
14
|
+
from .models import Attachment, get_storage_root
|
|
15
|
+
from .permissions import IsAuthenticatedForUpload, IsOwnerOrPublicReadOnly
|
|
16
|
+
from .serializers import AttachmentSerializer, AttachmentUploadSerializer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_permission_classes():
|
|
20
|
+
"""
|
|
21
|
+
Get permission classes from settings or use defaults.
|
|
22
|
+
|
|
23
|
+
Settings:
|
|
24
|
+
ATTACHMENTS_PERMISSION_CLASSES: List of permission class paths
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
# settings.py
|
|
28
|
+
ATTACHMENTS_PERMISSION_CLASSES = [
|
|
29
|
+
"IsAuthenticatedForUpload",
|
|
30
|
+
"myapp.permissions.CustomAttachmentPermission",
|
|
31
|
+
]
|
|
32
|
+
"""
|
|
33
|
+
custom_classes = getattr(settings, "ATTACHMENTS_PERMISSION_CLASSES", None)
|
|
34
|
+
|
|
35
|
+
if custom_classes:
|
|
36
|
+
loaded_classes = []
|
|
37
|
+
for class_path in custom_classes:
|
|
38
|
+
# If it's just a class name, try to load from default location
|
|
39
|
+
if "." not in class_path:
|
|
40
|
+
class_path = f"chewy_attachment.django_app.permissions.{class_path}"
|
|
41
|
+
try:
|
|
42
|
+
loaded_classes.append(load_permission_class(class_path))
|
|
43
|
+
except ImportError as e:
|
|
44
|
+
raise ImportError(
|
|
45
|
+
f"Failed to load permission class from ATTACHMENTS_PERMISSION_CLASSES: {e}"
|
|
46
|
+
)
|
|
47
|
+
return loaded_classes
|
|
48
|
+
|
|
49
|
+
# Default permission classes
|
|
50
|
+
return [IsAuthenticatedForUpload, IsOwnerOrPublicReadOnly]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AttachmentViewSet(viewsets.ModelViewSet):
|
|
54
|
+
"""
|
|
55
|
+
ViewSet for attachment operations.
|
|
56
|
+
|
|
57
|
+
Endpoints:
|
|
58
|
+
- POST /files/ - Upload file
|
|
59
|
+
- GET /files/{id}/ - Get file info
|
|
60
|
+
- DELETE /files/{id}/ - Delete file
|
|
61
|
+
|
|
62
|
+
Custom Permissions:
|
|
63
|
+
Configure via settings.ATTACHMENTS_PERMISSION_CLASSES
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
queryset = Attachment.objects.all()
|
|
67
|
+
serializer_class = AttachmentSerializer
|
|
68
|
+
http_method_names = ["get", "post", "delete", "head", "options"]
|
|
69
|
+
|
|
70
|
+
def __init__(self, *args, **kwargs):
|
|
71
|
+
super().__init__(*args, **kwargs)
|
|
72
|
+
# Dynamically load permission classes
|
|
73
|
+
self.permission_classes = get_permission_classes()
|
|
74
|
+
|
|
75
|
+
def get_storage_engine(self) -> FileStorageEngine:
|
|
76
|
+
"""Get storage engine instance"""
|
|
77
|
+
return FileStorageEngine(get_storage_root())
|
|
78
|
+
|
|
79
|
+
def create(self, request, *args, **kwargs):
|
|
80
|
+
"""Handle file upload"""
|
|
81
|
+
serializer = AttachmentUploadSerializer(data=request.data)
|
|
82
|
+
serializer.is_valid(raise_exception=True)
|
|
83
|
+
|
|
84
|
+
uploaded_file = serializer.validated_data["file"]
|
|
85
|
+
is_public = serializer.validated_data.get("is_public", False)
|
|
86
|
+
|
|
87
|
+
content = uploaded_file.read()
|
|
88
|
+
original_name = uploaded_file.name
|
|
89
|
+
|
|
90
|
+
storage = self.get_storage_engine()
|
|
91
|
+
result = storage.save_file(content, original_name)
|
|
92
|
+
|
|
93
|
+
attachment = Attachment.objects.create(
|
|
94
|
+
id=generate_uuid(),
|
|
95
|
+
original_name=original_name,
|
|
96
|
+
storage_path=result.storage_path,
|
|
97
|
+
mime_type=result.mime_type,
|
|
98
|
+
size=result.size,
|
|
99
|
+
owner_id=str(request.user.id),
|
|
100
|
+
is_public=is_public,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
output_serializer = AttachmentSerializer(attachment)
|
|
104
|
+
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
|
105
|
+
|
|
106
|
+
def retrieve(self, request, *args, **kwargs):
|
|
107
|
+
"""Get file metadata"""
|
|
108
|
+
instance = self.get_object()
|
|
109
|
+
serializer = self.get_serializer(instance)
|
|
110
|
+
return Response(serializer.data)
|
|
111
|
+
|
|
112
|
+
def destroy(self, request, *args, **kwargs):
|
|
113
|
+
"""Delete file"""
|
|
114
|
+
instance = self.get_object()
|
|
115
|
+
|
|
116
|
+
storage = self.get_storage_engine()
|
|
117
|
+
storage.delete_file(instance.storage_path)
|
|
118
|
+
|
|
119
|
+
instance.delete()
|
|
120
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
121
|
+
|
|
122
|
+
@action(detail=True, methods=["get"], url_path="content")
|
|
123
|
+
def download(self, request, pk=None):
|
|
124
|
+
"""Download file content"""
|
|
125
|
+
instance = self.get_object()
|
|
126
|
+
|
|
127
|
+
user_context = Attachment.get_user_context(request)
|
|
128
|
+
file_metadata = instance.to_file_metadata()
|
|
129
|
+
|
|
130
|
+
if not PermissionChecker.can_download(file_metadata, user_context):
|
|
131
|
+
return Response(
|
|
132
|
+
{"detail": "You do not have permission to download this file"},
|
|
133
|
+
status=status.HTTP_403_FORBIDDEN,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
storage = self.get_storage_engine()
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
file_path = storage.get_file_path(instance.storage_path)
|
|
140
|
+
except Exception:
|
|
141
|
+
raise Http404("File not found on storage")
|
|
142
|
+
|
|
143
|
+
response = FileResponse(
|
|
144
|
+
open(file_path, "rb"),
|
|
145
|
+
content_type=instance.mime_type,
|
|
146
|
+
)
|
|
147
|
+
response["Content-Disposition"] = f'attachment; filename="{instance.original_name}"'
|
|
148
|
+
response["Content-Length"] = instance.size
|
|
149
|
+
return response
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AttachmentDownloadView(APIView):
|
|
153
|
+
"""
|
|
154
|
+
Alternative download view using APIView.
|
|
155
|
+
|
|
156
|
+
GET /files/{id}/content - Download file content
|
|
157
|
+
|
|
158
|
+
Custom Permissions:
|
|
159
|
+
Configure via settings.ATTACHMENTS_PERMISSION_CLASSES
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, *args, **kwargs):
|
|
163
|
+
super().__init__(*args, **kwargs)
|
|
164
|
+
# Dynamically load permission classes
|
|
165
|
+
self.permission_classes = get_permission_classes()
|
|
166
|
+
|
|
167
|
+
def get_object(self, pk):
|
|
168
|
+
"""Get attachment by ID"""
|
|
169
|
+
try:
|
|
170
|
+
return Attachment.objects.get(pk=pk)
|
|
171
|
+
except Attachment.DoesNotExist:
|
|
172
|
+
raise Http404("Attachment not found")
|
|
173
|
+
|
|
174
|
+
def get(self, request, pk, format=None):
|
|
175
|
+
"""Download file"""
|
|
176
|
+
attachment = self.get_object(pk)
|
|
177
|
+
|
|
178
|
+
self.check_object_permissions(request, attachment)
|
|
179
|
+
|
|
180
|
+
user_context = Attachment.get_user_context(request)
|
|
181
|
+
file_metadata = attachment.to_file_metadata()
|
|
182
|
+
|
|
183
|
+
if not PermissionChecker.can_download(file_metadata, user_context):
|
|
184
|
+
return Response(
|
|
185
|
+
{"detail": "You do not have permission to download this file"},
|
|
186
|
+
status=status.HTTP_403_FORBIDDEN,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
storage = FileStorageEngine(get_storage_root())
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
file_path = storage.get_file_path(attachment.storage_path)
|
|
193
|
+
except Exception:
|
|
194
|
+
raise Http404("File not found on storage")
|
|
195
|
+
|
|
196
|
+
response = FileResponse(
|
|
197
|
+
open(file_path, "rb"),
|
|
198
|
+
content_type=attachment.mime_type,
|
|
199
|
+
)
|
|
200
|
+
response["Content-Disposition"] = f'attachment; filename="{attachment.original_name}"'
|
|
201
|
+
response["Content-Length"] = attachment.size
|
|
202
|
+
return response
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""CRUD operations for ChewyAttachment FastAPI app"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from sqlmodel import Session, select
|
|
6
|
+
|
|
7
|
+
from ..core.utils import generate_uuid
|
|
8
|
+
from .models import Attachment, AttachmentCreate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_attachment(session: Session, data: AttachmentCreate) -> Attachment:
|
|
12
|
+
"""
|
|
13
|
+
Create a new attachment record.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
session: Database session
|
|
17
|
+
data: Attachment creation data
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Created Attachment instance
|
|
21
|
+
"""
|
|
22
|
+
attachment = Attachment(
|
|
23
|
+
id=generate_uuid(),
|
|
24
|
+
original_name=data.original_name,
|
|
25
|
+
storage_path=data.storage_path,
|
|
26
|
+
mime_type=data.mime_type,
|
|
27
|
+
size=data.size,
|
|
28
|
+
owner_id=data.owner_id,
|
|
29
|
+
is_public=data.is_public,
|
|
30
|
+
)
|
|
31
|
+
session.add(attachment)
|
|
32
|
+
session.commit()
|
|
33
|
+
session.refresh(attachment)
|
|
34
|
+
return attachment
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_attachment(session: Session, attachment_id: str) -> Optional[Attachment]:
|
|
38
|
+
"""
|
|
39
|
+
Get attachment by ID.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
session: Database session
|
|
43
|
+
attachment_id: Attachment ID
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Attachment instance or None
|
|
47
|
+
"""
|
|
48
|
+
statement = select(Attachment).where(Attachment.id == attachment_id)
|
|
49
|
+
return session.exec(statement).first()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_attachments_by_owner(
|
|
53
|
+
session: Session,
|
|
54
|
+
owner_id: str,
|
|
55
|
+
skip: int = 0,
|
|
56
|
+
limit: int = 100,
|
|
57
|
+
) -> list[Attachment]:
|
|
58
|
+
"""
|
|
59
|
+
Get attachments by owner ID.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
session: Database session
|
|
63
|
+
owner_id: Owner ID
|
|
64
|
+
skip: Number of records to skip
|
|
65
|
+
limit: Maximum number of records to return
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of Attachment instances
|
|
69
|
+
"""
|
|
70
|
+
statement = (
|
|
71
|
+
select(Attachment)
|
|
72
|
+
.where(Attachment.owner_id == owner_id)
|
|
73
|
+
.offset(skip)
|
|
74
|
+
.limit(limit)
|
|
75
|
+
.order_by(Attachment.created_at.desc())
|
|
76
|
+
)
|
|
77
|
+
return list(session.exec(statement).all())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def delete_attachment(session: Session, attachment: Attachment) -> bool:
|
|
81
|
+
"""
|
|
82
|
+
Delete attachment record.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
session: Database session
|
|
86
|
+
attachment: Attachment instance to delete
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if deleted
|
|
90
|
+
"""
|
|
91
|
+
session.delete(attachment)
|
|
92
|
+
session.commit()
|
|
93
|
+
return True
|