django-cfg 1.5.8__py3-none-any.whl → 1.5.20__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/api/commands/serializers.py +152 -0
- django_cfg/apps/api/commands/views.py +32 -0
- django_cfg/apps/business/accounts/management/commands/otp_test.py +5 -2
- django_cfg/apps/business/accounts/serializers/profile.py +42 -0
- django_cfg/apps/business/agents/management/commands/create_agent.py +5 -194
- django_cfg/apps/business/agents/management/commands/load_agent_templates.py +205 -0
- django_cfg/apps/business/agents/management/commands/orchestrator_status.py +4 -2
- django_cfg/apps/business/knowbase/management/commands/knowbase_stats.py +4 -2
- django_cfg/apps/business/knowbase/management/commands/setup_knowbase.py +4 -2
- django_cfg/apps/business/newsletter/management/commands/test_newsletter.py +5 -2
- django_cfg/apps/business/payments/management/commands/check_payment_status.py +4 -2
- django_cfg/apps/business/payments/management/commands/create_payment.py +4 -2
- django_cfg/apps/business/payments/management/commands/sync_currencies.py +4 -2
- django_cfg/apps/business/support/serializers.py +3 -2
- 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 +6 -6
- django_cfg/apps/integrations/centrifugo/serializers/__init__.py +2 -1
- django_cfg/apps/integrations/centrifugo/serializers/publishes.py +22 -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/direct_client.py +282 -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/monitoring.py +25 -40
- django_cfg/apps/integrations/centrifugo/views/testing_api.py +0 -79
- django_cfg/apps/integrations/centrifugo/views/token_api.py +101 -0
- django_cfg/apps/integrations/centrifugo/views/wrapper.py +257 -0
- django_cfg/apps/integrations/grpc/admin/__init__.py +7 -1
- django_cfg/apps/integrations/grpc/admin/config.py +113 -9
- django_cfg/apps/integrations/grpc/admin/grpc_api_key.py +129 -0
- django_cfg/apps/integrations/grpc/admin/grpc_request_log.py +72 -63
- django_cfg/apps/integrations/grpc/admin/grpc_server_status.py +236 -0
- django_cfg/apps/integrations/grpc/auth/__init__.py +11 -3
- django_cfg/apps/integrations/grpc/auth/api_key_auth.py +320 -0
- django_cfg/apps/integrations/grpc/centrifugo/__init__.py +29 -0
- django_cfg/apps/integrations/grpc/centrifugo/bridge.py +277 -0
- django_cfg/apps/integrations/grpc/centrifugo/config.py +167 -0
- django_cfg/apps/integrations/grpc/centrifugo/demo.py +626 -0
- django_cfg/apps/integrations/grpc/centrifugo/test_publish.py +229 -0
- django_cfg/apps/integrations/grpc/centrifugo/transformers.py +89 -0
- django_cfg/apps/integrations/grpc/interceptors/__init__.py +3 -1
- django_cfg/apps/integrations/grpc/interceptors/centrifugo.py +541 -0
- django_cfg/apps/integrations/grpc/interceptors/logging.py +17 -20
- django_cfg/apps/integrations/grpc/interceptors/metrics.py +15 -14
- django_cfg/apps/integrations/grpc/interceptors/request_logger.py +79 -59
- django_cfg/apps/integrations/grpc/management/commands/compile_proto.py +105 -0
- django_cfg/apps/integrations/grpc/management/commands/generate_protos.py +185 -0
- django_cfg/apps/integrations/grpc/management/commands/rungrpc.py +474 -95
- django_cfg/apps/integrations/grpc/management/commands/test_grpc_integration.py +75 -0
- 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/__init__.py +2 -0
- django_cfg/apps/integrations/grpc/managers/grpc_api_key.py +192 -0
- django_cfg/apps/integrations/grpc/managers/grpc_server_status.py +19 -11
- django_cfg/apps/integrations/grpc/migrations/0005_grpcapikey.py +143 -0
- django_cfg/apps/integrations/grpc/migrations/0006_grpcrequestlog_api_key_and_more.py +34 -0
- django_cfg/apps/integrations/grpc/models/__init__.py +2 -0
- django_cfg/apps/integrations/grpc/models/grpc_api_key.py +198 -0
- django_cfg/apps/integrations/grpc/models/grpc_request_log.py +11 -0
- django_cfg/apps/integrations/grpc/models/grpc_server_status.py +39 -4
- django_cfg/apps/integrations/grpc/serializers/__init__.py +22 -6
- django_cfg/apps/integrations/grpc/serializers/api_keys.py +63 -0
- django_cfg/apps/integrations/grpc/serializers/charts.py +118 -120
- django_cfg/apps/integrations/grpc/serializers/config.py +65 -51
- django_cfg/apps/integrations/grpc/serializers/health.py +7 -7
- django_cfg/apps/integrations/grpc/serializers/proto_files.py +74 -0
- django_cfg/apps/integrations/grpc/serializers/requests.py +13 -7
- django_cfg/apps/integrations/grpc/serializers/service_registry.py +181 -112
- django_cfg/apps/integrations/grpc/serializers/services.py +14 -32
- django_cfg/apps/integrations/grpc/serializers/stats.py +50 -12
- django_cfg/apps/integrations/grpc/serializers/testing.py +66 -58
- django_cfg/apps/integrations/grpc/services/__init__.py +2 -0
- django_cfg/apps/integrations/grpc/services/discovery.py +7 -1
- django_cfg/apps/integrations/grpc/services/monitoring_service.py +149 -43
- django_cfg/apps/integrations/grpc/services/proto_files_manager.py +268 -0
- django_cfg/apps/integrations/grpc/services/service_registry.py +48 -46
- django_cfg/apps/integrations/grpc/services/testing_service.py +10 -15
- django_cfg/apps/integrations/grpc/urls.py +8 -0
- django_cfg/apps/integrations/grpc/utils/SERVER_LOGGING.md +164 -0
- django_cfg/apps/integrations/grpc/utils/__init__.py +4 -13
- django_cfg/apps/integrations/grpc/utils/integration_test.py +334 -0
- django_cfg/apps/integrations/grpc/utils/proto_gen.py +48 -8
- django_cfg/apps/integrations/grpc/utils/streaming_logger.py +378 -0
- django_cfg/apps/integrations/grpc/views/__init__.py +4 -0
- django_cfg/apps/integrations/grpc/views/api_keys.py +255 -0
- django_cfg/apps/integrations/grpc/views/charts.py +21 -14
- django_cfg/apps/integrations/grpc/views/config.py +8 -6
- django_cfg/apps/integrations/grpc/views/monitoring.py +51 -79
- django_cfg/apps/integrations/grpc/views/proto_files.py +214 -0
- django_cfg/apps/integrations/grpc/views/services.py +30 -21
- django_cfg/apps/integrations/grpc/views/testing.py +45 -43
- django_cfg/apps/integrations/rq/views/jobs.py +19 -9
- django_cfg/apps/integrations/rq/views/schedule.py +7 -3
- django_cfg/apps/system/dashboard/serializers/commands.py +25 -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/dashboard/services/commands_service.py +12 -1
- django_cfg/apps/system/frontend/views.py +87 -6
- django_cfg/apps/system/maintenance/management/commands/maintenance.py +5 -2
- django_cfg/apps/system/maintenance/management/commands/process_scheduled_maintenance.py +4 -2
- django_cfg/apps/system/maintenance/management/commands/sync_cloudflare.py +5 -2
- django_cfg/config.py +33 -0
- django_cfg/core/builders/security_builder.py +1 -0
- django_cfg/core/generation/integration_generators/api.py +2 -0
- django_cfg/core/generation/integration_generators/grpc_generator.py +30 -32
- django_cfg/management/commands/check_endpoints.py +2 -2
- django_cfg/management/commands/check_settings.py +3 -10
- django_cfg/management/commands/clear_constance.py +3 -10
- django_cfg/management/commands/create_token.py +4 -11
- django_cfg/management/commands/list_urls.py +4 -10
- django_cfg/management/commands/migrate_all.py +18 -12
- django_cfg/management/commands/migrator.py +4 -11
- django_cfg/management/commands/script.py +4 -10
- django_cfg/management/commands/show_config.py +8 -16
- django_cfg/management/commands/show_urls.py +5 -11
- django_cfg/management/commands/superuser.py +4 -11
- django_cfg/management/commands/tree.py +5 -10
- django_cfg/management/utils/README.md +402 -0
- django_cfg/management/utils/__init__.py +29 -0
- django_cfg/management/utils/mixins.py +176 -0
- django_cfg/middleware/pagination.py +53 -54
- django_cfg/models/api/grpc/__init__.py +15 -21
- django_cfg/models/api/grpc/config.py +155 -73
- django_cfg/models/ngrok/config.py +7 -6
- django_cfg/modules/django_client/core/generator/python/files_generator.py +5 -13
- django_cfg/modules/django_client/core/generator/python/templates/api_wrapper.py.jinja +16 -4
- django_cfg/modules/django_client/core/generator/python/templates/main_init.py.jinja +2 -3
- django_cfg/modules/django_client/core/generator/typescript/files_generator.py +6 -5
- 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/generator/typescript/templates/main_index.ts.jinja +12 -8
- django_cfg/modules/django_client/core/ir/schema.py +15 -1
- django_cfg/modules/django_client/core/parser/base.py +126 -30
- django_cfg/modules/django_client/management/commands/generate_client.py +5 -2
- django_cfg/modules/django_client/management/commands/validate_openapi.py +5 -2
- django_cfg/modules/django_email/management/commands/test_email.py +4 -10
- django_cfg/modules/django_ngrok/management/commands/runserver_ngrok.py +16 -13
- django_cfg/modules/django_telegram/management/commands/test_telegram.py +4 -11
- django_cfg/modules/django_twilio/management/commands/test_twilio.py +4 -11
- django_cfg/modules/django_unfold/navigation.py +6 -18
- django_cfg/pyproject.toml +1 -1
- django_cfg/registry/modules.py +1 -4
- django_cfg/requirements.txt +52 -0
- django_cfg/static/frontend/admin.zip +0 -0
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/METADATA +1 -1
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/RECORD +158 -121
- django_cfg/apps/integrations/grpc/auth/jwt_auth.py +0 -295
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/WHEEL +0 -0
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.5.8.dist-info → django_cfg-1.5.20.dist-info}/licenses/LICENSE +0 -0
|
@@ -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({})"
|
django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/fetchers.ts.jinja
CHANGED
|
@@ -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'
|
django_cfg/modules/django_client/core/generator/typescript/templates/fetchers/function.ts.jinja
CHANGED
|
@@ -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
|
-
|
|
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 %}
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { APIClient } from "./client";
|
|
36
|
-
import { OPENAPI_SCHEMA } from "./schema";
|
|
37
36
|
import {
|
|
38
37
|
StorageAdapter,
|
|
39
38
|
LocalStorageAdapter,
|
|
@@ -82,9 +81,6 @@ export * as Hooks from "./_utils/hooks";
|
|
|
82
81
|
// Re-export core client
|
|
83
82
|
export { APIClient };
|
|
84
83
|
|
|
85
|
-
// Re-export OpenAPI schema
|
|
86
|
-
export { OPENAPI_SCHEMA };
|
|
87
|
-
|
|
88
84
|
// Re-export storage adapters for convenience
|
|
89
85
|
export type { StorageAdapter };
|
|
90
86
|
export { LocalStorageAdapter, CookieStorageAdapter, MemoryStorageAdapter };
|
|
@@ -267,11 +263,19 @@ export class API {
|
|
|
267
263
|
}
|
|
268
264
|
|
|
269
265
|
/**
|
|
270
|
-
* Get OpenAPI schema
|
|
271
|
-
* @returns
|
|
266
|
+
* Get OpenAPI schema path
|
|
267
|
+
* @returns Path to the OpenAPI schema JSON file
|
|
268
|
+
*
|
|
269
|
+
* Note: The OpenAPI schema is available in the schema.json file.
|
|
270
|
+
* You can load it dynamically using:
|
|
271
|
+
* ```typescript
|
|
272
|
+
* const schema = await fetch('./schema.json').then(r => r.json());
|
|
273
|
+
* // or using fs in Node.js:
|
|
274
|
+
* // const schema = JSON.parse(fs.readFileSync('./schema.json', 'utf-8'));
|
|
275
|
+
* ```
|
|
272
276
|
*/
|
|
273
|
-
|
|
274
|
-
return
|
|
277
|
+
getSchemaPath(): string {
|
|
278
|
+
return './schema.json';
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
|
|
@@ -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",
|
|
@@ -62,8 +62,12 @@ class BaseParser(ABC):
|
|
|
62
62
|
IRContext with all schemas and operations
|
|
63
63
|
|
|
64
64
|
Raises:
|
|
65
|
-
ValueError: If COMPONENT_SPLIT_REQUEST is not detected
|
|
65
|
+
ValueError: If COMPONENT_SPLIT_REQUEST is not detected or schema name conflicts found
|
|
66
66
|
"""
|
|
67
|
+
# CRITICAL: Validate schema names BEFORE parsing
|
|
68
|
+
# This prevents generation from starting with invalid schemas
|
|
69
|
+
self._validate_schema_names()
|
|
70
|
+
|
|
67
71
|
# Parse metadata
|
|
68
72
|
openapi_info = self._parse_openapi_info()
|
|
69
73
|
django_metadata = self._parse_django_metadata()
|
|
@@ -237,54 +241,134 @@ class BaseParser(ABC):
|
|
|
237
241
|
|
|
238
242
|
return False
|
|
239
243
|
|
|
240
|
-
# ===== Schema
|
|
244
|
+
# ===== Schema Validation =====
|
|
245
|
+
|
|
246
|
+
def _validate_schema_names(self) -> None:
|
|
247
|
+
"""
|
|
248
|
+
Validate schema names for conflicts BEFORE parsing.
|
|
249
|
+
|
|
250
|
+
This method checks for:
|
|
251
|
+
1. Case-insensitive duplicates (e.g., "User" and "user")
|
|
252
|
+
2. Exact duplicates (e.g., "GRPCServerInfo" from two different serializers)
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
ValueError: If schema name conflicts are detected (hard exit with traceback)
|
|
256
|
+
|
|
257
|
+
Example conflicts:
|
|
258
|
+
- Case-insensitive: "Profile" and "profile" → filesystem conflict
|
|
259
|
+
- Exact duplicate: "HealthCheck" from multiple serializers → schema conflict
|
|
260
|
+
"""
|
|
261
|
+
import traceback
|
|
262
|
+
import sys
|
|
241
263
|
|
|
242
|
-
def _parse_all_schemas(self) -> dict[str, IRSchemaObject]:
|
|
243
|
-
"""Parse all schemas from components."""
|
|
244
264
|
if not self.spec.components or not self.spec.components.schemas:
|
|
245
|
-
return
|
|
265
|
+
return # No schemas to validate
|
|
246
266
|
|
|
247
|
-
# Check for duplicate schema names with different casing
|
|
248
267
|
schema_names = list(self.spec.components.schemas.keys())
|
|
249
268
|
lowercase_map = {}
|
|
250
|
-
|
|
269
|
+
exact_duplicate_sources = {}
|
|
251
270
|
|
|
252
271
|
for name in schema_names:
|
|
253
272
|
lowercase = name.lower()
|
|
254
273
|
|
|
255
274
|
# Check case-insensitive duplicates
|
|
256
275
|
if lowercase in lowercase_map:
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
f"
|
|
260
|
-
f"
|
|
261
|
-
f"
|
|
262
|
-
f"
|
|
276
|
+
existing_name = lowercase_map[lowercase]
|
|
277
|
+
error_msg = (
|
|
278
|
+
f"\n{'=' * 80}\n"
|
|
279
|
+
f"❌ SCHEMA NAME CONFLICT DETECTED\n"
|
|
280
|
+
f"{'=' * 80}\n\n"
|
|
281
|
+
f"Conflict Type: Case-insensitive duplicate\n"
|
|
282
|
+
f"Schema Names:\n"
|
|
283
|
+
f" 1. '{existing_name}'\n"
|
|
284
|
+
f" 2. '{name}'\n\n"
|
|
285
|
+
f"Problem:\n"
|
|
286
|
+
f" These names differ only in casing and will cause conflicts on\n"
|
|
287
|
+
f" case-insensitive filesystems (macOS, Windows).\n\n"
|
|
288
|
+
f"Solution:\n"
|
|
289
|
+
f" Rename one of the Django serializers to make them distinct.\n"
|
|
290
|
+
f" Example: {name}Serializer → {name}DetailSerializer\n\n"
|
|
291
|
+
f"{'=' * 80}\n"
|
|
263
292
|
)
|
|
264
|
-
lowercase_map[lowercase] = name
|
|
265
293
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
title1 = getattr(schema1, 'title', 'Unknown')
|
|
271
|
-
title2 = exact_duplicate_map[name]
|
|
294
|
+
# Print full error with traceback
|
|
295
|
+
print(error_msg, file=sys.stderr)
|
|
296
|
+
print("\n🔍 Traceback (schema validation):", file=sys.stderr)
|
|
297
|
+
traceback.print_stack(file=sys.stderr)
|
|
272
298
|
|
|
299
|
+
# Hard exit - stop generation immediately
|
|
273
300
|
raise ValueError(
|
|
274
|
-
f"
|
|
275
|
-
f"
|
|
276
|
-
f" - {title1}\n"
|
|
277
|
-
f" - {title2}\n"
|
|
278
|
-
f"This causes schema conflicts in the generated API client.\n"
|
|
279
|
-
f"Please rename one of the serializers to make them unique.\n"
|
|
280
|
-
f"Example: HealthCheckSerializer → GRPCHealthCheckSerializer"
|
|
301
|
+
f"Case-insensitive schema name conflict: '{existing_name}' vs '{name}'. "
|
|
302
|
+
f"Cannot generate client with conflicting schema names."
|
|
281
303
|
)
|
|
282
304
|
|
|
283
|
-
|
|
305
|
+
lowercase_map[lowercase] = name
|
|
306
|
+
|
|
307
|
+
# Track schema sources for exact duplicate detection
|
|
284
308
|
schema = self.spec.components.schemas.get(name)
|
|
285
|
-
|
|
286
|
-
|
|
309
|
+
if schema and not isinstance(schema, ReferenceObject):
|
|
310
|
+
# Get source information from schema
|
|
311
|
+
title = getattr(schema, 'title', name)
|
|
312
|
+
description = getattr(schema, 'description', '')
|
|
313
|
+
|
|
314
|
+
# Create signature for duplicate detection
|
|
315
|
+
if name in exact_duplicate_sources:
|
|
316
|
+
# Found exact duplicate!
|
|
317
|
+
existing = exact_duplicate_sources[name]
|
|
318
|
+
error_msg = (
|
|
319
|
+
f"\n{'=' * 80}\n"
|
|
320
|
+
f"❌ SCHEMA NAME CONFLICT DETECTED\n"
|
|
321
|
+
f"{'=' * 80}\n\n"
|
|
322
|
+
f"Conflict Type: Exact duplicate schema name\n"
|
|
323
|
+
f"Schema Name: '{name}'\n\n"
|
|
324
|
+
f"Sources:\n"
|
|
325
|
+
f" 1. {existing['title']}\n"
|
|
326
|
+
f" Description: {existing['description'][:100]}\n"
|
|
327
|
+
f" 2. {title}\n"
|
|
328
|
+
f" Description: {description[:100]}\n\n"
|
|
329
|
+
f"Problem:\n"
|
|
330
|
+
f" Multiple serializers are generating the same schema name '{name}'.\n"
|
|
331
|
+
f" This causes schema conflicts in the generated API client and\n"
|
|
332
|
+
f" OpenAPI documentation.\n\n"
|
|
333
|
+
f"Solution:\n"
|
|
334
|
+
f" Rename one of the serializers to make them unique.\n"
|
|
335
|
+
f" Examples:\n"
|
|
336
|
+
f" - HealthCheckSerializer → GRPCHealthCheckSerializer\n"
|
|
337
|
+
f" - ServerInfoSerializer → GRPCServerStatusSerializer\n\n"
|
|
338
|
+
f"{'=' * 80}\n"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Print full error with traceback
|
|
342
|
+
print(error_msg, file=sys.stderr)
|
|
343
|
+
print("\n🔍 Traceback (schema validation):", file=sys.stderr)
|
|
344
|
+
traceback.print_stack(file=sys.stderr)
|
|
345
|
+
|
|
346
|
+
# Hard exit - stop generation immediately
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Duplicate schema name detected: '{name}'. "
|
|
349
|
+
f"Multiple serializers are generating this schema. "
|
|
350
|
+
f"Cannot generate client with conflicting schemas."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Store for duplicate detection
|
|
354
|
+
exact_duplicate_sources[name] = {
|
|
355
|
+
'title': title,
|
|
356
|
+
'description': description,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# ===== Schema Parsing =====
|
|
287
360
|
|
|
361
|
+
def _parse_all_schemas(self) -> dict[str, IRSchemaObject]:
|
|
362
|
+
"""
|
|
363
|
+
Parse all schemas from components.
|
|
364
|
+
|
|
365
|
+
Note: Schema name validation is done in _validate_schema_names()
|
|
366
|
+
which is called BEFORE this method in parse().
|
|
367
|
+
"""
|
|
368
|
+
if not self.spec.components or not self.spec.components.schemas:
|
|
369
|
+
return {}
|
|
370
|
+
|
|
371
|
+
# Parse schemas (validation already done in _validate_schema_names)
|
|
288
372
|
schemas = {}
|
|
289
373
|
for name, schema_or_ref in self.spec.components.schemas.items():
|
|
290
374
|
# Skip references for now
|
|
@@ -369,6 +453,17 @@ class BaseParser(ABC):
|
|
|
369
453
|
else:
|
|
370
454
|
items = self._parse_schema(f"{name}.items", schema.items)
|
|
371
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
|
+
|
|
372
467
|
# Create IR schema
|
|
373
468
|
ir_schema = IRSchemaObject(
|
|
374
469
|
name=name,
|
|
@@ -378,6 +473,7 @@ class BaseParser(ABC):
|
|
|
378
473
|
nullable=self._detect_nullable(schema),
|
|
379
474
|
properties=properties,
|
|
380
475
|
required=schema.required or [],
|
|
476
|
+
additional_properties=additional_properties,
|
|
381
477
|
items=items,
|
|
382
478
|
enum=schema.enum,
|
|
383
479
|
enum_var_names=schema.x_enum_varnames,
|
|
@@ -8,12 +8,15 @@ Usage:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
from django.core.management.base import
|
|
11
|
+
from django.core.management.base import CommandError
|
|
12
12
|
|
|
13
|
+
from django_cfg.management.utils import AdminCommand
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
class Command(AdminCommand):
|
|
15
17
|
"""Generate OpenAPI clients for configured application groups."""
|
|
16
18
|
|
|
19
|
+
command_name = 'generate_client'
|
|
17
20
|
help = "Generate Python, TypeScript, and Go API clients from OpenAPI schemas"
|
|
18
21
|
|
|
19
22
|
def add_arguments(self, parser):
|
|
@@ -12,12 +12,15 @@ Usage:
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import List
|
|
14
14
|
|
|
15
|
-
from django.core.management.base import
|
|
15
|
+
from django.core.management.base import CommandError
|
|
16
16
|
|
|
17
|
+
from django_cfg.management.utils import AdminCommand
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
class Command(AdminCommand):
|
|
19
21
|
"""Validate and fix OpenAPI schema quality issues in DRF serializers."""
|
|
20
22
|
|
|
23
|
+
command_name = 'validate_openapi'
|
|
21
24
|
help = "Validate and auto-fix OpenAPI schema quality issues"
|
|
22
25
|
|
|
23
26
|
def add_arguments(self, parser):
|
|
@@ -5,22 +5,16 @@ Tests email sending functionality using django_cfg configuration.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from django.contrib.auth import get_user_model
|
|
8
|
-
from django.core.management.base import BaseCommand
|
|
9
8
|
|
|
10
|
-
from django_cfg.
|
|
9
|
+
from django_cfg.management.utils import SafeCommand
|
|
11
10
|
|
|
12
11
|
User = get_user_model()
|
|
13
|
-
logger = get_logger('test_email')
|
|
14
12
|
|
|
15
13
|
|
|
16
|
-
class Command(
|
|
14
|
+
class Command(SafeCommand):
|
|
17
15
|
"""Command to test email functionality."""
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
web_executable = True
|
|
21
|
-
requires_input = False
|
|
22
|
-
is_destructive = False
|
|
23
|
-
|
|
17
|
+
command_name = 'test_email'
|
|
24
18
|
help = "Test email sending functionality"
|
|
25
19
|
|
|
26
20
|
def add_arguments(self, parser):
|
|
@@ -48,7 +42,7 @@ class Command(BaseCommand):
|
|
|
48
42
|
subject = options["subject"]
|
|
49
43
|
message = options["message"]
|
|
50
44
|
|
|
51
|
-
logger.info(f"Starting email test for {email}")
|
|
45
|
+
self.logger.info(f"Starting email test for {email}")
|
|
52
46
|
self.stdout.write(f"🚀 Testing email service for {email}")
|
|
53
47
|
|
|
54
48
|
# Create test user if not exists
|
|
@@ -12,8 +12,6 @@ from django.core.management.commands.runserver import Command as RunServerComman
|
|
|
12
12
|
from django_cfg.modules.django_logging import get_logger
|
|
13
13
|
from django_cfg.modules.django_ngrok import get_ngrok_service
|
|
14
14
|
|
|
15
|
-
logger = get_logger('runserver_ngrok')
|
|
16
|
-
|
|
17
15
|
|
|
18
16
|
class Command(RunServerCommand):
|
|
19
17
|
"""Enhanced runserver command with ngrok tunnel support."""
|
|
@@ -25,6 +23,11 @@ class Command(RunServerCommand):
|
|
|
25
23
|
|
|
26
24
|
help = f'{RunServerCommand.help.rstrip(".")} with ngrok tunnel.'
|
|
27
25
|
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
"""Initialize with logger."""
|
|
28
|
+
super().__init__(*args, **kwargs)
|
|
29
|
+
self.logger = get_logger('runserver_ngrok')
|
|
30
|
+
|
|
28
31
|
def add_arguments(self, parser):
|
|
29
32
|
super().add_arguments(parser)
|
|
30
33
|
parser.add_argument(
|
|
@@ -76,14 +79,14 @@ class Command(RunServerCommand):
|
|
|
76
79
|
ngrok_service = get_ngrok_service()
|
|
77
80
|
|
|
78
81
|
self.stdout.write("🚇 Starting ngrok tunnel...")
|
|
79
|
-
logger.info(f"Starting ngrok tunnel for port {server_port}")
|
|
82
|
+
self.logger.info(f"Starting ngrok tunnel for port {server_port}")
|
|
80
83
|
|
|
81
84
|
tunnel_url = ngrok_service.start_tunnel(server_port)
|
|
82
85
|
|
|
83
86
|
if tunnel_url:
|
|
84
87
|
# Wait for tunnel to be fully established
|
|
85
88
|
self.stdout.write("⏳ Waiting for tunnel to be established...")
|
|
86
|
-
logger.info("Waiting for ngrok tunnel to be fully established")
|
|
89
|
+
self.logger.info("Waiting for ngrok tunnel to be fully established")
|
|
87
90
|
|
|
88
91
|
max_retries = 10
|
|
89
92
|
retry_count = 0
|
|
@@ -98,10 +101,10 @@ class Command(RunServerCommand):
|
|
|
98
101
|
current_url = ngrok_service.get_tunnel_url()
|
|
99
102
|
if current_url and current_url == tunnel_url:
|
|
100
103
|
tunnel_ready = True
|
|
101
|
-
logger.info(f"Ngrok tunnel established successfully: {tunnel_url}")
|
|
104
|
+
self.logger.info(f"Ngrok tunnel established successfully: {tunnel_url}")
|
|
102
105
|
break
|
|
103
106
|
except Exception as e:
|
|
104
|
-
logger.warning(f"Tunnel check attempt {retry_count} failed: {e}")
|
|
107
|
+
self.logger.warning(f"Tunnel check attempt {retry_count} failed: {e}")
|
|
105
108
|
|
|
106
109
|
self.stdout.write(f"⏳ Tunnel check {retry_count}/{max_retries}...")
|
|
107
110
|
|
|
@@ -116,16 +119,16 @@ class Command(RunServerCommand):
|
|
|
116
119
|
self.stdout.write(
|
|
117
120
|
self.style.SUCCESS(f"✅ Ngrok tunnel ready: {tunnel_url}")
|
|
118
121
|
)
|
|
119
|
-
logger.info(f"Ngrok tunnel fully ready: {tunnel_url}")
|
|
122
|
+
self.logger.info(f"Ngrok tunnel fully ready: {tunnel_url}")
|
|
120
123
|
else:
|
|
121
124
|
self.stdout.write(
|
|
122
125
|
self.style.WARNING("⚠️ Ngrok tunnel started but may not be fully ready")
|
|
123
126
|
)
|
|
124
|
-
logger.warning("Ngrok tunnel started but readiness check failed")
|
|
127
|
+
self.logger.warning("Ngrok tunnel started but readiness check failed")
|
|
125
128
|
else:
|
|
126
129
|
error_msg = "Failed to start ngrok tunnel"
|
|
127
130
|
self.stdout.write(self.style.ERROR(f"❌ {error_msg}"))
|
|
128
|
-
logger.error(error_msg)
|
|
131
|
+
self.logger.error(error_msg)
|
|
129
132
|
|
|
130
133
|
def _set_ngrok_env_vars(self, tunnel_url: str):
|
|
131
134
|
"""Set environment variables with ngrok URL for easy access."""
|
|
@@ -145,10 +148,10 @@ class Command(RunServerCommand):
|
|
|
145
148
|
os.environ['NGROK_API_URL'] = tunnel_url
|
|
146
149
|
|
|
147
150
|
# Environment variables set - no need for verbose output
|
|
148
|
-
logger.info(f"Set ngrok environment variables: {tunnel_url}")
|
|
151
|
+
self.logger.info(f"Set ngrok environment variables: {tunnel_url}")
|
|
149
152
|
|
|
150
153
|
except Exception as e:
|
|
151
|
-
logger.warning(f"Could not set ngrok environment variables: {e}")
|
|
154
|
+
self.logger.warning(f"Could not set ngrok environment variables: {e}")
|
|
152
155
|
|
|
153
156
|
def _update_allowed_hosts(self, tunnel_url: str):
|
|
154
157
|
"""Update ALLOWED_HOSTS with ngrok domain."""
|
|
@@ -164,7 +167,7 @@ class Command(RunServerCommand):
|
|
|
164
167
|
if hasattr(settings, 'ALLOWED_HOSTS'):
|
|
165
168
|
if ngrok_host not in settings.ALLOWED_HOSTS:
|
|
166
169
|
settings.ALLOWED_HOSTS.append(ngrok_host)
|
|
167
|
-
logger.info(f"Added {ngrok_host} to ALLOWED_HOSTS")
|
|
170
|
+
self.logger.info(f"Added {ngrok_host} to ALLOWED_HOSTS")
|
|
168
171
|
|
|
169
172
|
except Exception as e:
|
|
170
|
-
logger.warning(f"Could not update ALLOWED_HOSTS: {e}")
|
|
173
|
+
self.logger.warning(f"Could not update ALLOWED_HOSTS: {e}")
|
|
@@ -4,20 +4,13 @@ Test Telegram Command
|
|
|
4
4
|
Tests Telegram notification functionality using django_cfg configuration.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from django_cfg.management.utils import SafeCommand
|
|
8
8
|
|
|
9
|
-
from django_cfg.modules.django_logging import get_logger
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class Command(BaseCommand):
|
|
10
|
+
class Command(SafeCommand):
|
|
14
11
|
"""Command to test Telegram functionality."""
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
web_executable = True
|
|
18
|
-
requires_input = False
|
|
19
|
-
is_destructive = False
|
|
20
|
-
|
|
13
|
+
command_name = 'test_telegram'
|
|
21
14
|
help = "Test Telegram notification functionality"
|
|
22
15
|
|
|
23
16
|
def add_arguments(self, parser):
|
|
@@ -29,7 +22,7 @@ class Command(BaseCommand):
|
|
|
29
22
|
)
|
|
30
23
|
|
|
31
24
|
def handle(self, *args, **options):
|
|
32
|
-
logger.info("Starting test_telegram command")
|
|
25
|
+
self.logger.info("Starting test_telegram command")
|
|
33
26
|
message = options["message"]
|
|
34
27
|
|
|
35
28
|
self.stdout.write("🚀 Testing Telegram notification service")
|