statezero 0.1.0b20__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 (48) 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/actions.py +248 -0
  5. statezero/adaptors/django/apps.py +137 -0
  6. statezero/adaptors/django/config.py +99 -0
  7. statezero/adaptors/django/context_manager.py +12 -0
  8. statezero/adaptors/django/event_emitters.py +78 -0
  9. statezero/adaptors/django/exception_handler.py +98 -0
  10. statezero/adaptors/django/extensions/__init__.py +0 -0
  11. statezero/adaptors/django/extensions/custom_field_serializers/__init__.py +0 -0
  12. statezero/adaptors/django/extensions/custom_field_serializers/file_fields.py +141 -0
  13. statezero/adaptors/django/extensions/custom_field_serializers/money_field.py +83 -0
  14. statezero/adaptors/django/f_handler.py +312 -0
  15. statezero/adaptors/django/helpers.py +153 -0
  16. statezero/adaptors/django/middleware.py +10 -0
  17. statezero/adaptors/django/migrations/0001_initial.py +33 -0
  18. statezero/adaptors/django/migrations/0002_delete_modelviewsubscription.py +16 -0
  19. statezero/adaptors/django/migrations/__init__.py +0 -0
  20. statezero/adaptors/django/orm.py +1073 -0
  21. statezero/adaptors/django/permissions.py +252 -0
  22. statezero/adaptors/django/query_optimizer.py +810 -0
  23. statezero/adaptors/django/schemas.py +360 -0
  24. statezero/adaptors/django/search_providers/__init__.py +0 -0
  25. statezero/adaptors/django/search_providers/basic_search.py +24 -0
  26. statezero/adaptors/django/search_providers/postgres_search.py +51 -0
  27. statezero/adaptors/django/serializers.py +588 -0
  28. statezero/adaptors/django/urls.py +18 -0
  29. statezero/adaptors/django/views.py +588 -0
  30. statezero/core/__init__.py +34 -0
  31. statezero/core/actions.py +92 -0
  32. statezero/core/ast_parser.py +961 -0
  33. statezero/core/ast_validator.py +266 -0
  34. statezero/core/classes.py +224 -0
  35. statezero/core/config.py +267 -0
  36. statezero/core/context_storage.py +4 -0
  37. statezero/core/event_bus.py +175 -0
  38. statezero/core/event_emitters.py +60 -0
  39. statezero/core/exceptions.py +106 -0
  40. statezero/core/hook_checks.py +86 -0
  41. statezero/core/interfaces.py +677 -0
  42. statezero/core/process_request.py +184 -0
  43. statezero/core/types.py +29 -0
  44. statezero-0.1.0b20.dist-info/METADATA +242 -0
  45. statezero-0.1.0b20.dist-info/RECORD +48 -0
  46. statezero-0.1.0b20.dist-info/WHEEL +5 -0
  47. statezero-0.1.0b20.dist-info/licenses/license.md +117 -0
  48. statezero-0.1.0b20.dist-info/top_level.txt +1 -0
statezero/__init__.py ADDED
File without changes
File without changes
File without changes
@@ -0,0 +1,248 @@
1
+ import os
2
+ from django.apps import apps
3
+ from rest_framework.response import Response
4
+ from rest_framework import fields, serializers
5
+ from django.db import models
6
+ from statezero.core.actions import action_registry
7
+
8
+ class DjangoActionSchemaGenerator:
9
+ """Django-specific action schema generator that matches StateZero model schema format"""
10
+
11
+ @staticmethod
12
+ def generate_actions_schema():
13
+ """Generate schema for all registered actions matching StateZero model schema format"""
14
+ actions_schema = {}
15
+ all_app_configs = list(apps.get_app_configs())
16
+
17
+ for action_name, action_config in action_registry.get_actions().items():
18
+ func = action_config.get("function")
19
+ if not func:
20
+ raise ValueError(
21
+ f"Action '{action_name}' is missing a function and cannot be processed."
22
+ )
23
+
24
+ func_path = os.path.abspath(func.__code__.co_filename)
25
+ found_app = None
26
+
27
+ for app_config in all_app_configs:
28
+ app_path = os.path.abspath(app_config.path)
29
+ if func_path.startswith(app_path + os.sep):
30
+ if not found_app or len(app_path) > len(
31
+ os.path.abspath(found_app.path)
32
+ ):
33
+ found_app = app_config
34
+
35
+ if not found_app:
36
+ raise LookupError(
37
+ f"Action '{action_name}' from file '{func_path}' does not belong to any "
38
+ f"installed Django app. Please ensure the parent app is in INSTALLED_APPS."
39
+ )
40
+
41
+ app_name = found_app.label
42
+ docstring = action_config.get("docstring")
43
+
44
+ input_properties, input_relationships = DjangoActionSchemaGenerator._get_serializer_schema(
45
+ action_config["serializer"]
46
+ )
47
+ response_properties, response_relationships = DjangoActionSchemaGenerator._get_serializer_schema(
48
+ action_config["response_serializer"]
49
+ )
50
+
51
+ # Serialize display metadata if present
52
+ display_data = None
53
+ if action_config.get("display"):
54
+ display_data = DjangoActionSchemaGenerator._serialize_display_metadata(
55
+ action_config["display"]
56
+ )
57
+
58
+ schema_info = {
59
+ "action_name": action_name,
60
+ "app": app_name,
61
+ "title": action_name.replace("_", " ").title(),
62
+ "docstring": docstring,
63
+ "class_name": "".join(
64
+ word.capitalize() for word in action_name.split("_")
65
+ ),
66
+ "input_properties": input_properties,
67
+ "response_properties": response_properties,
68
+ "relationships": {**input_relationships, **response_relationships},
69
+ "permissions": [
70
+ perm.__name__ for perm in action_config.get("permissions", [])
71
+ ],
72
+ "display": display_data,
73
+ }
74
+ actions_schema[action_name] = schema_info
75
+
76
+ return Response({"actions": actions_schema, "count": len(actions_schema)})
77
+
78
+ @staticmethod
79
+ def _get_serializer_schema(serializer_class):
80
+ if not serializer_class:
81
+ return {}, {}
82
+ try:
83
+ serializer_instance = serializer_class()
84
+ properties = {}
85
+ relationships = {}
86
+ for field_name, field in serializer_instance.fields.items():
87
+ relation_info = DjangoActionSchemaGenerator._get_relation_info(field)
88
+ if relation_info:
89
+ relationships[field_name] = relation_info
90
+
91
+ field_info = {
92
+ "type": DjangoActionSchemaGenerator._get_field_type(field),
93
+ "title": getattr(field, "label")
94
+ or field_name.replace("_", " ").title(),
95
+ "required": field.required,
96
+ "description": getattr(field, "help_text", None),
97
+ "nullable": getattr(field, "allow_null", False),
98
+ "format": DjangoActionSchemaGenerator._get_field_format(field),
99
+ "max_length": getattr(field, "max_length", None),
100
+ "choices": DjangoActionSchemaGenerator._get_field_choices(field),
101
+ "default": DjangoActionSchemaGenerator._get_field_default(field),
102
+ "validators": [],
103
+ "max_digits": getattr(field, "max_digits", None),
104
+ "decimal_places": getattr(field, "decimal_places", None),
105
+ "read_only": field.read_only,
106
+ "ref": None,
107
+ }
108
+ if hasattr(field, "max_value") and field.max_value is not None:
109
+ field_info["max_value"] = field.max_value
110
+ if hasattr(field, "min_value") and field.min_value is not None:
111
+ field_info["min_value"] = field.min_value
112
+ if hasattr(field, "min_length") and field.min_length is not None:
113
+ field_info["min_length"] = field.min_length
114
+ properties[field_name] = field_info
115
+ return properties, relationships
116
+ except Exception as e:
117
+ print(f"Could not inspect serializer: {str(e)}")
118
+ raise e
119
+
120
+ @staticmethod
121
+ def _get_field_type(field):
122
+ if isinstance(field, serializers.PrimaryKeyRelatedField):
123
+ pk_field = field.queryset.model._meta.pk
124
+ if isinstance(pk_field, (models.UUIDField, models.CharField)):
125
+ return "string"
126
+ return "integer"
127
+
128
+ # Handle nested serializers (many=True creates a ListSerializer)
129
+ if isinstance(field, serializers.ListSerializer):
130
+ return "array"
131
+
132
+ # Handle nested serializers (single nested serializer)
133
+ if isinstance(field, serializers.Serializer):
134
+ return "object"
135
+
136
+ type_mapping = {
137
+ fields.BooleanField: "boolean",
138
+ fields.CharField: "string",
139
+ fields.EmailField: "string",
140
+ fields.URLField: "string",
141
+ fields.UUIDField: "string",
142
+ fields.IntegerField: "integer",
143
+ fields.FloatField: "number",
144
+ fields.DecimalField: "string",
145
+ fields.DateField: "string",
146
+ fields.DateTimeField: "string",
147
+ fields.TimeField: "string",
148
+ fields.JSONField: "object",
149
+ fields.DictField: "object",
150
+ fields.ListField: "array",
151
+ serializers.ManyRelatedField: "array",
152
+ }
153
+ return type_mapping.get(type(field), "string")
154
+
155
+ @staticmethod
156
+ def _get_field_format(field):
157
+ format_mapping = {
158
+ fields.EmailField: "email",
159
+ fields.URLField: "uri",
160
+ fields.UUIDField: "uuid",
161
+ fields.DateField: "date",
162
+ fields.DateTimeField: "date-time",
163
+ fields.TimeField: "time",
164
+ serializers.ManyRelatedField: "many-to-many",
165
+ serializers.PrimaryKeyRelatedField: "foreign-key",
166
+ }
167
+ return format_mapping.get(type(field))
168
+
169
+ @staticmethod
170
+ def _get_field_choices(field):
171
+ if hasattr(field, "choices") and field.choices:
172
+ choices = field.choices
173
+
174
+ # Handle dict format: {'low': 'Low', 'high': 'High'}
175
+ if isinstance(choices, dict):
176
+ return choices
177
+
178
+ # Handle list/tuple format: [('low', 'Low'), ('high', 'High')]
179
+ elif isinstance(choices, (list, tuple)):
180
+ try:
181
+ # Return dict with value->label mapping (same as model)
182
+ return {str(choice[0]): choice[1] for choice in choices}
183
+ except (IndexError, TypeError) as e:
184
+ raise ValueError(
185
+ f"Invalid choice format for field '{field}'. Expected list of tuples "
186
+ f"like [('value', 'display')], but got: {choices}. Error: {e}"
187
+ )
188
+
189
+ # Handle unexpected format
190
+ else:
191
+ raise ValueError(
192
+ f"Unsupported choice format for field '{field}'. Expected dict or list of tuples, "
193
+ f"but got {type(choices)}: {choices}"
194
+ )
195
+
196
+ return None
197
+
198
+ @staticmethod
199
+ def _get_field_default(field):
200
+ if hasattr(field, "default"):
201
+ default = field.default
202
+ if default is fields.empty:
203
+ return None
204
+ if callable(default):
205
+ return None
206
+ return default
207
+ return None
208
+
209
+ @staticmethod
210
+ def _get_relation_info(field):
211
+ relation_type = DjangoActionSchemaGenerator._get_field_format(field)
212
+ if not relation_type in ["foreign-key", "one-to-one", "many-to-many"]:
213
+ return None
214
+
215
+ if isinstance(field, serializers.PrimaryKeyRelatedField):
216
+ model = field.queryset.model
217
+ return {
218
+ "type": relation_type,
219
+ "model": f"{model._meta.app_label}.{model._meta.model_name}",
220
+ "class_name": model.__name__,
221
+ "primary_key_field": model._meta.pk.name,
222
+ }
223
+ if isinstance(field, serializers.ManyRelatedField):
224
+ model = field.child_relation.queryset.model
225
+ return {
226
+ "type": relation_type,
227
+ "model": f"{model._meta.app_label}.{model._meta.model_name}",
228
+ "class_name": model.__name__,
229
+ "primary_key_field": model._meta.pk.name,
230
+ }
231
+ return None
232
+
233
+ @staticmethod
234
+ def _serialize_display_metadata(display):
235
+ """Convert DisplayMetadata dataclass to dict for JSON serialization"""
236
+ from dataclasses import asdict, is_dataclass
237
+
238
+ if display is None:
239
+ return None
240
+
241
+ if is_dataclass(display):
242
+ return asdict(display)
243
+
244
+ # If it's already a dict, return as-is
245
+ if isinstance(display, dict):
246
+ return display
247
+
248
+ return None
@@ -0,0 +1,137 @@
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 (
34
+ len(config_file_prefix) < 1
35
+ ):
36
+ raise ValueError(
37
+ 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'"
38
+ )
39
+ else:
40
+ config_file_prefix = "crud"
41
+ for app_config_instance in apps.get_app_configs():
42
+ module_name = f"{app_config_instance.name}.{config_file_prefix}"
43
+ try:
44
+ importlib.import_module(module_name)
45
+ logger.debug(
46
+ f"Imported {config_file_prefix} module from {app_config_instance.name}"
47
+ )
48
+ except ModuleNotFoundError:
49
+ pass
50
+
51
+ # Import actions modules which register actions in the action registry.
52
+ from statezero.core.actions import action_registry
53
+
54
+ for app_config_instance in apps.get_app_configs():
55
+ actions_module_name = f"{app_config_instance.name}.actions"
56
+ try:
57
+ importlib.import_module(actions_module_name)
58
+ logger.debug(f"Imported actions module from {app_config_instance.name}")
59
+ except ModuleNotFoundError:
60
+ pass
61
+
62
+ # Once all the apps are imported, initialize StateZero and provide the registry to the event bus.
63
+ config.initialize()
64
+ config.validate_exposed_models(
65
+ registry
66
+ ) # Raises an exception if a non StateZero model is implicitly exposed
67
+ config.event_bus.set_registry(registry)
68
+
69
+ # Print the list of published models and actions to confirm StateZero is running.
70
+ try:
71
+ published_models = []
72
+ for model in registry._models_config.keys():
73
+ # Use the ORM provider's get_model_name to get the namespaced model name.
74
+ model_name = model.__name__
75
+ published_models.append(model_name)
76
+
77
+ # Get registered actions
78
+ registered_actions = list(action_registry.get_actions().keys())
79
+
80
+ # Build base message for models
81
+ if published_models:
82
+ models_message = (
83
+ "[bold green]StateZero is exposing models:[/bold green] [bold yellow]"
84
+ + ", ".join(published_models)
85
+ + "[/bold yellow]"
86
+ )
87
+ else:
88
+ models_message = "[bold yellow]StateZero is running but no models are registered.[/bold yellow]"
89
+
90
+ # Build message for actions (limit to first 10 to avoid cluttering console)
91
+ if registered_actions:
92
+ displayed_actions = registered_actions[:10]
93
+ actions_message = (
94
+ "\n[bold green]StateZero is exposing actions:[/bold green] [bold cyan]"
95
+ + ", ".join(displayed_actions)
96
+ )
97
+ if len(registered_actions) > 10:
98
+ actions_message += (
99
+ f" [dim](and {len(registered_actions) - 10} more)[/dim]"
100
+ )
101
+ actions_message += "[/bold cyan]"
102
+ else:
103
+ actions_message = (
104
+ "\n[bold yellow]No actions are registered.[/bold yellow]"
105
+ )
106
+
107
+ base_message = models_message + actions_message
108
+
109
+ # Append the npm command instruction only in debug mode.
110
+ if (published_models or registered_actions) and settings.DEBUG:
111
+ npm_message = (
112
+ "\n[bold blue]Next step:[/bold blue] Run [italic]npm run sync[/italic] in your frontend project directory "
113
+ "to generate or update the client-side code corresponding to these models and actions. "
114
+ "Note: This command should only be executed in a development environment."
115
+ )
116
+ message = base_message + npm_message
117
+ else:
118
+ message = base_message
119
+
120
+ # Use Rich Panel for a boxed display if Rich is available.
121
+ if console:
122
+ final_message = Panel(message, expand=False)
123
+ console.print(final_message)
124
+ else:
125
+ # Fallback to simple demarcation lines if Rich isn't available.
126
+ demarcation = "\n" + "-" * 50 + "\n"
127
+ final_message = demarcation + message + demarcation
128
+ logger.info(final_message)
129
+ except Exception as e:
130
+ error_message = f"[bold red]Error retrieving published models and actions: {e}[/bold red]"
131
+ if console:
132
+ final_message = Panel(error_message, expand=False)
133
+ console.print(final_message)
134
+ else:
135
+ demarcation = "\n" + "-" * 50 + "\n"
136
+ final_message = demarcation + error_message + demarcation
137
+ 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 StateZero'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