django-cfg 1.5.20__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/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 +47 -0
- django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -37
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +25 -23
- 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 +21 -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} +215 -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/groups/manager.py +25 -18
- 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.20.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.29.dist-info}/RECORD +87 -59
- 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.29.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.20.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.20.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 ===
|
|
@@ -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
|
|
@@ -586,6 +586,14 @@ class Command(AdminCommand):
|
|
|
586
586
|
|
|
587
587
|
self.stdout.write(f"\n📦 Copying TypeScript clients to Next.js admin...")
|
|
588
588
|
|
|
589
|
+
# Clean api_output_path before copying (remove old generated files)
|
|
590
|
+
if api_output_path.exists():
|
|
591
|
+
self.stdout.write(f" 🧹 Cleaning API output directory: {api_output_path.relative_to(project_path)}")
|
|
592
|
+
shutil.rmtree(api_output_path)
|
|
593
|
+
|
|
594
|
+
# Recreate directory
|
|
595
|
+
api_output_path.mkdir(parents=True, exist_ok=True)
|
|
596
|
+
|
|
589
597
|
# Copy each group (exclude 'cfg' for Next.js admin)
|
|
590
598
|
copied_count = 0
|
|
591
599
|
for group_dir in ts_source.iterdir():
|
|
@@ -601,11 +609,7 @@ class Command(AdminCommand):
|
|
|
601
609
|
|
|
602
610
|
target_dir = api_output_path / group_name
|
|
603
611
|
|
|
604
|
-
#
|
|
605
|
-
if target_dir.exists():
|
|
606
|
-
shutil.rmtree(target_dir)
|
|
607
|
-
|
|
608
|
-
# Copy new
|
|
612
|
+
# Copy group directory
|
|
609
613
|
shutil.copytree(group_dir, target_dir)
|
|
610
614
|
copied_count += 1
|
|
611
615
|
|
|
@@ -65,6 +65,32 @@ class DjangoLogger(BaseCfgModule):
|
|
|
65
65
|
|
|
66
66
|
_loggers: Dict[str, logging.Logger] = {}
|
|
67
67
|
_configured = False
|
|
68
|
+
_debug_mode: Optional[bool] = None # Cached debug mode to avoid repeated config loads
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _get_debug_mode(cls) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Get debug mode from config (cached).
|
|
74
|
+
|
|
75
|
+
Loads config only once and caches the result to avoid repeated config loads.
|
|
76
|
+
This is a performance optimization - config loading can be expensive.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if debug mode is enabled, False otherwise
|
|
80
|
+
"""
|
|
81
|
+
if cls._debug_mode is not None:
|
|
82
|
+
return cls._debug_mode
|
|
83
|
+
|
|
84
|
+
# Load config once and cache
|
|
85
|
+
try:
|
|
86
|
+
from django_cfg.core.state import get_current_config
|
|
87
|
+
config = get_current_config()
|
|
88
|
+
cls._debug_mode = config.debug if config and hasattr(config, 'debug') else False
|
|
89
|
+
except Exception:
|
|
90
|
+
import os
|
|
91
|
+
cls._debug_mode = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
|
|
92
|
+
|
|
93
|
+
return cls._debug_mode
|
|
68
94
|
|
|
69
95
|
@classmethod
|
|
70
96
|
def get_logger(cls, name: str = "django_cfg") -> logging.Logger:
|
|
@@ -92,13 +118,8 @@ class DjangoLogger(BaseCfgModule):
|
|
|
92
118
|
# print(f" Django logs: {logs_dir / 'django.log'}")
|
|
93
119
|
# print(f" Django-CFG logs: {djangocfg_logs_dir}/")
|
|
94
120
|
|
|
95
|
-
# Get debug mode
|
|
96
|
-
|
|
97
|
-
from django_cfg.core.state import get_current_config
|
|
98
|
-
config = get_current_config()
|
|
99
|
-
debug = config.debug if config else False
|
|
100
|
-
except Exception:
|
|
101
|
-
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
|
|
121
|
+
# Get debug mode (cached - loaded once)
|
|
122
|
+
debug = cls._get_debug_mode()
|
|
102
123
|
|
|
103
124
|
# Create handlers
|
|
104
125
|
try:
|
|
@@ -111,9 +132,13 @@ class DjangoLogger(BaseCfgModule):
|
|
|
111
132
|
backupCount=30, # Keep 30 days of logs
|
|
112
133
|
encoding='utf-8',
|
|
113
134
|
)
|
|
114
|
-
|
|
135
|
+
# File handlers ALWAYS capture DEBUG in dev mode (for complete debugging history)
|
|
136
|
+
# In production, still use INFO+ to save disk space
|
|
137
|
+
django_handler.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
115
138
|
|
|
116
|
-
# Console handler -
|
|
139
|
+
# Console handler - configurable noise level
|
|
140
|
+
# In dev: show DEBUG+ (full visibility)
|
|
141
|
+
# In production: show WARNING+ only (reduce noise)
|
|
117
142
|
console_handler = logging.StreamHandler()
|
|
118
143
|
console_handler.setLevel(logging.DEBUG if debug else logging.WARNING)
|
|
119
144
|
|
|
@@ -123,8 +148,10 @@ class DjangoLogger(BaseCfgModule):
|
|
|
123
148
|
console_handler.setFormatter(formatter)
|
|
124
149
|
|
|
125
150
|
# Configure root logger
|
|
151
|
+
# CRITICAL: Root logger must be DEBUG in dev mode to allow all messages through
|
|
152
|
+
# Handlers will filter based on their own levels, but logger must not block
|
|
126
153
|
root_logger = logging.getLogger()
|
|
127
|
-
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
154
|
+
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
128
155
|
|
|
129
156
|
# Clear existing handlers
|
|
130
157
|
root_logger.handlers.clear()
|
|
@@ -149,9 +176,24 @@ class DjangoLogger(BaseCfgModule):
|
|
|
149
176
|
|
|
150
177
|
@classmethod
|
|
151
178
|
def _create_logger(cls, name: str) -> logging.Logger:
|
|
152
|
-
"""
|
|
179
|
+
"""
|
|
180
|
+
Create logger with modular file handling for django-cfg loggers.
|
|
181
|
+
|
|
182
|
+
In dev/debug mode, loggers inherit DEBUG level from root logger,
|
|
183
|
+
ensuring all log messages reach file handlers regardless of explicit level settings.
|
|
184
|
+
"""
|
|
153
185
|
logger = logging.getLogger(name)
|
|
154
186
|
|
|
187
|
+
# In dev mode, ensure logger doesn't block DEBUG messages
|
|
188
|
+
# Logger inherits from root by default (propagate=True), which is set to DEBUG in dev
|
|
189
|
+
# This is crucial: logger level must be <= handler level, or messages get blocked
|
|
190
|
+
debug = cls._get_debug_mode() # Use cached debug mode
|
|
191
|
+
|
|
192
|
+
# In dev mode, force DEBUG level on logger to ensure complete file logging
|
|
193
|
+
# Handlers will still filter console output (WARNING+), but files get everything (DEBUG+)
|
|
194
|
+
if debug and not logger.level:
|
|
195
|
+
logger.setLevel(logging.DEBUG)
|
|
196
|
+
|
|
155
197
|
# If this is a django-cfg logger, add a specific file handler
|
|
156
198
|
if name.startswith('django_cfg'):
|
|
157
199
|
try:
|
|
@@ -181,15 +223,12 @@ class DjangoLogger(BaseCfgModule):
|
|
|
181
223
|
encoding='utf-8',
|
|
182
224
|
)
|
|
183
225
|
|
|
184
|
-
# Get debug mode
|
|
185
|
-
|
|
186
|
-
from django_cfg.core.state import get_current_config
|
|
187
|
-
config = get_current_config()
|
|
188
|
-
debug = config.debug if config else False
|
|
189
|
-
except Exception:
|
|
190
|
-
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
|
|
226
|
+
# Get debug mode (cached - loaded once)
|
|
227
|
+
debug = cls._get_debug_mode()
|
|
191
228
|
|
|
192
|
-
|
|
229
|
+
# Module file handlers ALWAYS capture DEBUG in dev mode
|
|
230
|
+
# This ensures complete log history for debugging, independent of logger level
|
|
231
|
+
file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
193
232
|
|
|
194
233
|
# Set format
|
|
195
234
|
formatter = logging.Formatter('[%(asctime)s] %(levelname)s in %(name)s [%(filename)s:%(lineno)d]: %(message)s')
|
django_cfg/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "django-cfg"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.29"
|
|
8
8
|
description = "Modern Django framework with type-safe Pydantic v2 configuration, Next.js admin integration, real-time WebSockets, and 8 enterprise apps. Replace settings.py with validated models, 90% less code. Production-ready with AI agents, auto-generated TypeScript clients, and zero-config features."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [ "django", "configuration", "pydantic", "settings", "type-safety", "pydantic-settings", "django-environ", "startup-validation", "ide-autocomplete", "nextjs-admin", "react-admin", "websocket", "centrifugo", "real-time", "typescript-generation", "ai-agents", "enterprise-django", "django-settings", "type-safe-config", "modern-django",]
|
|
@@ -26,13 +26,13 @@ text = "MIT"
|
|
|
26
26
|
local = []
|
|
27
27
|
django52 = [ "django>=5.2,<6.0",]
|
|
28
28
|
ai = [ "pydantic-ai>=1.0.10,<2.0",]
|
|
29
|
-
grpc = [ "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0",]
|
|
29
|
+
grpc = [ "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0", "aiobreaker>=1.2.0,<2.0",]
|
|
30
30
|
centrifugo = [ "cent>=5.0.0,<6.0", "websockets>=13.0,<15.0",]
|
|
31
31
|
dev = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "factory-boy>=3.0", "fakeredis>=2.0", "black>=23.0", "isort>=5.0", "flake8>=5.0", "mypy>=1.0", "pre-commit>=3.0", "build>=1.0", "twine>=4.0", "tomlkit>=0.11", "questionary>=2.0", "rich>=13.0", "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "redis>=5.0",]
|
|
32
32
|
test = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "pytest-xdist>=3.0", "factory-boy>=3.0", "fakeredis>=2.0",]
|
|
33
33
|
docs = [ "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "pymdown-extensions>=10.0",]
|
|
34
34
|
tasks = [ "redis>=5.0",]
|
|
35
|
-
full = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "pytest-xdist>=3.0", "factory-boy>=3.0", "black>=23.0", "isort>=5.0", "flake8>=5.0", "mypy>=1.0", "pre-commit>=3.0", "build>=1.0", "twine>=4.0", "tomlkit>=0.11", "questionary>=2.0", "rich>=13.0", "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "pymdown-extensions>=10.0", "redis>=5.0", "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0", "cent>=5.0.0,<6.0", "websockets>=13.0,<15.0", "django-rq>=3.0", "rq>=1.0", "rq-scheduler>=0.13", "hiredis>=2.0",]
|
|
35
|
+
full = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "pytest-xdist>=3.0", "factory-boy>=3.0", "black>=23.0", "isort>=5.0", "flake8>=5.0", "mypy>=1.0", "pre-commit>=3.0", "build>=1.0", "twine>=4.0", "tomlkit>=0.11", "questionary>=2.0", "rich>=13.0", "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "pymdown-extensions>=10.0", "redis>=5.0", "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0", "aiobreaker>=1.2.0,<2.0", "cent>=5.0.0,<6.0", "websockets>=13.0,<15.0", "django-rq>=3.0", "rq>=1.0", "rq-scheduler>=0.13", "hiredis>=2.0",]
|
|
36
36
|
rq = [ "django-rq>=3.0", "rq>=1.0", "rq-scheduler>=0.13", "redis>=5.0", "hiredis>=2.0",]
|
|
37
37
|
|
|
38
38
|
[project.urls]
|
|
Binary file
|