django-cfg 1.5.20__py3-none-any.whl → 1.5.31__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.
Potentially problematic release.
This version of django-cfg might be problematic. Click here for more details.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/logging.py +90 -14
- django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +47 -43
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +41 -29
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +1 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +22 -36
- django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
- django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/demo.py +1 -1
- django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/test_publish.py +4 -4
- django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
- django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
- django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
- django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
- django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
- django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
- django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
- django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +62 -55
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +216 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
- django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
- django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
- django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
- django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
- django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
- django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
- django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
- django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
- django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
- django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
- django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
- django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
- django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
- django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
- django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +55 -8
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- django_cfg/core/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/management/commands/pool_status.py +153 -0
- django_cfg/middleware/pool_cleanup.py +261 -0
- django_cfg/models/api/grpc/config.py +2 -2
- django_cfg/models/infrastructure/database/config.py +16 -0
- django_cfg/models/infrastructure/database/converters.py +2 -0
- django_cfg/modules/django_admin/utils/html/composition.py +57 -13
- django_cfg/modules/django_admin/utils/html_builder.py +1 -0
- django_cfg/modules/django_client/core/generator/typescript/files_generator.py +12 -0
- django_cfg/modules/django_client/core/generator/typescript/generator.py +8 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +22 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/main_index.ts.jinja +4 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/utils/validation-events.ts.jinja +133 -0
- django_cfg/modules/django_client/core/groups/manager.py +25 -18
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_client/urls.py +38 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/modules/django_twilio/email_otp.py +3 -1
- django_cfg/modules/django_twilio/sms.py +3 -1
- django_cfg/modules/django_twilio/unified.py +6 -2
- django_cfg/modules/django_twilio/whatsapp.py +3 -1
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +17 -57
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/METADATA +75 -5
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/RECORD +97 -68
- django_cfg/apps/integrations/grpc/centrifugo/bridge.py +0 -277
- /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/__init__.py +0 -0
- /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/config.py +0 -0
- /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/transformers.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/centrifugo.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
- /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.31.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connection Pool Cleanup Middleware.
|
|
3
|
+
|
|
4
|
+
Ensures database connections are properly returned to the pool after each
|
|
5
|
+
request, including error cases. Prevents connection leaks in production.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
Add to MIDDLEWARE in settings.py:
|
|
9
|
+
MIDDLEWARE = [
|
|
10
|
+
# ... other middleware ...
|
|
11
|
+
'django_cfg.middleware.pool_cleanup.ConnectionPoolCleanupMiddleware',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
Note:
|
|
15
|
+
This middleware should be placed AFTER all other middleware to ensure
|
|
16
|
+
cleanup happens regardless of what other middleware does.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from django.db import connection, connections, transaction
|
|
24
|
+
from django.http import HttpRequest, HttpResponse
|
|
25
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger('django_cfg.middleware.pool_cleanup')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectionPoolCleanupMiddleware(MiddlewareMixin):
|
|
31
|
+
"""
|
|
32
|
+
Middleware to ensure database connections are cleaned up after each request.
|
|
33
|
+
|
|
34
|
+
Features:
|
|
35
|
+
- Closes connections after successful responses
|
|
36
|
+
- Closes connections after exceptions
|
|
37
|
+
- Rolls back uncommitted transactions on errors
|
|
38
|
+
- Works with both sync and async views
|
|
39
|
+
- Minimal performance overhead (<1ms)
|
|
40
|
+
|
|
41
|
+
This middleware is critical when using connection pooling with
|
|
42
|
+
ATOMIC_REQUESTS=False, as it ensures connections don't leak.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, get_response: Callable):
|
|
46
|
+
"""
|
|
47
|
+
Initialize middleware.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
get_response: The next middleware or view to call
|
|
51
|
+
"""
|
|
52
|
+
super().__init__(get_response)
|
|
53
|
+
self.get_response = get_response
|
|
54
|
+
self._enable_logging = False # Set to True for debug logging
|
|
55
|
+
|
|
56
|
+
def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
57
|
+
"""
|
|
58
|
+
Process request through middleware chain.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
request: The HTTP request
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
HttpResponse from view or next middleware
|
|
65
|
+
"""
|
|
66
|
+
start_time = time.time() if self._enable_logging else None
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
response = self.get_response(request)
|
|
70
|
+
return response
|
|
71
|
+
finally:
|
|
72
|
+
# Cleanup happens in finally block to ensure it runs
|
|
73
|
+
self._cleanup_connections(request, rollback_on_error=False)
|
|
74
|
+
|
|
75
|
+
if self._enable_logging and start_time:
|
|
76
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
77
|
+
logger.debug(f"Pool cleanup overhead: {duration_ms:.2f}ms")
|
|
78
|
+
|
|
79
|
+
def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse:
|
|
80
|
+
"""
|
|
81
|
+
Process response before returning to client.
|
|
82
|
+
|
|
83
|
+
Called after view execution for successful responses.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
request: The HTTP request
|
|
87
|
+
response: The HTTP response from view
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The HTTP response (unchanged)
|
|
91
|
+
"""
|
|
92
|
+
# Cleanup handled in __call__ finally block
|
|
93
|
+
return response
|
|
94
|
+
|
|
95
|
+
def process_exception(self, request: HttpRequest, exception: Exception) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Process exception raised during request handling.
|
|
98
|
+
|
|
99
|
+
Called when view raises an exception. Rolls back any pending
|
|
100
|
+
transactions and closes connections.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
request: The HTTP request
|
|
104
|
+
exception: The exception that was raised
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
None (allows exception to propagate)
|
|
108
|
+
"""
|
|
109
|
+
logger.warning(
|
|
110
|
+
f"Exception in request, rolling back transactions: {exception.__class__.__name__}",
|
|
111
|
+
extra={
|
|
112
|
+
'path': request.path,
|
|
113
|
+
'method': request.method,
|
|
114
|
+
'exception': str(exception),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Rollback all open transactions
|
|
119
|
+
self._cleanup_connections(request, rollback_on_error=True)
|
|
120
|
+
|
|
121
|
+
# Return None to allow exception to propagate
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _cleanup_connections(self, request: HttpRequest, rollback_on_error: bool = False) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Clean up all database connections.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
request: The HTTP request
|
|
130
|
+
rollback_on_error: If True, rollback uncommitted transactions
|
|
131
|
+
"""
|
|
132
|
+
for db_alias in connections:
|
|
133
|
+
try:
|
|
134
|
+
conn = connections[db_alias]
|
|
135
|
+
|
|
136
|
+
# Check if connection is open
|
|
137
|
+
if conn.connection is None:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Rollback uncommitted transactions if requested
|
|
141
|
+
if rollback_on_error:
|
|
142
|
+
self._rollback_transaction(conn, db_alias)
|
|
143
|
+
|
|
144
|
+
# Close the connection to return it to pool
|
|
145
|
+
# Django's close() is safe to call multiple times
|
|
146
|
+
conn.close()
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.error(
|
|
150
|
+
f"Error cleaning up connection '{db_alias}': {e}",
|
|
151
|
+
exc_info=True,
|
|
152
|
+
extra={
|
|
153
|
+
'database': db_alias,
|
|
154
|
+
'path': request.path,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _rollback_transaction(self, conn, db_alias: str) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Rollback any uncommitted transaction on a connection.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
conn: Database connection
|
|
164
|
+
db_alias: Database alias for logging
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
# Check if there's an open transaction
|
|
168
|
+
if conn.in_atomic_block:
|
|
169
|
+
logger.debug(f"Rolling back transaction for database '{db_alias}'")
|
|
170
|
+
conn.rollback()
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.error(
|
|
173
|
+
f"Error rolling back transaction for '{db_alias}': {e}",
|
|
174
|
+
exc_info=True
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AsyncConnectionPoolCleanupMiddleware:
|
|
179
|
+
"""
|
|
180
|
+
Async version of ConnectionPoolCleanupMiddleware.
|
|
181
|
+
|
|
182
|
+
Use this middleware in ASGI deployments for better async compatibility.
|
|
183
|
+
|
|
184
|
+
Usage:
|
|
185
|
+
MIDDLEWARE = [
|
|
186
|
+
'django_cfg.middleware.pool_cleanup.AsyncConnectionPoolCleanupMiddleware',
|
|
187
|
+
]
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, get_response: Callable):
|
|
191
|
+
"""
|
|
192
|
+
Initialize async middleware.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
get_response: The next middleware or view to call
|
|
196
|
+
"""
|
|
197
|
+
self.get_response = get_response
|
|
198
|
+
self._enable_logging = False
|
|
199
|
+
|
|
200
|
+
async def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
201
|
+
"""
|
|
202
|
+
Process request through middleware chain (async).
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
request: The HTTP request
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
HttpResponse from view or next middleware
|
|
209
|
+
"""
|
|
210
|
+
start_time = time.time() if self._enable_logging else None
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
response = await self.get_response(request)
|
|
214
|
+
return response
|
|
215
|
+
except Exception as e:
|
|
216
|
+
# Rollback on exception
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"Exception in async request, rolling back: {e.__class__.__name__}",
|
|
219
|
+
extra={'path': request.path, 'exception': str(e)}
|
|
220
|
+
)
|
|
221
|
+
self._cleanup_connections(request, rollback_on_error=True)
|
|
222
|
+
raise
|
|
223
|
+
finally:
|
|
224
|
+
# Always cleanup connections
|
|
225
|
+
self._cleanup_connections(request, rollback_on_error=False)
|
|
226
|
+
|
|
227
|
+
if self._enable_logging and start_time:
|
|
228
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
229
|
+
logger.debug(f"Async pool cleanup overhead: {duration_ms:.2f}ms")
|
|
230
|
+
|
|
231
|
+
def _cleanup_connections(self, request: HttpRequest, rollback_on_error: bool = False) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Clean up all database connections (sync code in async middleware).
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
request: The HTTP request
|
|
237
|
+
rollback_on_error: If True, rollback uncommitted transactions
|
|
238
|
+
"""
|
|
239
|
+
for db_alias in connections:
|
|
240
|
+
try:
|
|
241
|
+
conn = connections[db_alias]
|
|
242
|
+
|
|
243
|
+
if conn.connection is None:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if rollback_on_error and conn.in_atomic_block:
|
|
247
|
+
logger.debug(f"Rolling back async transaction for '{db_alias}'")
|
|
248
|
+
conn.rollback()
|
|
249
|
+
|
|
250
|
+
conn.close()
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.error(
|
|
254
|
+
f"Error cleaning up async connection '{db_alias}': {e}",
|
|
255
|
+
exc_info=True,
|
|
256
|
+
extra={'database': db_alias, 'path': request.path}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Default export - use sync middleware
|
|
261
|
+
__all__ = ['ConnectionPoolCleanupMiddleware', 'AsyncConnectionPoolCleanupMiddleware']
|
|
@@ -370,9 +370,9 @@ class GRPCConfig(BaseConfig):
|
|
|
370
370
|
description="Proto generation configuration (optional, use flatten fields above for common settings)",
|
|
371
371
|
)
|
|
372
372
|
|
|
373
|
-
handlers_hook: str = Field(
|
|
373
|
+
handlers_hook: str | List[str] = Field(
|
|
374
374
|
default="",
|
|
375
|
-
description="Import path to grpc_handlers function (optional, e.g., '{ROOT_URLCONF}.grpc_handlers')",
|
|
375
|
+
description="Import path(s) to grpc_handlers function (optional, e.g., '{ROOT_URLCONF}.grpc_handlers' or list of paths)",
|
|
376
376
|
)
|
|
377
377
|
|
|
378
378
|
auto_register_apps: bool = Field(
|
|
@@ -81,6 +81,18 @@ class DatabaseConfig(BaseModel):
|
|
|
81
81
|
description="Additional database-specific options",
|
|
82
82
|
)
|
|
83
83
|
|
|
84
|
+
# Connection pooling options (CRITICAL for preventing connection exhaustion)
|
|
85
|
+
conn_max_age: int = Field(
|
|
86
|
+
default=0, # 0 disables persistent connections when using connection pooling
|
|
87
|
+
description="Maximum age of database connections in seconds. Must be 0 when using connection pooling.",
|
|
88
|
+
ge=0,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
conn_health_checks: bool = Field(
|
|
92
|
+
default=True,
|
|
93
|
+
description="Enable database connection health checks",
|
|
94
|
+
)
|
|
95
|
+
|
|
84
96
|
# Database routing configuration
|
|
85
97
|
apps: List[str] = Field(
|
|
86
98
|
default_factory=list,
|
|
@@ -223,6 +235,8 @@ class DatabaseConfig(BaseModel):
|
|
|
223
235
|
apps: Optional[List[str]] = None,
|
|
224
236
|
operations: Optional[List[Literal["read", "write", "migrate"]]] = None,
|
|
225
237
|
routing_description: str = "",
|
|
238
|
+
conn_max_age: int = 0, # 0 disables persistent connections when using connection pooling
|
|
239
|
+
conn_health_checks: bool = True,
|
|
226
240
|
**kwargs
|
|
227
241
|
) -> "DatabaseConfig":
|
|
228
242
|
"""
|
|
@@ -254,6 +268,8 @@ class DatabaseConfig(BaseModel):
|
|
|
254
268
|
apps=apps or [],
|
|
255
269
|
operations=operations or ["read", "write", "migrate"],
|
|
256
270
|
routing_description=routing_description,
|
|
271
|
+
conn_max_age=conn_max_age,
|
|
272
|
+
conn_health_checks=conn_health_checks,
|
|
257
273
|
**kwargs
|
|
258
274
|
)
|
|
259
275
|
|
|
@@ -25,6 +25,8 @@ def to_django_config(config: "DatabaseConfig") -> Dict[str, Any]: # type: ignor
|
|
|
25
25
|
django_config = {
|
|
26
26
|
"ENGINE": config.engine,
|
|
27
27
|
"OPTIONS": {**config.options},
|
|
28
|
+
"CONN_MAX_AGE": config.conn_max_age,
|
|
29
|
+
"CONN_HEALTH_CHECKS": config.conn_health_checks,
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
# Add database-specific options
|
|
@@ -28,7 +28,8 @@ class CompositionElements:
|
|
|
28
28
|
|
|
29
29
|
@staticmethod
|
|
30
30
|
def icon_text(icon_or_text: Union[str, Any], text: Any = None,
|
|
31
|
-
icon_size: str = "xs", separator: str = " "
|
|
31
|
+
icon_size: str = "xs", separator: str = " ",
|
|
32
|
+
color: str = None) -> SafeString:
|
|
32
33
|
"""
|
|
33
34
|
Render icon with text or emoji with text.
|
|
34
35
|
|
|
@@ -37,28 +38,35 @@ class CompositionElements:
|
|
|
37
38
|
text: Optional text to display after icon
|
|
38
39
|
icon_size: Icon size (xs, sm, base, lg, xl)
|
|
39
40
|
separator: Separator between icon and text
|
|
41
|
+
color: Optional color (success, warning, danger, info, secondary, primary)
|
|
40
42
|
|
|
41
43
|
Usage:
|
|
42
44
|
html.icon_text(Icons.EDIT, 5) # Icon with number
|
|
43
|
-
html.icon_text("📝", 5) # Emoji with number
|
|
44
45
|
html.icon_text("Active") # Just text
|
|
46
|
+
html.icon_text(Icons.CHECK, "Yes", color="success") # Icon with color
|
|
45
47
|
"""
|
|
48
|
+
# Color mapping to Tailwind/Unfold classes
|
|
49
|
+
color_classes = {
|
|
50
|
+
'success': 'text-green-600 dark:text-green-400',
|
|
51
|
+
'warning': 'text-yellow-600 dark:text-yellow-400',
|
|
52
|
+
'danger': 'text-red-600 dark:text-red-400',
|
|
53
|
+
'error': 'text-red-600 dark:text-red-400',
|
|
54
|
+
'info': 'text-blue-600 dark:text-blue-400',
|
|
55
|
+
'secondary': 'text-gray-600 dark:text-gray-400',
|
|
56
|
+
'primary': 'text-indigo-600 dark:text-indigo-400',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
color_class = color_classes.get(color, '') if color else ''
|
|
60
|
+
|
|
46
61
|
if text is None:
|
|
47
62
|
# Just text
|
|
63
|
+
if color_class:
|
|
64
|
+
return format_html('<span class="{}">{}</span>', color_class, escape(str(icon_or_text)))
|
|
48
65
|
return format_html('<span>{}</span>', escape(str(icon_or_text)))
|
|
49
66
|
|
|
50
|
-
#
|
|
67
|
+
# Render icon (Material Icon from Icons class)
|
|
51
68
|
icon_str = str(icon_or_text)
|
|
52
|
-
|
|
53
|
-
# Detect if it's emoji by checking for non-ASCII characters
|
|
54
|
-
is_emoji = any(ord(c) > 127 for c in icon_str)
|
|
55
|
-
|
|
56
|
-
if is_emoji or icon_str in ['📝', '💬', '🛒', '👤', '📧', '🔔', '⚙️', '🔧', '📊', '🎯']:
|
|
57
|
-
# Emoji
|
|
58
|
-
icon_html = escape(icon_str)
|
|
59
|
-
else:
|
|
60
|
-
# Material Icon
|
|
61
|
-
icon_html = CompositionElements.icon(icon_str, size=icon_size)
|
|
69
|
+
icon_html = CompositionElements.icon(icon_str, size=icon_size)
|
|
62
70
|
|
|
63
71
|
# DON'T escape SafeString - it's already safe HTML!
|
|
64
72
|
from django.utils.safestring import SafeString
|
|
@@ -67,8 +75,44 @@ class CompositionElements:
|
|
|
67
75
|
else:
|
|
68
76
|
text_html = escape(str(text))
|
|
69
77
|
|
|
78
|
+
# Wrap in span with color class if provided
|
|
79
|
+
if color_class:
|
|
80
|
+
return format_html('<span class="{}">{}{}<span>{}</span></span>',
|
|
81
|
+
color_class, icon_html, separator, text_html)
|
|
82
|
+
|
|
70
83
|
return format_html('{}{}<span>{}</span>', icon_html, separator, text_html)
|
|
71
84
|
|
|
85
|
+
@staticmethod
|
|
86
|
+
def colored_text(text: Any, color: str = None) -> SafeString:
|
|
87
|
+
"""
|
|
88
|
+
Render colored text.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
text: Text to display
|
|
92
|
+
color: Color (success, warning, danger, info, secondary, primary)
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
html.colored_text("Active", "success")
|
|
96
|
+
html.colored_text("5 minutes ago", "warning")
|
|
97
|
+
"""
|
|
98
|
+
# Color mapping to Tailwind/Unfold classes
|
|
99
|
+
color_classes = {
|
|
100
|
+
'success': 'text-green-600 dark:text-green-400',
|
|
101
|
+
'warning': 'text-yellow-600 dark:text-yellow-400',
|
|
102
|
+
'danger': 'text-red-600 dark:text-red-400',
|
|
103
|
+
'error': 'text-red-600 dark:text-red-400',
|
|
104
|
+
'info': 'text-blue-600 dark:text-blue-400',
|
|
105
|
+
'secondary': 'text-gray-600 dark:text-gray-400',
|
|
106
|
+
'primary': 'text-indigo-600 dark:text-indigo-400',
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
color_class = color_classes.get(color, '') if color else ''
|
|
110
|
+
|
|
111
|
+
if color_class:
|
|
112
|
+
return format_html('<span class="{}">{}</span>', color_class, escape(str(text)))
|
|
113
|
+
|
|
114
|
+
return format_html('<span>{}</span>', escape(str(text)))
|
|
115
|
+
|
|
72
116
|
@staticmethod
|
|
73
117
|
def inline(*items, separator: str = " | ",
|
|
74
118
|
size: str = "small", css_class: str = "") -> SafeString:
|
|
@@ -45,6 +45,7 @@ class HtmlBuilder:
|
|
|
45
45
|
# === CompositionElements ===
|
|
46
46
|
inline = staticmethod(CompositionElements.inline)
|
|
47
47
|
icon_text = staticmethod(CompositionElements.icon_text)
|
|
48
|
+
colored_text = staticmethod(CompositionElements.colored_text)
|
|
48
49
|
header = staticmethod(CompositionElements.header)
|
|
49
50
|
|
|
50
51
|
# === FormattingElements ===
|
|
@@ -143,6 +143,18 @@ class FilesGenerator:
|
|
|
143
143
|
description="Retry utilities with p-retry",
|
|
144
144
|
)
|
|
145
145
|
|
|
146
|
+
def generate_validation_events_file(self):
|
|
147
|
+
"""Generate validation-events.ts with browser CustomEvent integration."""
|
|
148
|
+
|
|
149
|
+
template = self.jinja_env.get_template('utils/validation-events.ts.jinja')
|
|
150
|
+
content = template.render()
|
|
151
|
+
|
|
152
|
+
return GeneratedFile(
|
|
153
|
+
path="validation-events.ts",
|
|
154
|
+
content=content,
|
|
155
|
+
description="Zod validation error events for browser integration",
|
|
156
|
+
)
|
|
157
|
+
|
|
146
158
|
def generate_api_instance_file(self):
|
|
147
159
|
"""Generate api-instance.ts with global singleton."""
|
|
148
160
|
|
|
@@ -98,6 +98,10 @@ class TypeScriptGenerator(BaseGenerator):
|
|
|
98
98
|
# Generate retry.ts with p-retry
|
|
99
99
|
files.append(self.files_gen.generate_retry_file())
|
|
100
100
|
|
|
101
|
+
# Generate validation-events.ts (browser CustomEvent for Zod errors)
|
|
102
|
+
if self.generate_zod_schemas:
|
|
103
|
+
files.append(self.files_gen.generate_validation_events_file())
|
|
104
|
+
|
|
101
105
|
# Generate api-instance.ts singleton (needed for fetchers/hooks)
|
|
102
106
|
if self.generate_fetchers:
|
|
103
107
|
files.append(self.files_gen.generate_api_instance_file())
|
|
@@ -146,6 +150,10 @@ class TypeScriptGenerator(BaseGenerator):
|
|
|
146
150
|
# Generate retry.ts with p-retry
|
|
147
151
|
files.append(self.files_gen.generate_retry_file())
|
|
148
152
|
|
|
153
|
+
# Generate validation-events.ts (browser CustomEvent for Zod errors)
|
|
154
|
+
if self.generate_zod_schemas:
|
|
155
|
+
files.append(self.files_gen.generate_validation_events_file())
|
|
156
|
+
|
|
149
157
|
# Generate api-instance.ts singleton (needed for fetchers/hooks)
|
|
150
158
|
if self.generate_fetchers:
|
|
151
159
|
files.append(self.files_gen.generate_api_instance_file())
|
django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja
CHANGED
|
@@ -43,6 +43,28 @@ export async function {{ func_name }}(
|
|
|
43
43
|
|
|
44
44
|
consola.error('Response data:', response);
|
|
45
45
|
|
|
46
|
+
// Dispatch browser CustomEvent (only if window is defined)
|
|
47
|
+
if (typeof window !== 'undefined' && error instanceof Error && 'issues' in error) {
|
|
48
|
+
try {
|
|
49
|
+
const event = new CustomEvent('zod-validation-error', {
|
|
50
|
+
detail: {
|
|
51
|
+
operation: '{{ func_name }}',
|
|
52
|
+
path: '{{ operation.path }}',
|
|
53
|
+
method: '{{ operation.http_method }}',
|
|
54
|
+
error: error,
|
|
55
|
+
response: response,
|
|
56
|
+
timestamp: new Date(),
|
|
57
|
+
},
|
|
58
|
+
bubbles: true,
|
|
59
|
+
cancelable: false,
|
|
60
|
+
});
|
|
61
|
+
window.dispatchEvent(event);
|
|
62
|
+
} catch (eventError) {
|
|
63
|
+
// Silently fail - event dispatch should never crash the app
|
|
64
|
+
consola.warn('Failed to dispatch validation error event:', eventError);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
// Re-throw the error
|
|
47
69
|
throw error;
|
|
48
70
|
}
|
|
@@ -55,6 +55,10 @@ export * as Enums from "./enums";
|
|
|
55
55
|
|
|
56
56
|
// Re-export Zod schemas for runtime validation
|
|
57
57
|
export * as Schemas from "./_utils/schemas";
|
|
58
|
+
|
|
59
|
+
// Re-export Zod validation events for browser integration
|
|
60
|
+
export type { ValidationErrorDetail, ValidationErrorEvent } from "./validation-events";
|
|
61
|
+
export { dispatchValidationError, onValidationError, formatZodError } from "./validation-events";
|
|
58
62
|
{% endif %}
|
|
59
63
|
{% if generate_fetchers %}
|
|
60
64
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod Validation Events - Browser CustomEvent integration
|
|
3
|
+
*
|
|
4
|
+
* Dispatches browser CustomEvents when Zod validation fails, allowing
|
|
5
|
+
* React/frontend apps to listen and handle validation errors globally.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // In your React app
|
|
10
|
+
* window.addEventListener('zod-validation-error', (event) => {
|
|
11
|
+
* const { operation, path, method, error, response } = event.detail;
|
|
12
|
+
* console.error(`Validation failed for ${method} ${path}`, error);
|
|
13
|
+
* // Show toast notification, log to Sentry, etc.
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ZodError } from 'zod'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validation error event detail
|
|
22
|
+
*/
|
|
23
|
+
export interface ValidationErrorDetail {
|
|
24
|
+
/** Operation/function name that failed validation */
|
|
25
|
+
operation: string
|
|
26
|
+
/** API endpoint path */
|
|
27
|
+
path: string
|
|
28
|
+
/** HTTP method */
|
|
29
|
+
method: string
|
|
30
|
+
/** Zod validation error */
|
|
31
|
+
error: ZodError
|
|
32
|
+
/** Raw response data that failed validation */
|
|
33
|
+
response: any
|
|
34
|
+
/** Timestamp of the error */
|
|
35
|
+
timestamp: Date
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Custom event type for Zod validation errors
|
|
40
|
+
*/
|
|
41
|
+
export type ValidationErrorEvent = CustomEvent<ValidationErrorDetail>
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Dispatch a Zod validation error event.
|
|
45
|
+
*
|
|
46
|
+
* Only dispatches in browser environment (when window is defined).
|
|
47
|
+
* Safe to call in Node.js/SSR - will be a no-op.
|
|
48
|
+
*
|
|
49
|
+
* @param detail - Validation error details
|
|
50
|
+
*/
|
|
51
|
+
export function dispatchValidationError(detail: ValidationErrorDetail): void {
|
|
52
|
+
// Check if running in browser
|
|
53
|
+
if (typeof window === 'undefined') {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const event = new CustomEvent<ValidationErrorDetail>('zod-validation-error', {
|
|
59
|
+
detail,
|
|
60
|
+
bubbles: true,
|
|
61
|
+
cancelable: false,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
window.dispatchEvent(event)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Silently fail - validation event dispatch should never crash the app
|
|
67
|
+
console.warn('Failed to dispatch validation error event:', error)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add a global listener for Zod validation errors.
|
|
73
|
+
*
|
|
74
|
+
* @param callback - Function to call when validation error occurs
|
|
75
|
+
* @returns Cleanup function to remove the listener
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* ```typescript
|
|
79
|
+
* const cleanup = onValidationError(({ operation, error }) => {
|
|
80
|
+
* toast.error(`Validation failed in ${operation}`);
|
|
81
|
+
* logToSentry(error);
|
|
82
|
+
* });
|
|
83
|
+
*
|
|
84
|
+
* // Later, remove listener
|
|
85
|
+
* cleanup();
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function onValidationError(
|
|
89
|
+
callback: (detail: ValidationErrorDetail) => void
|
|
90
|
+
): () => void {
|
|
91
|
+
if (typeof window === 'undefined') {
|
|
92
|
+
// Return no-op cleanup function for SSR
|
|
93
|
+
return () => {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const handler = (event: Event) => {
|
|
97
|
+
if (event instanceof CustomEvent) {
|
|
98
|
+
callback(event.detail)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
window.addEventListener('zod-validation-error', handler)
|
|
103
|
+
|
|
104
|
+
// Return cleanup function
|
|
105
|
+
return () => {
|
|
106
|
+
window.removeEventListener('zod-validation-error', handler)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Format Zod error for logging/display.
|
|
112
|
+
*
|
|
113
|
+
* @param error - Zod validation error
|
|
114
|
+
* @returns Formatted error message
|
|
115
|
+
*/
|
|
116
|
+
export function formatZodError(error: ZodError): string {
|
|
117
|
+
const issues = error.issues.map((issue, index) => {
|
|
118
|
+
const path = issue.path.join('.') || 'root'
|
|
119
|
+
const parts = [`${index + 1}. ${path}: ${issue.message}`]
|
|
120
|
+
|
|
121
|
+
if ('expected' in issue && issue.expected) {
|
|
122
|
+
parts.push(` Expected: ${issue.expected}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if ('received' in issue && issue.received) {
|
|
126
|
+
parts.push(` Received: ${issue.received}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parts.join('\n')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return issues.join('\n')
|
|
133
|
+
}
|