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,21 @@
|
|
|
1
|
+
"""Core business logic layer - framework agnostic"""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
FileNotFoundException,
|
|
5
|
+
PermissionDeniedException,
|
|
6
|
+
StorageException,
|
|
7
|
+
InvalidFileException,
|
|
8
|
+
)
|
|
9
|
+
from .storage import FileStorageEngine
|
|
10
|
+
from .permissions import PermissionChecker
|
|
11
|
+
from .schemas import FileMetadata
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FileNotFoundException",
|
|
15
|
+
"PermissionDeniedException",
|
|
16
|
+
"StorageException",
|
|
17
|
+
"InvalidFileException",
|
|
18
|
+
"FileStorageEngine",
|
|
19
|
+
"PermissionChecker",
|
|
20
|
+
"FileMetadata",
|
|
21
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Custom exceptions for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ChewyAttachmentException(Exception):
|
|
5
|
+
"""Base exception for ChewyAttachment"""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileNotFoundException(ChewyAttachmentException):
|
|
11
|
+
"""Raised when a file is not found"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, file_id: str):
|
|
14
|
+
self.file_id = file_id
|
|
15
|
+
super().__init__(f"File not found: {file_id}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionDeniedException(ChewyAttachmentException):
|
|
19
|
+
"""Raised when permission is denied"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, action: str, file_id: str):
|
|
22
|
+
self.action = action
|
|
23
|
+
self.file_id = file_id
|
|
24
|
+
super().__init__(f"Permission denied: cannot {action} file {file_id}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StorageException(ChewyAttachmentException):
|
|
28
|
+
"""Raised when storage operation fails"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str):
|
|
31
|
+
super().__init__(f"Storage error: {message}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidFileException(ChewyAttachmentException):
|
|
35
|
+
"""Raised when file is invalid"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str):
|
|
38
|
+
super().__init__(f"Invalid file: {message}")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Permission checking logic for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Optional, Type
|
|
5
|
+
|
|
6
|
+
from .schemas import FileMetadata, UserContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PermissionChecker:
|
|
10
|
+
"""
|
|
11
|
+
Permission checker for file operations.
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
- View/Download: is_public=True OR owner_id == current_user_id
|
|
15
|
+
- Delete: owner_id == current_user_id
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def can_view(file: FileMetadata, user: UserContext) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
Check if user can view file metadata.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
file: File metadata
|
|
25
|
+
user: User context
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
True if user can view the file
|
|
29
|
+
"""
|
|
30
|
+
if file.is_public:
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
if user.is_authenticated and user.user_id == file.owner_id:
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def can_download(file: FileMetadata, user: UserContext) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Check if user can download file content.
|
|
42
|
+
|
|
43
|
+
Same rules as can_view.
|
|
44
|
+
"""
|
|
45
|
+
return PermissionChecker.can_view(file, user)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def can_delete(file: FileMetadata, user: UserContext) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Check if user can delete file.
|
|
51
|
+
|
|
52
|
+
Only owner can delete.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
file: File metadata
|
|
56
|
+
user: User context
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if user can delete the file
|
|
60
|
+
"""
|
|
61
|
+
if not user.is_authenticated:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
return user.user_id == file.owner_id
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def check_view_permission(
|
|
68
|
+
file: FileMetadata,
|
|
69
|
+
user: UserContext,
|
|
70
|
+
) -> Optional[str]:
|
|
71
|
+
"""
|
|
72
|
+
Check view permission and return error message if denied.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
None if allowed, error message if denied
|
|
76
|
+
"""
|
|
77
|
+
if PermissionChecker.can_view(file, user):
|
|
78
|
+
return None
|
|
79
|
+
return "You do not have permission to view this file"
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def check_download_permission(
|
|
83
|
+
file: FileMetadata,
|
|
84
|
+
user: UserContext,
|
|
85
|
+
) -> Optional[str]:
|
|
86
|
+
"""
|
|
87
|
+
Check download permission and return error message if denied.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
None if allowed, error message if denied
|
|
91
|
+
"""
|
|
92
|
+
if PermissionChecker.can_download(file, user):
|
|
93
|
+
return None
|
|
94
|
+
return "You do not have permission to download this file"
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def check_delete_permission(
|
|
98
|
+
file: FileMetadata,
|
|
99
|
+
user: UserContext,
|
|
100
|
+
) -> Optional[str]:
|
|
101
|
+
"""
|
|
102
|
+
Check delete permission and return error message if denied.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
None if allowed, error message if denied
|
|
106
|
+
"""
|
|
107
|
+
if PermissionChecker.can_delete(file, user):
|
|
108
|
+
return None
|
|
109
|
+
return "Only the file owner can delete this file"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_permission_class(import_path: str) -> Type:
|
|
113
|
+
"""
|
|
114
|
+
Dynamically load a permission class from import path.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
import_path: Full import path like "myapp.permissions.MyPermissionClass"
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The permission class
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ImportError: If module or class cannot be imported
|
|
124
|
+
AttributeError: If class not found in module
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
>>> perm_class = load_permission_class("myapp.permissions.CustomPermission")
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
module_path, class_name = import_path.rsplit(".", 1)
|
|
131
|
+
module = importlib.import_module(module_path)
|
|
132
|
+
return getattr(module, class_name)
|
|
133
|
+
except (ValueError, ImportError, AttributeError) as e:
|
|
134
|
+
raise ImportError(
|
|
135
|
+
f"Could not import permission class '{import_path}': {e}"
|
|
136
|
+
) from e
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Common data schemas for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class FileMetadata:
|
|
10
|
+
"""File metadata structure"""
|
|
11
|
+
|
|
12
|
+
id: str
|
|
13
|
+
original_name: str
|
|
14
|
+
storage_path: str
|
|
15
|
+
mime_type: str
|
|
16
|
+
size: int
|
|
17
|
+
owner_id: str
|
|
18
|
+
is_public: bool
|
|
19
|
+
created_at: datetime
|
|
20
|
+
|
|
21
|
+
def to_dict(self, include_storage_path: bool = False) -> dict:
|
|
22
|
+
"""Convert to dictionary for API response"""
|
|
23
|
+
result = {
|
|
24
|
+
"id": self.id,
|
|
25
|
+
"original_name": self.original_name,
|
|
26
|
+
"mime_type": self.mime_type,
|
|
27
|
+
"size": self.size,
|
|
28
|
+
"owner_id": self.owner_id,
|
|
29
|
+
"is_public": self.is_public,
|
|
30
|
+
"created_at": self.created_at.isoformat(),
|
|
31
|
+
}
|
|
32
|
+
if include_storage_path:
|
|
33
|
+
result["storage_path"] = self.storage_path
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FileUploadResult:
|
|
39
|
+
"""Result of file upload operation"""
|
|
40
|
+
|
|
41
|
+
storage_path: str
|
|
42
|
+
size: int
|
|
43
|
+
mime_type: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class UserContext:
|
|
48
|
+
"""User context for permission checking"""
|
|
49
|
+
|
|
50
|
+
user_id: Optional[str] = None
|
|
51
|
+
is_authenticated: bool = False
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def anonymous(cls) -> "UserContext":
|
|
55
|
+
"""Create anonymous user context"""
|
|
56
|
+
return cls(user_id=None, is_authenticated=False)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def authenticated(cls, user_id: str) -> "UserContext":
|
|
60
|
+
"""Create authenticated user context"""
|
|
61
|
+
return cls(user_id=user_id, is_authenticated=True)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""File storage engine for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .exceptions import StorageException
|
|
8
|
+
from .schemas import FileUploadResult
|
|
9
|
+
from .utils import detect_mime_type, generate_uuid, get_file_extension, safe_filename
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileStorageEngine:
|
|
13
|
+
"""
|
|
14
|
+
File storage engine that handles physical file operations.
|
|
15
|
+
|
|
16
|
+
Files are stored in a date-based directory structure:
|
|
17
|
+
<storage_root>/YYYY/MM/DD/<uuid>.<ext>
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, storage_root: str | Path):
|
|
21
|
+
"""
|
|
22
|
+
Initialize storage engine.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
storage_root: Root directory for file storage
|
|
26
|
+
"""
|
|
27
|
+
self.storage_root = Path(storage_root)
|
|
28
|
+
self._ensure_storage_root()
|
|
29
|
+
|
|
30
|
+
def _ensure_storage_root(self) -> None:
|
|
31
|
+
"""Ensure storage root directory exists"""
|
|
32
|
+
try:
|
|
33
|
+
self.storage_root.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise StorageException(f"Cannot create storage root: {e}")
|
|
36
|
+
|
|
37
|
+
def _generate_storage_path(self, original_name: str) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Generate storage path for a file.
|
|
40
|
+
|
|
41
|
+
Returns relative path from storage root: YYYY/MM/DD/<uuid>.<ext>
|
|
42
|
+
"""
|
|
43
|
+
now = datetime.now()
|
|
44
|
+
date_path = f"{now.year:04d}/{now.month:02d}/{now.day:02d}"
|
|
45
|
+
file_id = generate_uuid()
|
|
46
|
+
ext = get_file_extension(safe_filename(original_name))
|
|
47
|
+
filename = f"{file_id}{ext}" if ext else file_id
|
|
48
|
+
return f"{date_path}/{filename}"
|
|
49
|
+
|
|
50
|
+
def _get_full_path(self, storage_path: str) -> Path:
|
|
51
|
+
"""Get full filesystem path from relative storage path"""
|
|
52
|
+
full_path = (self.storage_root / storage_path).resolve()
|
|
53
|
+
if not str(full_path).startswith(str(self.storage_root.resolve())):
|
|
54
|
+
raise StorageException("Invalid storage path: directory traversal detected")
|
|
55
|
+
return full_path
|
|
56
|
+
|
|
57
|
+
def save_file(
|
|
58
|
+
self,
|
|
59
|
+
content: bytes,
|
|
60
|
+
original_name: str,
|
|
61
|
+
storage_path: Optional[str] = None,
|
|
62
|
+
) -> FileUploadResult:
|
|
63
|
+
"""
|
|
64
|
+
Save file content to storage.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
content: File content as bytes
|
|
68
|
+
original_name: Original filename
|
|
69
|
+
storage_path: Optional custom storage path (relative)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
FileUploadResult with storage_path, size, and mime_type
|
|
73
|
+
"""
|
|
74
|
+
if storage_path is None:
|
|
75
|
+
storage_path = self._generate_storage_path(original_name)
|
|
76
|
+
|
|
77
|
+
full_path = self._get_full_path(storage_path)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
full_path.write_bytes(content)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise StorageException(f"Failed to save file: {e}")
|
|
84
|
+
|
|
85
|
+
mime_type = detect_mime_type(content, original_name)
|
|
86
|
+
size = len(content)
|
|
87
|
+
|
|
88
|
+
return FileUploadResult(
|
|
89
|
+
storage_path=storage_path,
|
|
90
|
+
size=size,
|
|
91
|
+
mime_type=mime_type,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def get_file(self, storage_path: str) -> bytes:
|
|
95
|
+
"""
|
|
96
|
+
Read file content from storage.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
storage_path: Relative path to file
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
File content as bytes
|
|
103
|
+
"""
|
|
104
|
+
full_path = self._get_full_path(storage_path)
|
|
105
|
+
|
|
106
|
+
if not full_path.exists():
|
|
107
|
+
raise StorageException(f"File not found: {storage_path}")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
return full_path.read_bytes()
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise StorageException(f"Failed to read file: {e}")
|
|
113
|
+
|
|
114
|
+
def get_file_path(self, storage_path: str) -> Path:
|
|
115
|
+
"""
|
|
116
|
+
Get full filesystem path for a file.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
storage_path: Relative path to file
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Full Path object
|
|
123
|
+
"""
|
|
124
|
+
full_path = self._get_full_path(storage_path)
|
|
125
|
+
|
|
126
|
+
if not full_path.exists():
|
|
127
|
+
raise StorageException(f"File not found: {storage_path}")
|
|
128
|
+
|
|
129
|
+
return full_path
|
|
130
|
+
|
|
131
|
+
def delete_file(self, storage_path: str) -> bool:
|
|
132
|
+
"""
|
|
133
|
+
Delete file from storage.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
storage_path: Relative path to file
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if deleted, False if file didn't exist
|
|
140
|
+
"""
|
|
141
|
+
full_path = self._get_full_path(storage_path)
|
|
142
|
+
|
|
143
|
+
if not full_path.exists():
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
full_path.unlink()
|
|
148
|
+
return True
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise StorageException(f"Failed to delete file: {e}")
|
|
151
|
+
|
|
152
|
+
def file_exists(self, storage_path: str) -> bool:
|
|
153
|
+
"""Check if file exists in storage"""
|
|
154
|
+
try:
|
|
155
|
+
full_path = self._get_full_path(storage_path)
|
|
156
|
+
return full_path.exists()
|
|
157
|
+
except StorageException:
|
|
158
|
+
return False
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Utility functions for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import magic
|
|
10
|
+
|
|
11
|
+
HAS_MAGIC = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
HAS_MAGIC = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_uuid() -> str:
|
|
17
|
+
"""Generate a UUID string for file ID"""
|
|
18
|
+
return str(uuid.uuid4())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_mime_type(content: bytes, filename: Optional[str] = None) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Detect MIME type of file content.
|
|
24
|
+
|
|
25
|
+
Uses python-magic if available, falls back to mimetypes based on filename.
|
|
26
|
+
"""
|
|
27
|
+
if HAS_MAGIC:
|
|
28
|
+
try:
|
|
29
|
+
mime = magic.Magic(mime=True)
|
|
30
|
+
return mime.from_buffer(content)
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
if filename:
|
|
35
|
+
guessed_type, _ = mimetypes.guess_type(filename)
|
|
36
|
+
if guessed_type:
|
|
37
|
+
return guessed_type
|
|
38
|
+
|
|
39
|
+
return "application/octet-stream"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_file_extension(filename: str) -> str:
|
|
43
|
+
"""Extract file extension from filename"""
|
|
44
|
+
path = Path(filename)
|
|
45
|
+
return path.suffix.lower() if path.suffix else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def safe_filename(filename: str) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Sanitize filename to prevent directory traversal and other issues.
|
|
51
|
+
|
|
52
|
+
Returns only the basename without any directory components.
|
|
53
|
+
"""
|
|
54
|
+
name = Path(filename).name
|
|
55
|
+
name = name.replace("\x00", "")
|
|
56
|
+
return name if name else "unnamed"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Django admin configuration for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
|
|
5
|
+
from .models import Attachment
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@admin.register(Attachment)
|
|
9
|
+
class AttachmentAdmin(admin.ModelAdmin):
|
|
10
|
+
"""Admin configuration for Attachment model"""
|
|
11
|
+
|
|
12
|
+
list_display = ["id", "original_name", "mime_type", "size", "owner_id", "is_public", "created_at"]
|
|
13
|
+
list_filter = ["is_public", "mime_type", "created_at"]
|
|
14
|
+
search_fields = ["original_name", "owner_id"]
|
|
15
|
+
readonly_fields = ["id", "storage_path", "mime_type", "size", "created_at"]
|
|
16
|
+
ordering = ["-created_at"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Django app configuration for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChewyAttachmentConfig(AppConfig):
|
|
7
|
+
"""Django app configuration"""
|
|
8
|
+
|
|
9
|
+
name = "chewy_attachment.django_app"
|
|
10
|
+
verbose_name = "Chewy Attachment"
|
|
11
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-13 10:12
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='Attachment',
|
|
17
|
+
fields=[
|
|
18
|
+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
|
19
|
+
('original_name', models.CharField(max_length=255)),
|
|
20
|
+
('storage_path', models.CharField(max_length=500)),
|
|
21
|
+
('mime_type', models.CharField(max_length=100)),
|
|
22
|
+
('size', models.BigIntegerField()),
|
|
23
|
+
('owner_id', models.CharField(db_index=True, max_length=100)),
|
|
24
|
+
('is_public', models.BooleanField(db_index=True, default=False)),
|
|
25
|
+
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
26
|
+
],
|
|
27
|
+
options={
|
|
28
|
+
'db_table': 'chewy_attachment_files',
|
|
29
|
+
'ordering': ['-created_at'],
|
|
30
|
+
'indexes': [models.Index(fields=['owner_id', 'created_at'], name='chewy_attac_owner_i_9ee24d_idx')],
|
|
31
|
+
},
|
|
32
|
+
),
|
|
33
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Django models for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.db import models
|
|
7
|
+
|
|
8
|
+
from ..core.schemas import FileMetadata, UserContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_storage_root():
|
|
12
|
+
"""Get storage root from Django settings"""
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
chewy_settings = getattr(settings, "CHEWY_ATTACHMENT", {})
|
|
16
|
+
if "STORAGE_ROOT" in chewy_settings:
|
|
17
|
+
return chewy_settings["STORAGE_ROOT"]
|
|
18
|
+
|
|
19
|
+
base_dir = getattr(settings, "BASE_DIR", Path.cwd())
|
|
20
|
+
return Path(base_dir) / "media" / "attachments"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Attachment(models.Model):
|
|
24
|
+
"""Attachment model for storing file metadata"""
|
|
25
|
+
|
|
26
|
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
27
|
+
original_name = models.CharField(max_length=255)
|
|
28
|
+
storage_path = models.CharField(max_length=500)
|
|
29
|
+
mime_type = models.CharField(max_length=100)
|
|
30
|
+
size = models.BigIntegerField()
|
|
31
|
+
owner_id = models.CharField(max_length=100, db_index=True)
|
|
32
|
+
is_public = models.BooleanField(default=False, db_index=True)
|
|
33
|
+
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
34
|
+
|
|
35
|
+
class Meta:
|
|
36
|
+
db_table = "chewy_attachment_files"
|
|
37
|
+
ordering = ["-created_at"]
|
|
38
|
+
indexes = [
|
|
39
|
+
models.Index(fields=["owner_id", "created_at"]),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __str__(self):
|
|
43
|
+
return f"{self.original_name} ({self.id})"
|
|
44
|
+
|
|
45
|
+
def to_file_metadata(self) -> FileMetadata:
|
|
46
|
+
"""Convert to FileMetadata for permission checking"""
|
|
47
|
+
return FileMetadata(
|
|
48
|
+
id=str(self.id),
|
|
49
|
+
original_name=self.original_name,
|
|
50
|
+
storage_path=self.storage_path,
|
|
51
|
+
mime_type=self.mime_type,
|
|
52
|
+
size=self.size,
|
|
53
|
+
owner_id=self.owner_id,
|
|
54
|
+
is_public=self.is_public,
|
|
55
|
+
created_at=self.created_at,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def get_user_context(request) -> UserContext:
|
|
60
|
+
"""Extract UserContext from Django request"""
|
|
61
|
+
if hasattr(request, "user") and request.user.is_authenticated:
|
|
62
|
+
user_id = str(request.user.id)
|
|
63
|
+
return UserContext.authenticated(user_id)
|
|
64
|
+
return UserContext.anonymous()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""DRF permission classes for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from rest_framework import permissions
|
|
4
|
+
|
|
5
|
+
from ..core.permissions import PermissionChecker
|
|
6
|
+
from .models import Attachment
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IsOwnerOrPublicReadOnly(permissions.BasePermission):
|
|
10
|
+
"""
|
|
11
|
+
Permission class for attachment access.
|
|
12
|
+
|
|
13
|
+
- View/Download: public files OR owner
|
|
14
|
+
- Delete: owner only
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def has_object_permission(self, request, view, obj: Attachment):
|
|
18
|
+
user_context = Attachment.get_user_context(request)
|
|
19
|
+
file_metadata = obj.to_file_metadata()
|
|
20
|
+
|
|
21
|
+
if request.method in permissions.SAFE_METHODS:
|
|
22
|
+
return PermissionChecker.can_view(file_metadata, user_context)
|
|
23
|
+
|
|
24
|
+
if request.method == "DELETE":
|
|
25
|
+
return PermissionChecker.can_delete(file_metadata, user_context)
|
|
26
|
+
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class IsAuthenticatedForUpload(permissions.BasePermission):
|
|
31
|
+
"""Permission class requiring authentication for upload"""
|
|
32
|
+
|
|
33
|
+
def has_permission(self, request, view):
|
|
34
|
+
if view.action == "create":
|
|
35
|
+
return request.user and request.user.is_authenticated
|
|
36
|
+
return True
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""DRF serializers for ChewyAttachment"""
|
|
2
|
+
|
|
3
|
+
from rest_framework import serializers
|
|
4
|
+
|
|
5
|
+
from .models import Attachment
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AttachmentSerializer(serializers.ModelSerializer):
|
|
9
|
+
"""Serializer for Attachment model (read operations)"""
|
|
10
|
+
|
|
11
|
+
class Meta:
|
|
12
|
+
model = Attachment
|
|
13
|
+
fields = [
|
|
14
|
+
"id",
|
|
15
|
+
"original_name",
|
|
16
|
+
"mime_type",
|
|
17
|
+
"size",
|
|
18
|
+
"owner_id",
|
|
19
|
+
"is_public",
|
|
20
|
+
"created_at",
|
|
21
|
+
]
|
|
22
|
+
read_only_fields = fields
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AttachmentUploadSerializer(serializers.Serializer):
|
|
26
|
+
"""Serializer for file upload"""
|
|
27
|
+
|
|
28
|
+
file = serializers.FileField(required=True)
|
|
29
|
+
is_public = serializers.BooleanField(default=False, required=False)
|
|
30
|
+
|
|
31
|
+
def validate_file(self, value):
|
|
32
|
+
"""Validate uploaded file"""
|
|
33
|
+
if not value:
|
|
34
|
+
raise serializers.ValidationError("No file provided")
|
|
35
|
+
return value
|
|
File without changes
|