statezero 0.1.0b1__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.
- statezero/__init__.py +0 -0
- statezero/adaptors/__init__.py +0 -0
- statezero/adaptors/django/__init__.py +0 -0
- statezero/adaptors/django/apps.py +97 -0
- statezero/adaptors/django/config.py +99 -0
- statezero/adaptors/django/context_manager.py +12 -0
- statezero/adaptors/django/event_emitters.py +78 -0
- statezero/adaptors/django/exception_handler.py +98 -0
- statezero/adaptors/django/extensions/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
- statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
- statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +75 -0
- statezero/adaptors/django/f_handler.py +312 -0
- statezero/adaptors/django/helpers.py +153 -0
- statezero/adaptors/django/middleware.py +10 -0
- statezero/adaptors/django/migrations/0001_initial.py +33 -0
- statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
- statezero/adaptors/django/migrations/__init__.py +0 -0
- statezero/adaptors/django/orm.py +915 -0
- statezero/adaptors/django/permissions.py +252 -0
- statezero/adaptors/django/query_optimizer.py +772 -0
- statezero/adaptors/django/schemas.py +324 -0
- statezero/adaptors/django/search_providers/__init__.py +0 -0
- statezero/adaptors/django/search_providers/basic_search.py +24 -0
- statezero/adaptors/django/search_providers/postgres_search.py +51 -0
- statezero/adaptors/django/serializers.py +554 -0
- statezero/adaptors/django/urls.py +14 -0
- statezero/adaptors/django/views.py +336 -0
- statezero/core/__init__.py +34 -0
- statezero/core/ast_parser.py +821 -0
- statezero/core/ast_validator.py +266 -0
- statezero/core/classes.py +167 -0
- statezero/core/config.py +263 -0
- statezero/core/context_storage.py +4 -0
- statezero/core/event_bus.py +175 -0
- statezero/core/event_emitters.py +60 -0
- statezero/core/exceptions.py +106 -0
- statezero/core/interfaces.py +492 -0
- statezero/core/process_request.py +184 -0
- statezero/core/types.py +63 -0
- statezero-0.1.0b1.dist-info/METADATA +252 -0
- statezero-0.1.0b1.dist-info/RECORD +45 -0
- statezero-0.1.0b1.dist-info/WHEEL +5 -0
- statezero-0.1.0b1.dist-info/licenses/license.md +117 -0
- statezero-0.1.0b1.dist-info/top_level.txt +1 -0
statezero/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from django.apps import AppConfig as DjangoAppConfig
|
|
6
|
+
from django.apps import apps
|
|
7
|
+
from django.conf import settings
|
|
8
|
+
|
|
9
|
+
from statezero.adaptors.django.config import config, registry
|
|
10
|
+
|
|
11
|
+
# Attempt to import Rich for nicer console output.
|
|
12
|
+
try:
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
except ImportError:
|
|
18
|
+
console = None
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StateZeroDjangoConfig(DjangoAppConfig):
|
|
24
|
+
name = "statezero.adaptors.django"
|
|
25
|
+
verbose_name = "StateZero Django Integration"
|
|
26
|
+
label = "statezero"
|
|
27
|
+
|
|
28
|
+
def ready(self):
|
|
29
|
+
# Import crud modules which register models in the registry.
|
|
30
|
+
if hasattr(settings, 'CONFIG_FILE_PREFIX'):
|
|
31
|
+
config_file_prefix: str = settings.CONFIG_FILE_PREFIX
|
|
32
|
+
config_file_prefix = config_file_prefix.replace('.py', '')
|
|
33
|
+
if (not isinstance(config_file_prefix, str)) or (len(config_file_prefix) < 1):
|
|
34
|
+
raise ValueError(f"If provided, CONFIG_FILE_PREFIX must be a string with at least one character. In your settings.py it is set to {settings.CONFIG_FILE_PREFIX}. Either delete the setting completely or use a valid file name like 'crud'")
|
|
35
|
+
else:
|
|
36
|
+
config_file_prefix = "crud"
|
|
37
|
+
for app_config_instance in apps.get_app_configs():
|
|
38
|
+
module_name = f"{app_config_instance.name}.{config_file_prefix}"
|
|
39
|
+
try:
|
|
40
|
+
importlib.import_module(module_name)
|
|
41
|
+
logger.debug(f"Imported {config_file_prefix} module from {app_config_instance.name}")
|
|
42
|
+
except ModuleNotFoundError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# Once all the apps are imported, initialize StateZero and provide the registry to the event bus.
|
|
46
|
+
config.initialize()
|
|
47
|
+
config.validate_exposed_models(registry) # Raises an exception if a non StateZero model is implicitly exposed
|
|
48
|
+
config.event_bus.set_registry(registry)
|
|
49
|
+
|
|
50
|
+
# Print the list of published models (from registry) to confirm StateZero is running.
|
|
51
|
+
try:
|
|
52
|
+
published_models = []
|
|
53
|
+
for model in registry._models_config.keys():
|
|
54
|
+
# Use the ORM provider's get_model_name to get the namespaced model name.
|
|
55
|
+
model_name = model.__name__
|
|
56
|
+
published_models.append(model_name)
|
|
57
|
+
|
|
58
|
+
if published_models:
|
|
59
|
+
base_message = (
|
|
60
|
+
"[bold green]StateZero is exposing models:[/bold green] [bold yellow]"
|
|
61
|
+
+ ", ".join(published_models)
|
|
62
|
+
+ "[/bold yellow]"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
base_message = "[bold yellow]StateZero is running but no models are registered.[/bold yellow]"
|
|
66
|
+
|
|
67
|
+
# Append the npm command instruction only in debug mode.
|
|
68
|
+
if published_models and settings.DEBUG:
|
|
69
|
+
npm_message = (
|
|
70
|
+
"\n[bold blue]Next step:[/bold blue] Run [italic]npm run sync-models[/italic] in your frontend project directory "
|
|
71
|
+
"to generate or update the client-side code corresponding to these models. "
|
|
72
|
+
"Note: This command should only be executed in a development environment."
|
|
73
|
+
)
|
|
74
|
+
message = base_message + npm_message
|
|
75
|
+
else:
|
|
76
|
+
message = base_message
|
|
77
|
+
|
|
78
|
+
# Use Rich Panel for a boxed display if Rich is available.
|
|
79
|
+
if console:
|
|
80
|
+
final_message = Panel(message, expand=False)
|
|
81
|
+
console.print(final_message)
|
|
82
|
+
else:
|
|
83
|
+
# Fallback to simple demarcation lines if Rich isn't available.
|
|
84
|
+
demarcation = "\n" + "-" * 50 + "\n"
|
|
85
|
+
final_message = demarcation + message + demarcation
|
|
86
|
+
logger.info(final_message)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
error_message = (
|
|
89
|
+
f"[bold red]Error retrieving published models: {e}[/bold red]"
|
|
90
|
+
)
|
|
91
|
+
if console:
|
|
92
|
+
final_message = Panel(error_message, expand=False)
|
|
93
|
+
console.print(final_message)
|
|
94
|
+
else:
|
|
95
|
+
demarcation = "\n" + "-" * 50 + "\n"
|
|
96
|
+
final_message = demarcation + error_message + demarcation
|
|
97
|
+
logger.info(final_message)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from django.utils.module_loading import import_string
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
from statezero.adaptors.django.query_optimizer import DjangoQueryOptimizer
|
|
9
|
+
from statezero.adaptors.django.context_manager import query_timeout
|
|
10
|
+
from statezero.core.config import AppConfig, Registry
|
|
11
|
+
from django.db.models import FileField
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from django.db.models import ImageField
|
|
15
|
+
image_field_available = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
ImageField = None
|
|
18
|
+
image_field_available = False
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
class DjangoLocalConfig(AppConfig):
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.DEBUG = settings.DEBUG
|
|
25
|
+
|
|
26
|
+
def initialize(self):
|
|
27
|
+
from statezero.adaptors.django.event_emitters import \
|
|
28
|
+
DjangoPusherEventEmitter, DjangoConsoleEventEmitter
|
|
29
|
+
from statezero.adaptors.django.orm import DjangoORMAdapter
|
|
30
|
+
from statezero.adaptors.django.schemas import DjangoSchemaGenerator
|
|
31
|
+
from statezero.adaptors.django.serializers import DRFDynamicSerializer
|
|
32
|
+
from statezero.adaptors.django.search_providers.basic_search import BasicSearchProvider
|
|
33
|
+
from statezero.core.event_bus import EventBus
|
|
34
|
+
|
|
35
|
+
# Initialize serializer, schema generator, and ORM adapter.
|
|
36
|
+
self.serializer = DRFDynamicSerializer()
|
|
37
|
+
self.schema_generator = DjangoSchemaGenerator()
|
|
38
|
+
self.orm_provider = DjangoORMAdapter()
|
|
39
|
+
self.context_manager = query_timeout
|
|
40
|
+
self.query_optimizer = DjangoQueryOptimizer
|
|
41
|
+
|
|
42
|
+
# Instantiate emitters by injecting only the necessary functions.
|
|
43
|
+
if hasattr(settings, 'STATEZERO_PUSHER'):
|
|
44
|
+
event_emitter = DjangoPusherEventEmitter()
|
|
45
|
+
else:
|
|
46
|
+
warnings.warn("You have not added STATEZERO_PUSHER to your settings.py. Live model changes will not be broadcast")
|
|
47
|
+
event_emitter = DjangoConsoleEventEmitter()
|
|
48
|
+
|
|
49
|
+
# Create the EventBus with two explicit emitters.
|
|
50
|
+
self.event_bus = EventBus(
|
|
51
|
+
broadcast_emitter=event_emitter,
|
|
52
|
+
orm_provider=self.orm_provider,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Setup the search provider
|
|
56
|
+
self.search_provider = BasicSearchProvider()
|
|
57
|
+
|
|
58
|
+
self.file_upload_callbacks = None
|
|
59
|
+
|
|
60
|
+
# Explicitly register event signals after both components are configured.
|
|
61
|
+
self.orm_provider.register_event_signals(self.event_bus)
|
|
62
|
+
|
|
63
|
+
from statezero.adaptors.django.extensions.custom_field_serializers.file_fields import (
|
|
64
|
+
FileFieldSerializer, ImageFieldSerializer)
|
|
65
|
+
|
|
66
|
+
# Initialize custom serializers
|
|
67
|
+
self.custom_serializers = {
|
|
68
|
+
FileField: FileFieldSerializer
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if image_field_available:
|
|
72
|
+
self.custom_serializers[ImageField] = ImageFieldSerializer
|
|
73
|
+
|
|
74
|
+
# Try to register djmoney support
|
|
75
|
+
try:
|
|
76
|
+
from statezero.adaptors.django.extensions.custom_field_serializers.money_field import (
|
|
77
|
+
MoneyFieldSchema, MoneyFieldSerializer)
|
|
78
|
+
from djmoney.models.fields import MoneyField
|
|
79
|
+
self.custom_serializers[MoneyField] = MoneyFieldSerializer
|
|
80
|
+
self.schema_overrides = {
|
|
81
|
+
MoneyField: MoneyFieldSchema,
|
|
82
|
+
}
|
|
83
|
+
except Exception:
|
|
84
|
+
self.schema_overrides = {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Create the singleton instances.
|
|
88
|
+
custom_config_path = getattr(settings, "STATEZERO_CUSTOM_CONFIG", None)
|
|
89
|
+
if custom_config_path:
|
|
90
|
+
custom_config_class = import_string(custom_config_path)
|
|
91
|
+
if not issubclass(custom_config_class, AppConfig):
|
|
92
|
+
raise ImproperlyConfigured(
|
|
93
|
+
"STATEZERO_CUSTOM_CONFIG must be a subclass of AppConfig"
|
|
94
|
+
)
|
|
95
|
+
config = custom_config_class()
|
|
96
|
+
else:
|
|
97
|
+
config = DjangoLocalConfig()
|
|
98
|
+
|
|
99
|
+
registry = Registry()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from django.db import connection
|
|
3
|
+
|
|
4
|
+
@contextmanager
|
|
5
|
+
def query_timeout(timeout_ms):
|
|
6
|
+
if connection.vendor == 'postgresql':
|
|
7
|
+
with connection.cursor() as cursor:
|
|
8
|
+
cursor.execute(f'SET LOCAL statement_timeout = {timeout_ms};')
|
|
9
|
+
yield
|
|
10
|
+
else:
|
|
11
|
+
# For SQLite or others, no operation
|
|
12
|
+
yield
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import logging
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from rest_framework.request import Request
|
|
5
|
+
from django.utils.module_loading import import_string
|
|
6
|
+
|
|
7
|
+
from statezero.core.event_emitters import ConsoleEventEmitter, PusherEventEmitter
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DjangoConsoleEventEmitter(ConsoleEventEmitter):
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
permission_class_path = getattr(
|
|
17
|
+
settings,
|
|
18
|
+
"STATEZERO_VIEW_ACCESS_CLASS",
|
|
19
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
20
|
+
)
|
|
21
|
+
try:
|
|
22
|
+
self.permission_class = import_string(permission_class_path)()
|
|
23
|
+
logger.debug("Using emitter permission class: %s", permission_class_path)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
logger.error("Error importing emitter permission class '%s': %s", permission_class_path, str(e))
|
|
26
|
+
from rest_framework.permissions import IsAuthenticated
|
|
27
|
+
self.permission_class = IsAuthenticated()
|
|
28
|
+
|
|
29
|
+
def has_permission(self, request: Request, namespace: str) -> bool:
|
|
30
|
+
return self.permission_class.has_permission(request, None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DjangoPusherEventEmitter(PusherEventEmitter):
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
app_id: Optional[str] = None,
|
|
37
|
+
key: Optional[str] = None,
|
|
38
|
+
secret: Optional[str] = None,
|
|
39
|
+
cluster: Optional[str] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
import pusher
|
|
42
|
+
|
|
43
|
+
app_id = app_id or settings.STATEZERO_PUSHER.get("APP_ID")
|
|
44
|
+
key = key or settings.STATEZERO_PUSHER.get("KEY")
|
|
45
|
+
secret = secret or settings.STATEZERO_PUSHER.get("SECRET")
|
|
46
|
+
cluster = cluster or settings.STATEZERO_PUSHER.get("CLUSTER")
|
|
47
|
+
|
|
48
|
+
if not all([app_id, key, secret, cluster]):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Pusher credentials must be provided via parameters or defined in settings as "
|
|
51
|
+
"'APP_ID', 'KEY', 'SECRET', and 'CLUSTER'."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
pusher_client = pusher.Pusher(
|
|
55
|
+
app_id=app_id,
|
|
56
|
+
key=key,
|
|
57
|
+
secret=secret,
|
|
58
|
+
cluster=cluster,
|
|
59
|
+
ssl=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
super().__init__(pusher_client=pusher_client)
|
|
63
|
+
|
|
64
|
+
permission_class_path = getattr(
|
|
65
|
+
settings,
|
|
66
|
+
"STATEZERO_VIEW_ACCESS_CLASS",
|
|
67
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
self.permission_class = import_string(permission_class_path)()
|
|
71
|
+
logger.debug("Using emitter permission class: %s", permission_class_path)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error("Error importing emitter permission class '%s': %s", permission_class_path, str(e))
|
|
74
|
+
from rest_framework.permissions import IsAuthenticated
|
|
75
|
+
self.permission_class = IsAuthenticated()
|
|
76
|
+
|
|
77
|
+
def has_permission(self, request: Request, namespace: str) -> bool:
|
|
78
|
+
return self.permission_class.has_permission(request, None)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.core.exceptions import \
|
|
6
|
+
MultipleObjectsReturned as DjangoMultipleObjectsReturned
|
|
7
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
8
|
+
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
|
9
|
+
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
10
|
+
# Import Django/DRF exceptions.
|
|
11
|
+
from django.db.models import Model
|
|
12
|
+
from django.http import Http404
|
|
13
|
+
from fastapi.encoders import jsonable_encoder # Requires fastapi dependency
|
|
14
|
+
from rest_framework import status
|
|
15
|
+
from rest_framework.exceptions import NotFound as DRFNotFound
|
|
16
|
+
from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied
|
|
17
|
+
from rest_framework.exceptions import ValidationError as DRFValidationError
|
|
18
|
+
from rest_framework.response import Response
|
|
19
|
+
|
|
20
|
+
# Import your custom StateZero exception types.
|
|
21
|
+
from statezero.core.exceptions import (ErrorDetail, StateZeroError,
|
|
22
|
+
MultipleObjectsReturned, NotFound,
|
|
23
|
+
PermissionDenied, ValidationError)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
logger.setLevel(logging.DEBUG)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def map_exception(exc):
|
|
30
|
+
"""
|
|
31
|
+
Map Django/DRF exceptions to your library’s errors.
|
|
32
|
+
If the exception is already one of your library's types, return it as is.
|
|
33
|
+
"""
|
|
34
|
+
logger.debug("Mapping exception type: %s", type(exc))
|
|
35
|
+
|
|
36
|
+
if isinstance(exc, StateZeroError):
|
|
37
|
+
return exc
|
|
38
|
+
|
|
39
|
+
if isinstance(exc, type) and issubclass(exc, Model.DoesNotExist):
|
|
40
|
+
return NotFound(detail=str(exc))
|
|
41
|
+
|
|
42
|
+
if isinstance(exc, DjangoMultipleObjectsReturned):
|
|
43
|
+
return MultipleObjectsReturned(detail=str(exc))
|
|
44
|
+
if isinstance(exc, (ObjectDoesNotExist, Http404)):
|
|
45
|
+
return NotFound(detail=str(exc))
|
|
46
|
+
if isinstance(exc, DjangoValidationError):
|
|
47
|
+
detail = getattr(exc, "message_dict", getattr(exc, "messages", str(exc)))
|
|
48
|
+
return ValidationError(detail=detail)
|
|
49
|
+
if isinstance(exc, DjangoPermissionDenied):
|
|
50
|
+
return PermissionDenied(detail=str(exc))
|
|
51
|
+
|
|
52
|
+
if isinstance(exc, DRFValidationError):
|
|
53
|
+
return ValidationError(detail=exc.detail)
|
|
54
|
+
if isinstance(exc, DRFNotFound):
|
|
55
|
+
return NotFound(detail=str(exc))
|
|
56
|
+
if isinstance(exc, DRFPermissionDenied):
|
|
57
|
+
return PermissionDenied(detail=str(exc))
|
|
58
|
+
|
|
59
|
+
return exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def explicit_exception_handler(exc):
|
|
63
|
+
"""
|
|
64
|
+
Extended explicit exception handler that builds a structured JSON response.
|
|
65
|
+
It maps known Django/DRF exceptions to your library's standard errors and
|
|
66
|
+
uses jsonable_encoder to ensure the output is JSON serializable.
|
|
67
|
+
"""
|
|
68
|
+
traceback.print_exc()
|
|
69
|
+
exc = map_exception(exc)
|
|
70
|
+
logger.debug("Using exception type after mapping: %s", type(exc))
|
|
71
|
+
|
|
72
|
+
if isinstance(exc, NotFound):
|
|
73
|
+
status_code = status.HTTP_404_NOT_FOUND
|
|
74
|
+
elif isinstance(exc, PermissionDenied):
|
|
75
|
+
status_code = status.HTTP_403_FORBIDDEN
|
|
76
|
+
elif isinstance(exc, ValidationError):
|
|
77
|
+
status_code = status.HTTP_400_BAD_REQUEST
|
|
78
|
+
else:
|
|
79
|
+
status_code = getattr(exc, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
80
|
+
|
|
81
|
+
# Only show detailed errors for 400 and 404 in production
|
|
82
|
+
# For 403 and 500 errors, only show details in debug mode
|
|
83
|
+
if status_code in [status.HTTP_403_FORBIDDEN, status.HTTP_500_INTERNAL_SERVER_ERROR] and not settings.DEBUG:
|
|
84
|
+
if status_code == status.HTTP_403_FORBIDDEN:
|
|
85
|
+
detail = "Permission denied"
|
|
86
|
+
else:
|
|
87
|
+
detail = "Internal server error"
|
|
88
|
+
else:
|
|
89
|
+
detail = jsonable_encoder(exc.detail) if hasattr(exc, "detail") else str(exc)
|
|
90
|
+
|
|
91
|
+
error_data = {
|
|
92
|
+
"status": status_code,
|
|
93
|
+
"type": exc.__class__.__name__,
|
|
94
|
+
"detail": detail,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
logger.error("Exception handled explicitly: %s", error_data)
|
|
98
|
+
return Response(error_data, status=status_code)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from rest_framework import fields
|
|
2
|
+
from rest_framework.fields import empty
|
|
3
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
4
|
+
from django.core.files.storage import default_storage
|
|
5
|
+
import os
|
|
6
|
+
import mimetypes
|
|
7
|
+
|
|
8
|
+
image_fields_supported = False
|
|
9
|
+
try:
|
|
10
|
+
from PIL import Image
|
|
11
|
+
import io
|
|
12
|
+
image_fields_supported = True
|
|
13
|
+
except:
|
|
14
|
+
image_fields_supported = False
|
|
15
|
+
|
|
16
|
+
class FileFieldSerializer(fields.FileField):
|
|
17
|
+
"""
|
|
18
|
+
Copy of DRF's FileField but handles file paths instead of file objects.
|
|
19
|
+
"""
|
|
20
|
+
default_error_messages = {
|
|
21
|
+
'required': 'No file path provided.',
|
|
22
|
+
'invalid': 'Not a valid file path.',
|
|
23
|
+
'no_name': 'No filename could be determined.',
|
|
24
|
+
'empty': 'The submitted file path is empty.',
|
|
25
|
+
'max_length': 'Ensure this filename has at most {max_length} characters (it has {length}).',
|
|
26
|
+
'file_not_found': 'File not found at the specified path.',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs):
|
|
30
|
+
self.max_length = kwargs.pop('max_length', None)
|
|
31
|
+
self.allow_empty_file = kwargs.pop('allow_empty_file', False)
|
|
32
|
+
super().__init__(**kwargs)
|
|
33
|
+
|
|
34
|
+
def to_representation(self, value):
|
|
35
|
+
if not value:
|
|
36
|
+
return None
|
|
37
|
+
url = super().to_representation(value)
|
|
38
|
+
mime_type, _ = mimetypes.guess_type(value.name)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
'file_path': value.name,
|
|
42
|
+
'file_name': os.path.basename(value.name),
|
|
43
|
+
'file_url': url,
|
|
44
|
+
'size': value.size,
|
|
45
|
+
'mime_type': mime_type
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def to_internal_value(self, data):
|
|
49
|
+
if data is empty:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if isinstance(data, dict) and 'file_path' in data:
|
|
53
|
+
file_path = data.get('file_path')
|
|
54
|
+
elif isinstance(data, str):
|
|
55
|
+
file_path = data
|
|
56
|
+
else:
|
|
57
|
+
self.fail('invalid')
|
|
58
|
+
|
|
59
|
+
if not isinstance(file_path, str):
|
|
60
|
+
self.fail('invalid')
|
|
61
|
+
|
|
62
|
+
if not file_path:
|
|
63
|
+
if self.allow_empty_file:
|
|
64
|
+
return file_path
|
|
65
|
+
self.fail('empty')
|
|
66
|
+
|
|
67
|
+
if self.max_length is not None and len(file_path) > self.max_length:
|
|
68
|
+
self.fail('max_length', max_length=self.max_length, length=len(file_path))
|
|
69
|
+
|
|
70
|
+
if not default_storage.exists(file_path):
|
|
71
|
+
self.fail('file_not_found')
|
|
72
|
+
|
|
73
|
+
return file_path
|
|
74
|
+
|
|
75
|
+
class ImageFieldSerializer(fields.ImageField):
|
|
76
|
+
"""
|
|
77
|
+
Copy of DRF's ImageField but handles file paths instead of file objects.
|
|
78
|
+
"""
|
|
79
|
+
default_error_messages = {
|
|
80
|
+
'invalid_image': (
|
|
81
|
+
'Upload a valid image. The file you uploaded was either not an '
|
|
82
|
+
'image or a corrupted image.'
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def __init__(self, **kwargs):
|
|
87
|
+
super().__init__(**kwargs)
|
|
88
|
+
|
|
89
|
+
def to_representation(self, value):
|
|
90
|
+
if not value:
|
|
91
|
+
return None
|
|
92
|
+
url = super().to_representation(value)
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
'file_path': value.name,
|
|
96
|
+
'file_name': os.path.basename(value.name),
|
|
97
|
+
'file_url': url,
|
|
98
|
+
'size': value.size
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def to_internal_value(self, data):
|
|
102
|
+
# File path validation logic
|
|
103
|
+
if data is empty:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if isinstance(data, dict) and 'file_path' in data:
|
|
107
|
+
file_path = data.get('file_path')
|
|
108
|
+
elif isinstance(data, str):
|
|
109
|
+
file_path = data
|
|
110
|
+
else:
|
|
111
|
+
self.fail('invalid')
|
|
112
|
+
|
|
113
|
+
if not isinstance(file_path, str):
|
|
114
|
+
self.fail('invalid')
|
|
115
|
+
|
|
116
|
+
if not file_path:
|
|
117
|
+
if self.allow_empty_file:
|
|
118
|
+
return file_path
|
|
119
|
+
self.fail('empty')
|
|
120
|
+
|
|
121
|
+
if self.max_length is not None and len(file_path) > self.max_length:
|
|
122
|
+
self.fail('max_length', max_length=self.max_length, length=len(file_path))
|
|
123
|
+
|
|
124
|
+
if not default_storage.exists(file_path):
|
|
125
|
+
self.fail('file_not_found')
|
|
126
|
+
|
|
127
|
+
# Image validation logic
|
|
128
|
+
if image_fields_supported:
|
|
129
|
+
try:
|
|
130
|
+
with default_storage.open(file_path, 'rb') as f:
|
|
131
|
+
image = Image.open(f)
|
|
132
|
+
image.verify()
|
|
133
|
+
|
|
134
|
+
# verify() invalidates the image
|
|
135
|
+
f.seek(0)
|
|
136
|
+
image = Image.open(f)
|
|
137
|
+
|
|
138
|
+
except Exception:
|
|
139
|
+
self.fail('invalid_image')
|
|
140
|
+
|
|
141
|
+
return file_path
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from decimal import Decimal, InvalidOperation
|
|
3
|
+
from typing import Dict, Tuple
|
|
4
|
+
|
|
5
|
+
from djmoney.contrib.django_rest_framework.fields import MoneyField
|
|
6
|
+
from djmoney.money import Money
|
|
7
|
+
from rest_framework import serializers
|
|
8
|
+
|
|
9
|
+
from statezero.core.classes import FieldFormat, FieldType, SchemaFieldMetadata
|
|
10
|
+
from statezero.core.interfaces import AbstractSchemaOverride
|
|
11
|
+
|
|
12
|
+
class MoneyFieldSerializer(serializers.Field):
|
|
13
|
+
def __init__(self, **kwargs):
|
|
14
|
+
self.max_digits = kwargs.pop("max_digits", 14)
|
|
15
|
+
self.decimal_places = kwargs.pop("decimal_places", 2)
|
|
16
|
+
super().__init__(**kwargs)
|
|
17
|
+
|
|
18
|
+
def to_representation(self, value):
|
|
19
|
+
djmoney_field = MoneyField(
|
|
20
|
+
max_digits=self.max_digits, decimal_places=self.decimal_places
|
|
21
|
+
)
|
|
22
|
+
amount_representation = djmoney_field.to_representation(value)
|
|
23
|
+
return {"amount": amount_representation, "currency": value.currency.code}
|
|
24
|
+
|
|
25
|
+
def to_internal_value(self, data):
|
|
26
|
+
if isinstance(data, Money):
|
|
27
|
+
return data
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
raise serializers.ValidationError(
|
|
30
|
+
"Input must be an object with 'amount' and 'currency' keys"
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
amount = data["amount"]
|
|
34
|
+
currency = data["currency"]
|
|
35
|
+
if isinstance(amount, (str, float, int)):
|
|
36
|
+
amount = Decimal(amount)
|
|
37
|
+
return Money(amount, currency)
|
|
38
|
+
except KeyError:
|
|
39
|
+
raise serializers.ValidationError("Missing 'amount' or 'currency'")
|
|
40
|
+
except InvalidOperation:
|
|
41
|
+
raise serializers.ValidationError("Invalid decimal format")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MoneyFieldSchema(AbstractSchemaOverride):
|
|
45
|
+
def __init__(self, *args, **kwargs):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def get_schema(
|
|
49
|
+
field: MoneyField,
|
|
50
|
+
) -> Tuple[SchemaFieldMetadata, Dict[str, str], str]:
|
|
51
|
+
"""
|
|
52
|
+
Generate a schema for MoneyField.
|
|
53
|
+
This registers a reusable definition and returns a schema metadata object with a $ref.
|
|
54
|
+
"""
|
|
55
|
+
key = field.__class__.__name__ # i.e. "MoneyField"
|
|
56
|
+
|
|
57
|
+
definition = {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"amount": {"type": "number"},
|
|
61
|
+
"currency": {"type": "string"},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
# Return a schema metadata object that references this definition.
|
|
65
|
+
schema = SchemaFieldMetadata(
|
|
66
|
+
type=FieldType.OBJECT,
|
|
67
|
+
title="Money",
|
|
68
|
+
required=True,
|
|
69
|
+
nullable=field.null,
|
|
70
|
+
format=FieldFormat.MONEY,
|
|
71
|
+
description=field.help_text or "Money field",
|
|
72
|
+
ref=f"#/components/schemas/{key}",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return schema, definition, key
|