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.

Files changed (118) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/business/accounts/serializers/profile.py +42 -0
  3. django_cfg/apps/business/support/serializers.py +3 -2
  4. django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
  5. django_cfg/apps/integrations/centrifugo/apps.py +2 -1
  6. django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
  7. django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
  8. django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
  9. django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
  10. django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
  11. django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
  12. django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
  13. django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
  14. django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
  15. django_cfg/apps/integrations/centrifugo/urls.py +8 -0
  16. django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
  17. django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
  18. django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
  19. django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
  20. django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
  21. django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
  22. django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
  23. django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
  24. django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
  25. django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
  26. django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
  27. django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
  28. django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
  29. django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
  30. django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
  31. django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
  32. django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
  33. django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
  34. django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
  35. django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
  36. django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
  37. django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
  38. django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
  39. django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
  40. django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
  41. django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
  42. django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
  43. django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
  44. django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
  45. django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
  46. django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
  47. django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
  48. django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
  49. django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
  50. django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
  51. django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
  52. django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
  53. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
  54. django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
  55. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
  56. django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
  57. django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
  58. django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
  59. django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
  60. django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
  61. django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
  62. django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
  63. django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
  64. django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
  65. django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
  66. django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
  67. django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
  68. django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
  69. django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
  70. django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
  71. django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
  72. django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
  73. django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
  74. django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
  75. django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
  76. django_cfg/apps/integrations/grpc/views/charts.py +1 -1
  77. django_cfg/apps/integrations/grpc/views/config.py +1 -1
  78. django_cfg/apps/system/dashboard/serializers/config.py +95 -9
  79. django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
  80. django_cfg/apps/system/frontend/views.py +87 -6
  81. django_cfg/core/base/config_model.py +11 -0
  82. django_cfg/core/builders/middleware_builder.py +5 -0
  83. django_cfg/core/builders/security_builder.py +1 -0
  84. django_cfg/core/generation/integration_generators/api.py +2 -0
  85. django_cfg/management/commands/pool_status.py +153 -0
  86. django_cfg/middleware/pool_cleanup.py +261 -0
  87. django_cfg/models/api/grpc/config.py +2 -2
  88. django_cfg/models/infrastructure/database/config.py +16 -0
  89. django_cfg/models/infrastructure/database/converters.py +2 -0
  90. django_cfg/modules/django_admin/utils/html/composition.py +57 -13
  91. django_cfg/modules/django_admin/utils/html_builder.py +1 -0
  92. django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
  93. django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
  94. django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
  95. django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
  96. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
  97. django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
  98. django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
  99. django_cfg/modules/django_client/core/groups/manager.py +25 -18
  100. django_cfg/modules/django_client/core/ir/schema.py +15 -1
  101. django_cfg/modules/django_client/core/parser/base.py +12 -0
  102. django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
  103. django_cfg/modules/django_logging/django_logger.py +58 -19
  104. django_cfg/pyproject.toml +3 -3
  105. django_cfg/static/frontend/admin.zip +0 -0
  106. django_cfg/templates/admin/index.html +0 -39
  107. django_cfg/utils/pool_monitor.py +320 -0
  108. django_cfg/utils/smart_defaults.py +233 -7
  109. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
  110. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
  111. /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
  112. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
  113. /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
  114. /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
  115. /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
  116. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
  117. {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
  118. {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 = " ") -> 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 ===
@@ -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({})"
@@ -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'
@@ -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
- return {{ response_schema }}.parse(response)
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
- # 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