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
@@ -103,6 +103,10 @@ class IRSchemaObject(BaseModel):
103
103
  default_factory=list,
104
104
  description="Required property names",
105
105
  )
106
+ additional_properties: IRSchemaObject | None = Field(
107
+ None,
108
+ description="Schema for additional properties (for dynamic keys in object, e.g., Record<string, T>)",
109
+ )
106
110
 
107
111
  # ===== Array Items =====
108
112
  items: IRSchemaObject | None = Field(
@@ -322,8 +326,11 @@ class IRSchemaObject(BaseModel):
322
326
  >>> IRSchemaObject(name="file", type="string", format="binary").typescript_type
323
327
  'File | Blob'
324
328
  """
329
+ # Handle $ref (e.g., CentrifugoConfig, User, etc.)
330
+ if self.ref:
331
+ base_type = self.ref
325
332
  # Handle binary type (file uploads)
326
- if self.is_binary:
333
+ elif self.is_binary:
327
334
  base_type = "File | Blob"
328
335
  # Handle array type with proper item type resolution
329
336
  elif self.type == "array":
@@ -336,6 +343,13 @@ class IRSchemaObject(BaseModel):
336
343
  base_type = f"Array<{item_type}>"
337
344
  else:
338
345
  base_type = "Array<any>"
346
+ # Handle object with additionalProperties (e.g., Record<string, DatabaseConfig>)
347
+ elif self.type == "object" and self.additional_properties:
348
+ if self.additional_properties.ref:
349
+ value_type = self.additional_properties.ref
350
+ else:
351
+ value_type = self.additional_properties.typescript_type
352
+ base_type = f"Record<string, {value_type}>"
339
353
  else:
340
354
  type_map = {
341
355
  "string": "string",
@@ -453,6 +453,17 @@ class BaseParser(ABC):
453
453
  else:
454
454
  items = self._parse_schema(f"{name}.items", schema.items)
455
455
 
456
+ # Parse additionalProperties (for Record<string, T> types)
457
+ additional_properties = None
458
+ if schema.additionalProperties and not isinstance(schema.additionalProperties, bool):
459
+ if isinstance(schema.additionalProperties, ReferenceObject):
460
+ # Resolve reference (e.g., Record<string, DatabaseConfig>)
461
+ additional_properties = self._resolve_ref(schema.additionalProperties)
462
+ else:
463
+ additional_properties = self._parse_schema(
464
+ f"{name}.additionalProperties", schema.additionalProperties
465
+ )
466
+
456
467
  # Create IR schema
457
468
  ir_schema = IRSchemaObject(
458
469
  name=name,
@@ -462,6 +473,7 @@ class BaseParser(ABC):
462
473
  nullable=self._detect_nullable(schema),
463
474
  properties=properties,
464
475
  required=schema.required or [],
476
+ additional_properties=additional_properties,
465
477
  items=items,
466
478
  enum=schema.enum,
467
479
  enum_var_names=schema.x_enum_varnames,
@@ -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.14"
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
@@ -179,8 +179,6 @@
179
179
  iframeId = 'nextjs-dashboard-iframe-builtin';
180
180
  } else if (tab === 'nextjs') {
181
181
  iframeId = 'nextjs-dashboard-iframe-nextjs';
182
- } else if (tab === 'docs') {
183
- iframeId = 'nextjs-dashboard-iframe-docs';
184
182
  }
185
183
 
186
184
  const iframe = document.getElementById(iframeId);
@@ -200,8 +198,6 @@
200
198
  iframeId = 'nextjs-dashboard-iframe-builtin';
201
199
  } else if (this.activeTab === 'nextjs') {
202
200
  iframeId = 'nextjs-dashboard-iframe-nextjs';
203
- } else if (this.activeTab === 'docs') {
204
- iframeId = 'nextjs-dashboard-iframe-docs';
205
201
  }
206
202
 
207
203
  const iframe = document.getElementById(iframeId);
@@ -298,17 +294,6 @@
298
294
  <span class="hidden sm:inline">{% nextjs_external_admin_title %}</span>
299
295
  <span class="sm:hidden">Admin</span>
300
296
  </button>
301
-
302
- {% if is_development %}
303
- <button @click="switchTab('docs')"
304
- class="whitespace-nowrap py-4 px-2 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-200"
305
- :class="activeTab === 'docs'
306
- ? 'border-primary-600 text-primary-600 dark:border-primary-500 dark:text-primary-500'
307
- : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:border-gray-700'">
308
- <span class="material-icons text-base">description</span>
309
- <span>Docs</span>
310
- </button>
311
- {% endif %}
312
297
  </nav>
313
298
 
314
299
  <!-- Actions & Version -->
@@ -372,27 +357,6 @@
372
357
  ></iframe>
373
358
  </div>
374
359
  </div>
375
-
376
- <!-- Docs Tab Content (Development Mode Only) -->
377
- {% if is_development %}
378
- <div x-show="activeTab === 'docs'" style="display: none;">
379
- <div class="iframe-container">
380
- <div class="iframe-loading" id="iframe-loading-docs">
381
- <div class="spinner"></div>
382
- <p id="loading-text-docs">Loading documentation...</p>
383
- </div>
384
-
385
- <iframe
386
- id="nextjs-dashboard-iframe-docs"
387
- class="nextjs-dashboard-iframe"
388
- src="{% lib_docs_url %}"
389
- data-original-src="{% lib_docs_url %}"
390
- title="Documentation"
391
- sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation"
392
- ></iframe>
393
- </div>
394
- </div>
395
- {% endif %}
396
360
  </div>
397
361
  {% endcomponent %}
398
362
  {% endblock %}
@@ -807,9 +771,6 @@
807
771
 
808
772
  manager.register('nextjs-dashboard-iframe-builtin', 'iframe-loading-builtin');
809
773
  manager.register('nextjs-dashboard-iframe-nextjs', 'iframe-loading-nextjs');
810
- {% if is_development %}
811
- manager.register('nextjs-dashboard-iframe-docs', 'iframe-loading-docs', { external: true });
812
- {% endif %}
813
774
  // console.log('[Django-CFG] Iframe registration completed');
814
775
  }
815
776
 
@@ -0,0 +1,320 @@
1
+ """
2
+ Connection Pool Monitoring for Django-CFG.
3
+
4
+ Provides utilities to monitor and inspect PostgreSQL connection pool status.
5
+ Works with Django 5.1+ native connection pooling (psycopg3).
6
+
7
+ Usage:
8
+ from django_cfg.utils.pool_monitor import PoolMonitor
9
+
10
+ # Get pool statistics
11
+ monitor = PoolMonitor()
12
+ stats = monitor.get_pool_stats()
13
+
14
+ # Check pool health
15
+ health = monitor.check_pool_health()
16
+
17
+ # Log pool status
18
+ monitor.log_pool_status()
19
+ """
20
+
21
+ import logging
22
+ from typing import Any, Dict, Optional
23
+
24
+ from django.conf import settings
25
+ from django.db import connection
26
+
27
+ from .smart_defaults import _detect_asgi_mode, get_pool_config
28
+
29
+ logger = logging.getLogger('django_cfg.pool_monitor')
30
+
31
+
32
+ class PoolMonitor:
33
+ """
34
+ Monitor and inspect database connection pool.
35
+
36
+ Provides methods to retrieve pool statistics, check health,
37
+ and log pool status for operational visibility.
38
+ """
39
+
40
+ def __init__(self, database_alias: str = 'default'):
41
+ """
42
+ Initialize pool monitor.
43
+
44
+ Args:
45
+ database_alias: Database alias to monitor (default: 'default')
46
+ """
47
+ self.database_alias = database_alias
48
+
49
+ def get_pool_stats(self) -> Optional[Dict[str, Any]]:
50
+ """
51
+ Get current connection pool statistics.
52
+
53
+ Returns:
54
+ Dict with pool statistics:
55
+ {
56
+ 'pool_size': int, # Current pool size
57
+ 'pool_available': int, # Available connections
58
+ 'pool_min_size': int, # Configured minimum size
59
+ 'pool_max_size': int, # Configured maximum size
60
+ 'pool_timeout': int, # Connection timeout
61
+ 'is_asgi': bool, # Deployment mode
62
+ 'environment': str, # Environment name
63
+ 'backend': str, # Database backend
64
+ 'has_pool': bool, # Whether pooling is enabled
65
+ }
66
+
67
+ Returns None if connection pooling is not configured.
68
+
69
+ Example:
70
+ >>> monitor = PoolMonitor()
71
+ >>> stats = monitor.get_pool_stats()
72
+ >>> print(f"Pool size: {stats['pool_size']}/{stats['pool_max_size']}")
73
+ """
74
+ try:
75
+ # Get database configuration
76
+ db_config = settings.DATABASES.get(self.database_alias, {})
77
+ backend = db_config.get('ENGINE', 'unknown')
78
+
79
+ # Check if PostgreSQL with pooling
80
+ if 'postgresql' not in backend:
81
+ logger.debug(f"Database {self.database_alias} is not PostgreSQL, pooling not available")
82
+ return None
83
+
84
+ pool_options = db_config.get('OPTIONS', {}).get('pool', {})
85
+ if not pool_options:
86
+ logger.debug(f"No pool configuration found for database {self.database_alias}")
87
+ return None
88
+
89
+ # Detect environment and mode
90
+ is_asgi = _detect_asgi_mode()
91
+ environment = getattr(settings, 'ENVIRONMENT', 'production')
92
+
93
+ # Get expected pool config
94
+ expected_config = get_pool_config(environment, is_asgi=is_asgi)
95
+
96
+ # Try to get actual pool statistics from psycopg3
97
+ pool_size = None
98
+ pool_available = None
99
+
100
+ try:
101
+ # Access underlying psycopg3 connection pool
102
+ # This requires psycopg3 with connection pooling
103
+ db_conn = connection.connection
104
+ if db_conn and hasattr(db_conn, 'pgconn'):
105
+ pool = getattr(db_conn, 'pool', None)
106
+ if pool:
107
+ # psycopg3 ConnectionPool has these attributes
108
+ pool_size = getattr(pool, 'size', None)
109
+ pool_available = getattr(pool, 'available', None)
110
+ except Exception as e:
111
+ logger.debug(f"Could not retrieve live pool stats: {e}")
112
+
113
+ return {
114
+ 'pool_size': pool_size,
115
+ 'pool_available': pool_available,
116
+ 'pool_min_size': pool_options.get('min_size', expected_config['min_size']),
117
+ 'pool_max_size': pool_options.get('max_size', expected_config['max_size']),
118
+ 'pool_timeout': pool_options.get('timeout', expected_config['timeout']),
119
+ 'max_lifetime': pool_options.get('max_lifetime', 3600),
120
+ 'max_idle': pool_options.get('max_idle', 600),
121
+ 'is_asgi': is_asgi,
122
+ 'environment': environment,
123
+ 'backend': backend,
124
+ 'has_pool': True,
125
+ }
126
+
127
+ except Exception as e:
128
+ logger.error(f"Failed to get pool stats: {e}", exc_info=True)
129
+ return None
130
+
131
+ def check_pool_health(self) -> Dict[str, Any]:
132
+ """
133
+ Check connection pool health status.
134
+
135
+ Returns:
136
+ Dict with health information:
137
+ {
138
+ 'healthy': bool, # Overall health status
139
+ 'status': str, # 'healthy', 'warning', 'critical', 'unavailable'
140
+ 'capacity_percent': float, # Pool capacity usage (0-100)
141
+ 'issues': list, # List of detected issues
142
+ 'recommendations': list, # Recommended actions
143
+ }
144
+
145
+ Health thresholds:
146
+ - < 70% capacity: Healthy
147
+ - 70-90% capacity: Warning
148
+ - > 90% capacity: Critical
149
+
150
+ Example:
151
+ >>> monitor = PoolMonitor()
152
+ >>> health = monitor.check_pool_health()
153
+ >>> if not health['healthy']:
154
+ ... print(f"Issues: {', '.join(health['issues'])}")
155
+ """
156
+ stats = self.get_pool_stats()
157
+
158
+ if not stats or not stats['has_pool']:
159
+ return {
160
+ 'healthy': True, # No pool = no problem (using regular connections)
161
+ 'status': 'unavailable',
162
+ 'capacity_percent': 0.0,
163
+ 'issues': ['Connection pooling not configured'],
164
+ 'recommendations': ['Consider enabling connection pooling for production'],
165
+ }
166
+
167
+ issues = []
168
+ recommendations = []
169
+ healthy = True
170
+ status = 'healthy'
171
+
172
+ # Calculate capacity if live stats available
173
+ capacity_percent = 0.0
174
+ if stats['pool_size'] is not None and stats['pool_max_size']:
175
+ capacity_percent = (stats['pool_size'] / stats['pool_max_size']) * 100
176
+
177
+ # Check capacity thresholds
178
+ if capacity_percent >= 90:
179
+ status = 'critical'
180
+ healthy = False
181
+ issues.append(f"Pool capacity critical: {capacity_percent:.1f}% used")
182
+ recommendations.append("Increase DB_POOL_MAX_SIZE or scale database")
183
+ elif capacity_percent >= 70:
184
+ status = 'warning'
185
+ issues.append(f"Pool capacity high: {capacity_percent:.1f}% used")
186
+ recommendations.append("Monitor pool usage and consider increasing max_size")
187
+
188
+ # Check if min_size is reasonable
189
+ min_size = stats['pool_min_size']
190
+ max_size = stats['pool_max_size']
191
+
192
+ if min_size >= max_size * 0.9:
193
+ issues.append(f"Min size ({min_size}) too close to max size ({max_size})")
194
+ recommendations.append("Reduce DB_POOL_MIN_SIZE for better resource management")
195
+
196
+ # Check timeout
197
+ timeout = stats['pool_timeout']
198
+ if timeout > 30:
199
+ issues.append(f"Pool timeout high: {timeout}s")
200
+ recommendations.append("Long timeouts may indicate slow queries or insufficient pool size")
201
+
202
+ # Check ASGI vs WSGI pool sizing
203
+ is_asgi = stats['is_asgi']
204
+ mode = 'ASGI' if is_asgi else 'WSGI'
205
+
206
+ expected_config = get_pool_config(stats['environment'], is_asgi=is_asgi)
207
+ if max_size < expected_config['max_size'] * 0.5:
208
+ issues.append(f"Pool size low for {mode} mode")
209
+ recommendations.append(f"Consider increasing to {expected_config['max_size']} for {mode}")
210
+
211
+ return {
212
+ 'healthy': healthy and len(issues) == 0,
213
+ 'status': status,
214
+ 'capacity_percent': capacity_percent,
215
+ 'issues': issues,
216
+ 'recommendations': recommendations,
217
+ }
218
+
219
+ def log_pool_status(self, level: str = 'info') -> None:
220
+ """
221
+ Log current pool status to Django logger.
222
+
223
+ Args:
224
+ level: Log level ('debug', 'info', 'warning', 'error')
225
+
226
+ Example:
227
+ >>> monitor = PoolMonitor()
228
+ >>> monitor.log_pool_status(level='info')
229
+ """
230
+ stats = self.get_pool_stats()
231
+
232
+ if not stats:
233
+ logger.debug(f"No pool statistics available for database {self.database_alias}")
234
+ return
235
+
236
+ health = self.check_pool_health()
237
+
238
+ log_func = getattr(logger, level, logger.info)
239
+
240
+ mode = 'ASGI' if stats['is_asgi'] else 'WSGI'
241
+ status_emoji = {
242
+ 'healthy': '✅',
243
+ 'warning': '⚠️',
244
+ 'critical': '🔴',
245
+ 'unavailable': '⚪',
246
+ }.get(health['status'], '❓')
247
+
248
+ log_message = (
249
+ f"[Pool Monitor] {status_emoji} Status: {health['status'].upper()} | "
250
+ f"Mode: {mode} | Env: {stats['environment']} | "
251
+ f"Pool: {stats['pool_size'] or '?'}/{stats['pool_max_size']} | "
252
+ f"Capacity: {health['capacity_percent']:.1f}%"
253
+ )
254
+
255
+ log_func(log_message)
256
+
257
+ if health['issues']:
258
+ for issue in health['issues']:
259
+ logger.warning(f"[Pool Monitor] Issue: {issue}")
260
+
261
+ if health['recommendations']:
262
+ for rec in health['recommendations']:
263
+ logger.info(f"[Pool Monitor] Recommendation: {rec}")
264
+
265
+ def get_pool_info_dict(self) -> Dict[str, Any]:
266
+ """
267
+ Get complete pool information as a dictionary.
268
+
269
+ Combines statistics and health check into a single dict.
270
+ Useful for API responses or structured logging.
271
+
272
+ Returns:
273
+ Dict with complete pool information including stats and health.
274
+
275
+ Example:
276
+ >>> monitor = PoolMonitor()
277
+ >>> info = monitor.get_pool_info_dict()
278
+ >>> print(json.dumps(info, indent=2))
279
+ """
280
+ stats = self.get_pool_stats()
281
+ health = self.check_pool_health()
282
+
283
+ if not stats:
284
+ return {
285
+ 'available': False,
286
+ 'reason': 'Connection pooling not configured',
287
+ }
288
+
289
+ return {
290
+ 'available': True,
291
+ 'statistics': stats,
292
+ 'health': health,
293
+ 'timestamp': self._get_timestamp(),
294
+ }
295
+
296
+ @staticmethod
297
+ def _get_timestamp() -> str:
298
+ """Get current timestamp in ISO format."""
299
+ from datetime import datetime
300
+ return datetime.utcnow().isoformat() + 'Z'
301
+
302
+
303
+ # Convenience function for quick pool status check
304
+ def get_pool_status(database_alias: str = 'default') -> Dict[str, Any]:
305
+ """
306
+ Convenience function to quickly get pool status.
307
+
308
+ Args:
309
+ database_alias: Database alias to check (default: 'default')
310
+
311
+ Returns:
312
+ Dict with pool information (same as PoolMonitor.get_pool_info_dict())
313
+
314
+ Example:
315
+ >>> from django_cfg.utils.pool_monitor import get_pool_status
316
+ >>> status = get_pool_status()
317
+ >>> print(status['health']['status'])
318
+ """
319
+ monitor = PoolMonitor(database_alias=database_alias)
320
+ return monitor.get_pool_info_dict()