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.
Files changed (33) hide show
  1. chewy_attachment/__init__.py +3 -0
  2. chewy_attachment/core/__init__.py +21 -0
  3. chewy_attachment/core/exceptions.py +38 -0
  4. chewy_attachment/core/permissions.py +136 -0
  5. chewy_attachment/core/schemas.py +61 -0
  6. chewy_attachment/core/storage.py +158 -0
  7. chewy_attachment/core/utils.py +56 -0
  8. chewy_attachment/django_app/__init__.py +3 -0
  9. chewy_attachment/django_app/admin.py +16 -0
  10. chewy_attachment/django_app/apps.py +11 -0
  11. chewy_attachment/django_app/migrations/0001_initial.py +33 -0
  12. chewy_attachment/django_app/migrations/__init__.py +0 -0
  13. chewy_attachment/django_app/models.py +64 -0
  14. chewy_attachment/django_app/permissions.py +36 -0
  15. chewy_attachment/django_app/serializers.py +35 -0
  16. chewy_attachment/django_app/tests/__init__.py +0 -0
  17. chewy_attachment/django_app/tests/conftest.py +56 -0
  18. chewy_attachment/django_app/tests/test_views.py +207 -0
  19. chewy_attachment/django_app/urls.py +14 -0
  20. chewy_attachment/django_app/views.py +202 -0
  21. chewy_attachment/fastapi_app/__init__.py +5 -0
  22. chewy_attachment/fastapi_app/crud.py +93 -0
  23. chewy_attachment/fastapi_app/dependencies.py +190 -0
  24. chewy_attachment/fastapi_app/models.py +51 -0
  25. chewy_attachment/fastapi_app/router.py +134 -0
  26. chewy_attachment/fastapi_app/schemas.py +31 -0
  27. chewy_attachment/fastapi_app/tests/__init__.py +0 -0
  28. chewy_attachment/fastapi_app/tests/conftest.py +113 -0
  29. chewy_attachment/fastapi_app/tests/test_router.py +182 -0
  30. chewy_attachment-0.1.0.dist-info/METADATA +384 -0
  31. chewy_attachment-0.1.0.dist-info/RECORD +33 -0
  32. chewy_attachment-0.1.0.dist-info/WHEEL +4 -0
  33. 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,5 @@
1
+ """FastAPI implementation of ChewyAttachment"""
2
+
3
+ from .router import router
4
+
5
+ __all__ = ["router"]
@@ -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