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.

Files changed (88) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  3. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  4. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  5. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  6. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -37
  7. django_cfg/apps/integrations/centrifugo/views/wrapper.py +25 -23
  8. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  9. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +1 -1
  10. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +21 -36
  11. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  12. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  13. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  14. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  15. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  16. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  17. django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/demo.py +1 -1
  18. django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/test_publish.py +4 -4
  19. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  20. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  21. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  22. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  23. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  24. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  25. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  26. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  27. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  28. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  29. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  30. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  31. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  32. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +62 -55
  33. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  34. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  35. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  36. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  37. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  38. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  39. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  40. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  41. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  42. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  43. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  44. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  45. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  46. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  47. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  48. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  49. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  50. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  51. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  52. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  53. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +55 -8
  54. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  55. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  56. django_cfg/core/base/config_model.py +11 -0
  57. django_cfg/core/builders/middleware_builder.py +5 -0
  58. django_cfg/management/commands/pool_status.py +153 -0
  59. django_cfg/middleware/pool_cleanup.py +261 -0
  60. django_cfg/models/api/grpc/config.py +2 -2
  61. django_cfg/models/infrastructure/database/config.py +16 -0
  62. django_cfg/models/infrastructure/database/converters.py +2 -0
  63. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  64. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  65. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  66. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  67. django_cfg/modules/django_logging/django_logger.py +58 -19
  68. django_cfg/pyproject.toml +3 -3
  69. django_cfg/static/frontend/admin.zip +0 -0
  70. django_cfg/templates/admin/index.html +0 -39
  71. django_cfg/utils/pool_monitor.py +320 -0
  72. django_cfg/utils/smart_defaults.py +233 -7
  73. {django_cfg-1.5.20.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  74. {django_cfg-1.5.20.dist-info → django_cfg-1.5.29.dist-info}/RECORD +87 -59
  75. django_cfg/apps/integrations/grpc/centrifugo/bridge.py +0 -277
  76. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/__init__.py +0 -0
  77. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/config.py +0 -0
  78. /django_cfg/apps/integrations/grpc/{centrifugo → services/centrifugo}/transformers.py +0 -0
  79. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  80. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +0 -0
  81. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/centrifugo.py +0 -0
  82. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  83. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  84. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  85. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  86. {django_cfg-1.5.20.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  87. {django_cfg-1.5.20.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  88. {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 = " ") -> SafeString:
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
- # Check if it's a Material Icon (from Icons class) or emoji
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
- # Find app config by full name
234
- app_config = None
235
- for config in django_apps.get_app_configs():
236
- if config.name == app_name:
237
- app_config = config
238
- break
239
-
240
- if app_config:
241
- # Use actual label from AppConfig
242
- app_label = app_config.label
243
- # Add API prefix from config (e.g., "api/workspaces/" instead of just "workspaces/")
244
- api_prefix = getattr(self.config, 'api_prefix', '').strip('/')
245
- if api_prefix:
246
- url_path = f"{api_prefix}/{app_label}/"
247
- else:
248
- url_path = f"{app_label}/"
249
- urlpatterns.append(f' path("{url_path}", include("{app_name}.urls")),')
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
- logger.debug(f"App '{app_name}' not found in installed apps")
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
- # Remove old
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
- try:
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
- django_handler.setLevel(logging.DEBUG if debug else logging.INFO) # INFO+ in production, DEBUG in dev
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 - WARNING+ always (less noise)
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) # INFO+ in production, DEBUG in dev
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
- """Create logger with modular file handling for django-cfg loggers."""
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
- try:
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
- file_handler.setLevel(logging.DEBUG if debug else logging.INFO) # INFO+ in production, DEBUG in dev
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.20"
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