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,3 @@
1
+ """ChewyAttachment - 通用图片/附件管理插件"""
2
+
3
+ __version__ = "0.1.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,3 @@
1
+ """Django implementation of ChewyAttachment"""
2
+
3
+ default_app_config = "chewy_attachment.django_app.apps.ChewyAttachmentConfig"
@@ -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