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.
Files changed (45) hide show
  1. statezero/__init__.py +0 -0
  2. statezero/adaptors/__init__.py +0 -0
  3. statezero/adaptors/django/__init__.py +0 -0
  4. statezero/adaptors/django/apps.py +97 -0
  5. statezero/adaptors/django/config.py +99 -0
  6. statezero/adaptors/django/context_manager.py +12 -0
  7. statezero/adaptors/django/event_emitters.py +78 -0
  8. statezero/adaptors/django/exception_handler.py +98 -0
  9. statezero/adaptors/django/extensions/__init__.py +0 -0
  10. statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  11. statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
  12. statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +75 -0
  13. statezero/adaptors/django/f_handler.py +312 -0
  14. statezero/adaptors/django/helpers.py +153 -0
  15. statezero/adaptors/django/middleware.py +10 -0
  16. statezero/adaptors/django/migrations/0001_initial.py +33 -0
  17. statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
  18. statezero/adaptors/django/migrations/__init__.py +0 -0
  19. statezero/adaptors/django/orm.py +915 -0
  20. statezero/adaptors/django/permissions.py +252 -0
  21. statezero/adaptors/django/query_optimizer.py +772 -0
  22. statezero/adaptors/django/schemas.py +324 -0
  23. statezero/adaptors/django/search_providers/__init__.py +0 -0
  24. statezero/adaptors/django/search_providers/basic_search.py +24 -0
  25. statezero/adaptors/django/search_providers/postgres_search.py +51 -0
  26. statezero/adaptors/django/serializers.py +554 -0
  27. statezero/adaptors/django/urls.py +14 -0
  28. statezero/adaptors/django/views.py +336 -0
  29. statezero/core/__init__.py +34 -0
  30. statezero/core/ast_parser.py +821 -0
  31. statezero/core/ast_validator.py +266 -0
  32. statezero/core/classes.py +167 -0
  33. statezero/core/config.py +263 -0
  34. statezero/core/context_storage.py +4 -0
  35. statezero/core/event_bus.py +175 -0
  36. statezero/core/event_emitters.py +60 -0
  37. statezero/core/exceptions.py +106 -0
  38. statezero/core/interfaces.py +492 -0
  39. statezero/core/process_request.py +184 -0
  40. statezero/core/types.py +63 -0
  41. statezero-0.1.0b1.dist-info/METADATA +252 -0
  42. statezero-0.1.0b1.dist-info/RECORD +45 -0
  43. statezero-0.1.0b1.dist-info/WHEEL +5 -0
  44. statezero-0.1.0b1.dist-info/licenses/license.md +117 -0
  45. 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
@@ -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