django-cfg 1.5.14__py3-none-any.whl → 1.5.29__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-cfg might be problematic. Click here for more details.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/support/serializers.py +3 -2
- django_cfg/apps/integrations/centrifugo/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/apps.py +2 -1
- django_cfg/apps/integrations/centrifugo/codegen/generators/typescript_thin/templates/rpc-client.ts.j2 +151 -12
- django_cfg/apps/integrations/centrifugo/management/commands/generate_centrifugo_clients.py +2 -2
- django_cfg/apps/integrations/centrifugo/services/__init__.py +6 -0
- django_cfg/apps/integrations/centrifugo/services/client/__init__.py +6 -1
- django_cfg/apps/integrations/centrifugo/services/client/client.py +1 -1
- django_cfg/apps/integrations/centrifugo/services/client/direct_client.py +282 -0
- django_cfg/apps/integrations/centrifugo/services/logging.py +47 -0
- django_cfg/apps/integrations/centrifugo/services/publisher.py +371 -0
- django_cfg/apps/integrations/centrifugo/services/token_generator.py +122 -0
- django_cfg/apps/integrations/centrifugo/urls.py +8 -0
- django_cfg/apps/integrations/centrifugo/views/__init__.py +2 -0
- django_cfg/apps/integrations/centrifugo/views/admin_api.py +29 -32
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +31 -116
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +259 -0
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +11 -10
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +56 -1
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +315 -26
- django_cfg/apps/integrations/grpc/management/proto/__init__.py +3 -0
- django_cfg/apps/integrations/grpc/management/proto/compiler.py +194 -0
- django_cfg/apps/integrations/grpc/managers/grpc_request_log.py +84 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +126 -3
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +7 -1
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +22 -3
- django_cfg/apps/integrations/grpc/services/__init__.py +102 -17
- django_cfg/apps/integrations/grpc/services/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/bridge.py +469 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/services/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/services/client/__init__.py +26 -0
- django_cfg/apps/integrations/grpc/services/commands/IMPLEMENTATION.md +456 -0
- django_cfg/apps/integrations/grpc/services/commands/README.md +252 -0
- django_cfg/apps/integrations/grpc/services/commands/__init__.py +93 -0
- django_cfg/apps/integrations/grpc/services/commands/base.py +243 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/__init__.py +22 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/base_client.py +228 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/client.py +272 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/config.py +177 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/start.py +125 -0
- django_cfg/apps/integrations/grpc/services/commands/examples/stop.py +101 -0
- django_cfg/apps/integrations/grpc/services/commands/registry.py +170 -0
- django_cfg/apps/integrations/grpc/services/discovery/__init__.py +39 -0
- django_cfg/apps/integrations/grpc/services/{discovery.py → discovery/discovery.py} +67 -54
- django_cfg/apps/integrations/grpc/services/{service_registry.py → discovery/registry.py} +215 -5
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/services/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/metrics.py +3 -3
- django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/request_logger.py +10 -13
- django_cfg/apps/integrations/grpc/services/management/__init__.py +37 -0
- django_cfg/apps/integrations/grpc/services/monitoring/__init__.py +38 -0
- django_cfg/apps/integrations/grpc/services/{monitoring_service.py → monitoring/monitoring.py} +2 -2
- django_cfg/apps/integrations/grpc/services/{testing_service.py → monitoring/testing.py} +5 -5
- django_cfg/apps/integrations/grpc/services/rendering/__init__.py +27 -0
- django_cfg/apps/integrations/grpc/services/{chart_generator.py → rendering/charts.py} +1 -1
- django_cfg/apps/integrations/grpc/services/routing/__init__.py +59 -0
- django_cfg/apps/integrations/grpc/services/routing/config.py +76 -0
- django_cfg/apps/integrations/grpc/services/routing/router.py +430 -0
- django_cfg/apps/integrations/grpc/services/streaming/__init__.py +117 -0
- django_cfg/apps/integrations/grpc/services/streaming/config.py +451 -0
- django_cfg/apps/integrations/grpc/services/streaming/service.py +651 -0
- django_cfg/apps/integrations/grpc/services/streaming/types.py +367 -0
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +58 -1
- django_cfg/apps/integrations/grpc/utils/converters.py +565 -0
- django_cfg/apps/integrations/grpc/utils/handlers.py +242 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +1 -1
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +261 -13
- django_cfg/apps/integrations/grpc/views/charts.py +1 -1
- django_cfg/apps/integrations/grpc/views/config.py +1 -1
- django_cfg/apps/system/dashboard/serializers/config.py +95 -9
- django_cfg/apps/system/dashboard/serializers/statistics.py +9 -4
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/core/base/config_model.py +11 -0
- django_cfg/core/builders/middleware_builder.py +5 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/management/commands/pool_status.py +153 -0
- django_cfg/middleware/pool_cleanup.py +261 -0
- django_cfg/models/api/grpc/config.py +2 -2
- django_cfg/models/infrastructure/database/config.py +16 -0
- django_cfg/models/infrastructure/database/converters.py +2 -0
- django_cfg/modules/django_admin/utils/html/composition.py +57 -13
- django_cfg/modules/django_admin/utils/html_builder.py +1 -0
- django_cfg/modules/django_client/core/generator/typescript/generator.py +26 -0
- django_cfg/modules/django_client/core/generator/typescript/hooks_generator.py +7 -1
- django_cfg/modules/django_client/core/generator/typescript/models_generator.py +5 -0
- django_cfg/modules/django_client/core/generator/typescript/schemas_generator.py +11 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja +1 -0
- django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja +29 -1
- django_cfg/modules/django_client/core/generator/typescript/templates/hooks/hooks.ts.jinja +4 -0
- django_cfg/modules/django_client/core/groups/manager.py +25 -18
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +12 -0
- django_cfg/modules/django_client/management/commands/generate_client.py +9 -5
- django_cfg/modules/django_logging/django_logger.py +58 -19
- django_cfg/pyproject.toml +3 -3
- django_cfg/static/frontend/admin.zip +0 -0
- django_cfg/templates/admin/index.html +0 -39
- django_cfg/utils/pool_monitor.py +320 -0
- django_cfg/utils/smart_defaults.py +233 -7
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/METADATA +75 -5
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/RECORD +118 -74
- /django_cfg/apps/integrations/grpc/services/{grpc_client.py → client/client.py} +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/errors.py +0 -0
- /django_cfg/apps/integrations/grpc/{interceptors → services/interceptors}/logging.py +0 -0
- /django_cfg/apps/integrations/grpc/services/{config_helper.py → management/config_helper.py} +0 -0
- /django_cfg/apps/integrations/grpc/services/{proto_files_manager.py → management/proto_manager.py} +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.14.dist-info → django_cfg-1.5.29.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
|
|
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
|
-
#
|
|
605
|
-
if target_dir.exists():
|
|
606
|
-
shutil.rmtree(target_dir)
|
|
607
|
-
|
|
608
|
-
# Copy new
|
|
612
|
+
# Copy group directory
|
|
609
613
|
shutil.copytree(group_dir, target_dir)
|
|
610
614
|
copied_count += 1
|
|
611
615
|
|
|
@@ -65,6 +65,32 @@ class DjangoLogger(BaseCfgModule):
|
|
|
65
65
|
|
|
66
66
|
_loggers: Dict[str, logging.Logger] = {}
|
|
67
67
|
_configured = False
|
|
68
|
+
_debug_mode: Optional[bool] = None # Cached debug mode to avoid repeated config loads
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _get_debug_mode(cls) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Get debug mode from config (cached).
|
|
74
|
+
|
|
75
|
+
Loads config only once and caches the result to avoid repeated config loads.
|
|
76
|
+
This is a performance optimization - config loading can be expensive.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
True if debug mode is enabled, False otherwise
|
|
80
|
+
"""
|
|
81
|
+
if cls._debug_mode is not None:
|
|
82
|
+
return cls._debug_mode
|
|
83
|
+
|
|
84
|
+
# Load config once and cache
|
|
85
|
+
try:
|
|
86
|
+
from django_cfg.core.state import get_current_config
|
|
87
|
+
config = get_current_config()
|
|
88
|
+
cls._debug_mode = config.debug if config and hasattr(config, 'debug') else False
|
|
89
|
+
except Exception:
|
|
90
|
+
import os
|
|
91
|
+
cls._debug_mode = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
|
|
92
|
+
|
|
93
|
+
return cls._debug_mode
|
|
68
94
|
|
|
69
95
|
@classmethod
|
|
70
96
|
def get_logger(cls, name: str = "django_cfg") -> logging.Logger:
|
|
@@ -92,13 +118,8 @@ class DjangoLogger(BaseCfgModule):
|
|
|
92
118
|
# print(f" Django logs: {logs_dir / 'django.log'}")
|
|
93
119
|
# print(f" Django-CFG logs: {djangocfg_logs_dir}/")
|
|
94
120
|
|
|
95
|
-
# Get debug mode
|
|
96
|
-
|
|
97
|
-
from django_cfg.core.state import get_current_config
|
|
98
|
-
config = get_current_config()
|
|
99
|
-
debug = config.debug if config else False
|
|
100
|
-
except Exception:
|
|
101
|
-
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
|
|
121
|
+
# Get debug mode (cached - loaded once)
|
|
122
|
+
debug = cls._get_debug_mode()
|
|
102
123
|
|
|
103
124
|
# Create handlers
|
|
104
125
|
try:
|
|
@@ -111,9 +132,13 @@ class DjangoLogger(BaseCfgModule):
|
|
|
111
132
|
backupCount=30, # Keep 30 days of logs
|
|
112
133
|
encoding='utf-8',
|
|
113
134
|
)
|
|
114
|
-
|
|
135
|
+
# File handlers ALWAYS capture DEBUG in dev mode (for complete debugging history)
|
|
136
|
+
# In production, still use INFO+ to save disk space
|
|
137
|
+
django_handler.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
115
138
|
|
|
116
|
-
# Console handler -
|
|
139
|
+
# Console handler - configurable noise level
|
|
140
|
+
# In dev: show DEBUG+ (full visibility)
|
|
141
|
+
# In production: show WARNING+ only (reduce noise)
|
|
117
142
|
console_handler = logging.StreamHandler()
|
|
118
143
|
console_handler.setLevel(logging.DEBUG if debug else logging.WARNING)
|
|
119
144
|
|
|
@@ -123,8 +148,10 @@ class DjangoLogger(BaseCfgModule):
|
|
|
123
148
|
console_handler.setFormatter(formatter)
|
|
124
149
|
|
|
125
150
|
# Configure root logger
|
|
151
|
+
# CRITICAL: Root logger must be DEBUG in dev mode to allow all messages through
|
|
152
|
+
# Handlers will filter based on their own levels, but logger must not block
|
|
126
153
|
root_logger = logging.getLogger()
|
|
127
|
-
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
154
|
+
root_logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
128
155
|
|
|
129
156
|
# Clear existing handlers
|
|
130
157
|
root_logger.handlers.clear()
|
|
@@ -149,9 +176,24 @@ class DjangoLogger(BaseCfgModule):
|
|
|
149
176
|
|
|
150
177
|
@classmethod
|
|
151
178
|
def _create_logger(cls, name: str) -> logging.Logger:
|
|
152
|
-
"""
|
|
179
|
+
"""
|
|
180
|
+
Create logger with modular file handling for django-cfg loggers.
|
|
181
|
+
|
|
182
|
+
In dev/debug mode, loggers inherit DEBUG level from root logger,
|
|
183
|
+
ensuring all log messages reach file handlers regardless of explicit level settings.
|
|
184
|
+
"""
|
|
153
185
|
logger = logging.getLogger(name)
|
|
154
186
|
|
|
187
|
+
# In dev mode, ensure logger doesn't block DEBUG messages
|
|
188
|
+
# Logger inherits from root by default (propagate=True), which is set to DEBUG in dev
|
|
189
|
+
# This is crucial: logger level must be <= handler level, or messages get blocked
|
|
190
|
+
debug = cls._get_debug_mode() # Use cached debug mode
|
|
191
|
+
|
|
192
|
+
# In dev mode, force DEBUG level on logger to ensure complete file logging
|
|
193
|
+
# Handlers will still filter console output (WARNING+), but files get everything (DEBUG+)
|
|
194
|
+
if debug and not logger.level:
|
|
195
|
+
logger.setLevel(logging.DEBUG)
|
|
196
|
+
|
|
155
197
|
# If this is a django-cfg logger, add a specific file handler
|
|
156
198
|
if name.startswith('django_cfg'):
|
|
157
199
|
try:
|
|
@@ -181,15 +223,12 @@ class DjangoLogger(BaseCfgModule):
|
|
|
181
223
|
encoding='utf-8',
|
|
182
224
|
)
|
|
183
225
|
|
|
184
|
-
# Get debug mode
|
|
185
|
-
|
|
186
|
-
from django_cfg.core.state import get_current_config
|
|
187
|
-
config = get_current_config()
|
|
188
|
-
debug = config.debug if config else False
|
|
189
|
-
except Exception:
|
|
190
|
-
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
|
|
226
|
+
# Get debug mode (cached - loaded once)
|
|
227
|
+
debug = cls._get_debug_mode()
|
|
191
228
|
|
|
192
|
-
|
|
229
|
+
# Module file handlers ALWAYS capture DEBUG in dev mode
|
|
230
|
+
# This ensures complete log history for debugging, independent of logger level
|
|
231
|
+
file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
193
232
|
|
|
194
233
|
# Set format
|
|
195
234
|
formatter = logging.Formatter('[%(asctime)s] %(levelname)s in %(name)s [%(filename)s:%(lineno)d]: %(message)s')
|
django_cfg/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "django-cfg"
|
|
7
|
-
version = "1.5.
|
|
7
|
+
version = "1.5.29"
|
|
8
8
|
description = "Modern Django framework with type-safe Pydantic v2 configuration, Next.js admin integration, real-time WebSockets, and 8 enterprise apps. Replace settings.py with validated models, 90% less code. Production-ready with AI agents, auto-generated TypeScript clients, and zero-config features."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [ "django", "configuration", "pydantic", "settings", "type-safety", "pydantic-settings", "django-environ", "startup-validation", "ide-autocomplete", "nextjs-admin", "react-admin", "websocket", "centrifugo", "real-time", "typescript-generation", "ai-agents", "enterprise-django", "django-settings", "type-safe-config", "modern-django",]
|
|
@@ -26,13 +26,13 @@ text = "MIT"
|
|
|
26
26
|
local = []
|
|
27
27
|
django52 = [ "django>=5.2,<6.0",]
|
|
28
28
|
ai = [ "pydantic-ai>=1.0.10,<2.0",]
|
|
29
|
-
grpc = [ "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0",]
|
|
29
|
+
grpc = [ "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0", "aiobreaker>=1.2.0,<2.0",]
|
|
30
30
|
centrifugo = [ "cent>=5.0.0,<6.0", "websockets>=13.0,<15.0",]
|
|
31
31
|
dev = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "factory-boy>=3.0", "fakeredis>=2.0", "black>=23.0", "isort>=5.0", "flake8>=5.0", "mypy>=1.0", "pre-commit>=3.0", "build>=1.0", "twine>=4.0", "tomlkit>=0.11", "questionary>=2.0", "rich>=13.0", "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "redis>=5.0",]
|
|
32
32
|
test = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "pytest-xdist>=3.0", "factory-boy>=3.0", "fakeredis>=2.0",]
|
|
33
33
|
docs = [ "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "pymdown-extensions>=10.0",]
|
|
34
34
|
tasks = [ "redis>=5.0",]
|
|
35
|
-
full = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "pytest-xdist>=3.0", "factory-boy>=3.0", "black>=23.0", "isort>=5.0", "flake8>=5.0", "mypy>=1.0", "pre-commit>=3.0", "build>=1.0", "twine>=4.0", "tomlkit>=0.11", "questionary>=2.0", "rich>=13.0", "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "pymdown-extensions>=10.0", "redis>=5.0", "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0", "cent>=5.0.0,<6.0", "websockets>=13.0,<15.0", "django-rq>=3.0", "rq>=1.0", "rq-scheduler>=0.13", "hiredis>=2.0",]
|
|
35
|
+
full = [ "django>=5.2,<6.0", "pytest>=7.0", "pytest-django>=4.5", "pytest-cov>=4.0", "pytest-mock>=3.0", "pytest-xdist>=3.0", "factory-boy>=3.0", "black>=23.0", "isort>=5.0", "flake8>=5.0", "mypy>=1.0", "pre-commit>=3.0", "build>=1.0", "twine>=4.0", "tomlkit>=0.11", "questionary>=2.0", "rich>=13.0", "mkdocs>=1.5", "mkdocs-material>=9.0", "mkdocstrings[python]>=0.24", "pymdown-extensions>=10.0", "redis>=5.0", "grpcio>=1.50.0,<2.0", "grpcio-tools>=1.50.0,<2.0", "grpcio-reflection>=1.50.0,<2.0", "grpcio-health-checking>=1.50.0,<2.0", "protobuf>=5.0,<7.0", "aiobreaker>=1.2.0,<2.0", "cent>=5.0.0,<6.0", "websockets>=13.0,<15.0", "django-rq>=3.0", "rq>=1.0", "rq-scheduler>=0.13", "hiredis>=2.0",]
|
|
36
36
|
rq = [ "django-rq>=3.0", "rq>=1.0", "rq-scheduler>=0.13", "redis>=5.0", "hiredis>=2.0",]
|
|
37
37
|
|
|
38
38
|
[project.urls]
|
|
Binary file
|
|
@@ -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()
|