django-cfg 1.5.14__py3-none-any.whl → 1.5.29__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/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/support/serializers.py +3 -2
- django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/apps.py +2 -1
- django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
- django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
- django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
- django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
- django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
- django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
- django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
- django_cfg/apps/integrations/centrifugo/urls.py +8 -0
- django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
- django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
- django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
- 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/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
- 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} +67 -54
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
- 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/SERVER_LOGGING.md +164 -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 +261 -13
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/core/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -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/generator.py +26 -0
- django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
- django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
- django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
- django_cfg/modules/django_client/core/groups/manager.py +25 -18
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +0 -39
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.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.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.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 ===
|
|
@@ -298,6 +298,14 @@ class TypeScriptGenerator(BaseGenerator):
|
|
|
298
298
|
queue.append(self.context.schemas[prop.items.ref])
|
|
299
299
|
seen.add(prop.items.ref)
|
|
300
300
|
|
|
301
|
+
# $ref inside additionalProperties (CRITICAL for Record<string, T> patterns!)
|
|
302
|
+
if prop.additional_properties and prop.additional_properties.ref:
|
|
303
|
+
if prop.additional_properties.ref not in seen:
|
|
304
|
+
if prop.additional_properties.ref in self.context.schemas:
|
|
305
|
+
resolved[prop.additional_properties.ref] = self.context.schemas[prop.additional_properties.ref]
|
|
306
|
+
queue.append(self.context.schemas[prop.additional_properties.ref])
|
|
307
|
+
seen.add(prop.additional_properties.ref)
|
|
308
|
+
|
|
301
309
|
# Check array items for $ref at schema level
|
|
302
310
|
if schema.items and schema.items.ref:
|
|
303
311
|
if schema.items.ref not in seen:
|
|
@@ -306,6 +314,14 @@ class TypeScriptGenerator(BaseGenerator):
|
|
|
306
314
|
queue.append(self.context.schemas[schema.items.ref])
|
|
307
315
|
seen.add(schema.items.ref)
|
|
308
316
|
|
|
317
|
+
# Check additionalProperties for $ref at schema level
|
|
318
|
+
if schema.additional_properties and schema.additional_properties.ref:
|
|
319
|
+
if schema.additional_properties.ref not in seen:
|
|
320
|
+
if schema.additional_properties.ref in self.context.schemas:
|
|
321
|
+
resolved[schema.additional_properties.ref] = self.context.schemas[schema.additional_properties.ref]
|
|
322
|
+
queue.append(self.context.schemas[schema.additional_properties.ref])
|
|
323
|
+
seen.add(schema.additional_properties.ref)
|
|
324
|
+
|
|
309
325
|
return resolved
|
|
310
326
|
|
|
311
327
|
# ===== Zod Schemas Generation =====
|
|
@@ -369,11 +385,21 @@ class TypeScriptGenerator(BaseGenerator):
|
|
|
369
385
|
if not self.context.schemas[prop.items.ref].enum:
|
|
370
386
|
refs.add(prop.items.ref)
|
|
371
387
|
|
|
388
|
+
if prop.additional_properties and prop.additional_properties.ref:
|
|
389
|
+
if prop.additional_properties.ref in self.context.schemas:
|
|
390
|
+
if not self.context.schemas[prop.additional_properties.ref].enum:
|
|
391
|
+
refs.add(prop.additional_properties.ref)
|
|
392
|
+
|
|
372
393
|
if schema.items and schema.items.ref:
|
|
373
394
|
if schema.items.ref in self.context.schemas:
|
|
374
395
|
if not self.context.schemas[schema.items.ref].enum:
|
|
375
396
|
refs.add(schema.items.ref)
|
|
376
397
|
|
|
398
|
+
if schema.additional_properties and schema.additional_properties.ref:
|
|
399
|
+
if schema.additional_properties.ref in self.context.schemas:
|
|
400
|
+
if not self.context.schemas[schema.additional_properties.ref].enum:
|
|
401
|
+
refs.add(schema.additional_properties.ref)
|
|
402
|
+
|
|
377
403
|
return refs
|
|
378
404
|
|
|
379
405
|
# ===== Fetchers Generation =====
|
|
@@ -363,6 +363,8 @@ class HooksGenerator:
|
|
|
363
363
|
# Separate queries and mutations & collect schema names
|
|
364
364
|
hooks = []
|
|
365
365
|
schema_names = set()
|
|
366
|
+
has_queries = False
|
|
367
|
+
has_mutations = False
|
|
366
368
|
|
|
367
369
|
for operation in operations:
|
|
368
370
|
# Collect schemas used in this operation (only if they exist as components)
|
|
@@ -378,11 +380,13 @@ class HooksGenerator:
|
|
|
378
380
|
if response and response.schema_name:
|
|
379
381
|
schema_names.add(response.schema_name)
|
|
380
382
|
|
|
381
|
-
# Generate hook
|
|
383
|
+
# Generate hook and track operation types
|
|
382
384
|
if operation.http_method == "GET":
|
|
383
385
|
hooks.append(self.generate_query_hook(operation))
|
|
386
|
+
has_queries = True
|
|
384
387
|
else:
|
|
385
388
|
hooks.append(self.generate_mutation_hook(operation))
|
|
389
|
+
has_mutations = True
|
|
386
390
|
|
|
387
391
|
# Get display name for documentation
|
|
388
392
|
tag_display_name = self.base.tag_to_display_name(tag)
|
|
@@ -398,6 +402,8 @@ class HooksGenerator:
|
|
|
398
402
|
tag_file=tag_file,
|
|
399
403
|
has_schemas=bool(schema_names),
|
|
400
404
|
schema_names=sorted(schema_names),
|
|
405
|
+
has_queries=has_queries,
|
|
406
|
+
has_mutations=has_mutations,
|
|
401
407
|
hooks=hooks
|
|
402
408
|
)
|
|
403
409
|
|
|
@@ -175,8 +175,13 @@ class ModelsGenerator:
|
|
|
175
175
|
# Handle nullable and optional separately
|
|
176
176
|
# - nullable: add | null to type
|
|
177
177
|
# - not required: add ? optional marker
|
|
178
|
+
# Special case: readOnly + nullable fields should be optional
|
|
179
|
+
# (they're always in response but can be null, so from client perspective they're optional)
|
|
178
180
|
if schema.nullable:
|
|
179
181
|
ts_type = f"{ts_type} | null"
|
|
182
|
+
# Make readOnly nullable fields optional
|
|
183
|
+
if schema.read_only:
|
|
184
|
+
is_required = False
|
|
180
185
|
|
|
181
186
|
optional_marker = "" if is_required else "?"
|
|
182
187
|
|
|
@@ -237,6 +237,17 @@ class SchemasGenerator:
|
|
|
237
237
|
if schema.ref:
|
|
238
238
|
# Explicit reference
|
|
239
239
|
return f"{schema.ref}Schema"
|
|
240
|
+
elif schema.additional_properties:
|
|
241
|
+
# Object with additionalProperties (e.g., Record<string, DatabaseConfig>)
|
|
242
|
+
if schema.additional_properties.ref:
|
|
243
|
+
value_type = f"{schema.additional_properties.ref}Schema"
|
|
244
|
+
else:
|
|
245
|
+
value_type = self._map_type_to_zod(schema.additional_properties)
|
|
246
|
+
# If DictField() produces additionalProperties with just string type,
|
|
247
|
+
# use z.any() to allow mixed types (common in Django configs/settings)
|
|
248
|
+
if value_type == "z.string()":
|
|
249
|
+
value_type = "z.any()"
|
|
250
|
+
return f"z.record(z.string(), {value_type})"
|
|
240
251
|
elif schema.properties:
|
|
241
252
|
# Inline object with properties - shouldn't reach here, but use z.object
|
|
242
253
|
return "z.object({})"
|
django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* const users = await getUsers({ page: 1 }, api)
|
|
30
30
|
* ```
|
|
31
31
|
*/
|
|
32
|
+
import { consola } from 'consola'
|
|
32
33
|
{% if has_schemas %}
|
|
33
34
|
{% for schema_name in schema_names %}
|
|
34
35
|
import { {{ schema_name }}Schema, type {{ schema_name }} } from '../schemas/{{ schema_name }}.schema'
|
django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja
CHANGED
|
@@ -17,7 +17,35 @@ export async function {{ func_name }}(
|
|
|
17
17
|
const response = await {{ api_call }}()
|
|
18
18
|
{% endif %}
|
|
19
19
|
{% if response_schema %}
|
|
20
|
-
|
|
20
|
+
try {
|
|
21
|
+
return {{ response_schema }}.parse(response)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
// Zod validation error - log detailed information
|
|
24
|
+
consola.error('❌ Zod Validation Failed');
|
|
25
|
+
consola.box({
|
|
26
|
+
title: '{{ func_name }}',
|
|
27
|
+
message: `Path: {{ operation.path }}\nMethod: {{ operation.http_method }}`,
|
|
28
|
+
style: {
|
|
29
|
+
borderColor: 'red',
|
|
30
|
+
borderStyle: 'rounded'
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (error instanceof Error && 'issues' in error && Array.isArray((error as any).issues)) {
|
|
35
|
+
consola.error('Validation Issues:');
|
|
36
|
+
(error as any).issues.forEach((issue: any, index: number) => {
|
|
37
|
+
consola.error(` ${index + 1}. ${issue.path.join('.') || 'root'}`);
|
|
38
|
+
consola.error(` ├─ Message: ${issue.message}`);
|
|
39
|
+
if (issue.expected) consola.error(` ├─ Expected: ${issue.expected}`);
|
|
40
|
+
if (issue.received) consola.error(` └─ Received: ${issue.received}`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
consola.error('Response data:', response);
|
|
45
|
+
|
|
46
|
+
// Re-throw the error
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
21
49
|
{% else %}
|
|
22
50
|
return response
|
|
23
51
|
{% endif %}
|
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
* await createUser({ name: 'John', email: 'john@example.com' })
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
+
{% if has_queries %}
|
|
17
18
|
import useSWR from 'swr'
|
|
19
|
+
{% endif %}
|
|
20
|
+
{% if has_mutations %}
|
|
18
21
|
import { useSWRConfig } from 'swr'
|
|
22
|
+
{% endif %}
|
|
19
23
|
import * as Fetchers from '../fetchers/{{ tag_file }}'
|
|
20
24
|
import type { API } from '../../index'
|
|
21
25
|
{% if has_schemas %}
|
|
@@ -230,25 +230,32 @@ from django_cfg.apps.urls import urlpatterns
|
|
|
230
230
|
for app_name in apps:
|
|
231
231
|
# Try to include app URLs
|
|
232
232
|
try:
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
233
|
+
# Check if app has urls.py module
|
|
234
|
+
import importlib
|
|
235
|
+
urls_module = f"{app_name}.urls"
|
|
236
|
+
try:
|
|
237
|
+
importlib.import_module(urls_module)
|
|
238
|
+
has_urls = True
|
|
239
|
+
except ImportError:
|
|
240
|
+
has_urls = False
|
|
241
|
+
|
|
242
|
+
if not has_urls:
|
|
243
|
+
logger.debug(f"App '{app_name}' has no urls.py - skipping")
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
# Determine URL path based on whether app has urls.py
|
|
247
|
+
# If app has urls.py, use basename (matches url_integration.py logic)
|
|
248
|
+
# e.g., "apps.web.controls" -> "controls"
|
|
249
|
+
app_basename = app_name.split('.')[-1]
|
|
250
|
+
|
|
251
|
+
# Add API prefix from config (e.g., "api/controls/" instead of just "controls/")
|
|
252
|
+
api_prefix = getattr(self.config, 'api_prefix', '').strip('/')
|
|
253
|
+
if api_prefix:
|
|
254
|
+
url_path = f"{api_prefix}/{app_basename}/"
|
|
250
255
|
else:
|
|
251
|
-
|
|
256
|
+
url_path = f"{app_basename}/"
|
|
257
|
+
|
|
258
|
+
urlpatterns.append(f' path("{url_path}", include("{app_name}.urls")),')
|
|
252
259
|
except Exception as e:
|
|
253
260
|
logger.debug(f"App '{app_name}' skipped: {e}")
|
|
254
261
|
continue
|