django-cfg 1.4.119__py3-none-any.whl → 1.5.1__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 (84) hide show
  1. django_cfg/__init__.py +8 -4
  2. django_cfg/apps/centrifugo/admin/centrifugo_log.py +33 -71
  3. django_cfg/apps/grpc/__init__.py +9 -0
  4. django_cfg/apps/grpc/admin/__init__.py +11 -0
  5. django_cfg/apps/grpc/admin/config.py +89 -0
  6. django_cfg/apps/grpc/admin/grpc_request_log.py +252 -0
  7. django_cfg/apps/grpc/apps.py +28 -0
  8. django_cfg/apps/grpc/auth/__init__.py +9 -0
  9. django_cfg/apps/grpc/auth/jwt_auth.py +295 -0
  10. django_cfg/apps/grpc/interceptors/__init__.py +19 -0
  11. django_cfg/apps/grpc/interceptors/errors.py +241 -0
  12. django_cfg/apps/grpc/interceptors/logging.py +270 -0
  13. django_cfg/apps/grpc/interceptors/metrics.py +306 -0
  14. django_cfg/apps/grpc/interceptors/request_logger.py +515 -0
  15. django_cfg/apps/grpc/management/__init__.py +1 -0
  16. django_cfg/apps/grpc/management/commands/__init__.py +0 -0
  17. django_cfg/apps/grpc/management/commands/rungrpc.py +302 -0
  18. django_cfg/apps/grpc/managers/__init__.py +10 -0
  19. django_cfg/apps/grpc/managers/grpc_request_log.py +310 -0
  20. django_cfg/apps/grpc/migrations/0001_initial.py +69 -0
  21. django_cfg/apps/grpc/migrations/0002_rename_django_cfg__service_4c4a8e_idx_django_cfg__service_584308_idx_and_more.py +38 -0
  22. django_cfg/apps/grpc/migrations/__init__.py +0 -0
  23. django_cfg/apps/grpc/models/__init__.py +9 -0
  24. django_cfg/apps/grpc/models/grpc_request_log.py +219 -0
  25. django_cfg/apps/grpc/serializers/__init__.py +23 -0
  26. django_cfg/apps/grpc/serializers/health.py +18 -0
  27. django_cfg/apps/grpc/serializers/requests.py +18 -0
  28. django_cfg/apps/grpc/serializers/services.py +50 -0
  29. django_cfg/apps/grpc/serializers/stats.py +22 -0
  30. django_cfg/apps/grpc/services/__init__.py +16 -0
  31. django_cfg/apps/grpc/services/base.py +375 -0
  32. django_cfg/apps/grpc/services/discovery.py +415 -0
  33. django_cfg/apps/grpc/urls.py +23 -0
  34. django_cfg/apps/grpc/utils/__init__.py +13 -0
  35. django_cfg/apps/grpc/utils/proto_gen.py +423 -0
  36. django_cfg/apps/grpc/views/__init__.py +9 -0
  37. django_cfg/apps/grpc/views/monitoring.py +497 -0
  38. django_cfg/apps/maintenance/admin/api_key_admin.py +7 -8
  39. django_cfg/apps/maintenance/admin/site_admin.py +5 -4
  40. django_cfg/apps/payments/admin/balance_admin.py +26 -36
  41. django_cfg/apps/payments/admin/payment_admin.py +65 -85
  42. django_cfg/apps/payments/admin/withdrawal_admin.py +65 -100
  43. django_cfg/apps/tasks/admin/task_log.py +20 -47
  44. django_cfg/apps/urls.py +7 -1
  45. django_cfg/config.py +106 -0
  46. django_cfg/core/base/config_model.py +6 -0
  47. django_cfg/core/builders/apps_builder.py +3 -0
  48. django_cfg/core/generation/integration_generators/grpc_generator.py +318 -0
  49. django_cfg/core/generation/orchestrator.py +10 -0
  50. django_cfg/models/api/grpc/__init__.py +59 -0
  51. django_cfg/models/api/grpc/config.py +364 -0
  52. django_cfg/modules/base.py +15 -0
  53. django_cfg/modules/django_admin/__init__.py +2 -0
  54. django_cfg/modules/django_admin/base/pydantic_admin.py +2 -2
  55. django_cfg/modules/django_admin/config/__init__.py +2 -0
  56. django_cfg/modules/django_admin/config/field_config.py +24 -0
  57. django_cfg/modules/django_admin/utils/__init__.py +41 -3
  58. django_cfg/modules/django_admin/utils/badges/__init__.py +13 -0
  59. django_cfg/modules/django_admin/utils/{badges.py → badges/status_badges.py} +3 -3
  60. django_cfg/modules/django_admin/utils/displays/__init__.py +13 -0
  61. django_cfg/modules/django_admin/utils/{displays.py → displays/data_displays.py} +2 -2
  62. django_cfg/modules/django_admin/utils/html/__init__.py +26 -0
  63. django_cfg/modules/django_admin/utils/html/badges.py +47 -0
  64. django_cfg/modules/django_admin/utils/html/base.py +167 -0
  65. django_cfg/modules/django_admin/utils/html/code.py +87 -0
  66. django_cfg/modules/django_admin/utils/html/composition.py +198 -0
  67. django_cfg/modules/django_admin/utils/html/formatting.py +231 -0
  68. django_cfg/modules/django_admin/utils/html/keyvalue.py +219 -0
  69. django_cfg/modules/django_admin/utils/html/markdown_integration.py +108 -0
  70. django_cfg/modules/django_admin/utils/html/progress.py +127 -0
  71. django_cfg/modules/django_admin/utils/html_builder.py +55 -408
  72. django_cfg/modules/django_admin/utils/markdown/__init__.py +21 -0
  73. django_cfg/modules/django_admin/widgets/registry.py +42 -0
  74. django_cfg/modules/django_unfold/navigation.py +28 -0
  75. django_cfg/pyproject.toml +3 -5
  76. django_cfg/registry/modules.py +6 -0
  77. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/METADATA +10 -1
  78. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/RECORD +83 -34
  79. django_cfg/modules/django_admin/utils/CODE_BLOCK_DOCS.md +0 -396
  80. /django_cfg/modules/django_admin/utils/{mermaid_plugin.py → markdown/mermaid_plugin.py} +0 -0
  81. /django_cfg/modules/django_admin/utils/{markdown_renderer.py → markdown/renderer.py} +0 -0
  82. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/WHEEL +0 -0
  83. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/entry_points.txt +0 -0
  84. {django_cfg-1.4.119.dist-info → django_cfg-1.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -209,93 +209,69 @@ class PaymentAdmin(PydanticAdmin):
209
209
  age = timezone.now() - obj.created_at
210
210
  age_text = f"{age.days} days, {age.seconds // 3600} hours"
211
211
 
212
- # Build details list
213
- details = []
214
-
215
- # Basic info
216
- details.append(self.html.inline([
217
- self.html.span("Internal ID:", "font-semibold"),
218
- self.html.span(obj.internal_payment_id, "")
219
- ], separator=" "))
220
-
221
- details.append(self.html.inline([
222
- self.html.span("Age:", "font-semibold"),
223
- self.html.span(age_text, "")
224
- ], separator=" "))
225
-
226
- # Provider info
227
- if obj.provider_payment_id:
228
- details.append(self.html.inline([
229
- self.html.span("Provider Payment ID:", "font-semibold"),
230
- self.html.span(obj.provider_payment_id, "")
231
- ], separator=" "))
232
-
233
212
  # Transaction details
213
+ transaction_value = None
234
214
  if obj.transaction_hash:
235
215
  explorer_link = obj.get_explorer_link()
236
216
  if explorer_link:
237
- details.append(self.html.inline([
238
- self.html.span("Transaction:", "font-semibold"),
239
- self.html.span(f'<a href="{explorer_link}" target="_blank">{obj.transaction_hash[:16]}...</a>', "")
240
- ], separator=" "))
217
+ transaction_value = self.html.link(
218
+ explorer_link,
219
+ f"{obj.transaction_hash[:16]}...",
220
+ target="_blank"
221
+ )
241
222
  else:
242
- details.append(self.html.inline([
243
- self.html.span("Transaction Hash:", "font-semibold"),
244
- self.html.span(obj.transaction_hash, "")
245
- ], separator=" "))
246
-
247
- if obj.confirmations_count > 0:
248
- details.append(self.html.inline([
249
- self.html.span("Confirmations:", "font-semibold"),
223
+ transaction_value = self.html.code(obj.transaction_hash)
224
+
225
+ return self.html.breakdown(
226
+ self.html.key_value("Internal ID", obj.internal_payment_id),
227
+ self.html.key_value("Age", age_text),
228
+ self.html.key_value(
229
+ "Provider Payment ID",
230
+ obj.provider_payment_id
231
+ ) if obj.provider_payment_id else None,
232
+ self.html.key_value(
233
+ "Transaction",
234
+ transaction_value
235
+ ) if obj.transaction_hash else None,
236
+ self.html.key_value(
237
+ "Confirmations",
250
238
  self.html.badge(str(obj.confirmations_count), variant="info", icon=Icons.CHECK_CIRCLE)
251
- ], separator=" "))
252
-
253
- if obj.pay_address:
254
- details.append(self.html.inline([
255
- self.html.span("Pay Address:", "font-semibold"),
256
- self.html.span(f'<code>{obj.pay_address}</code>', "")
257
- ], separator=" "))
258
-
259
- if obj.pay_amount:
260
- details.append(self.html.inline([
261
- self.html.span("Pay Amount:", "font-semibold"),
262
- self.html.span(f'{obj.pay_amount:.8f} {obj.currency.token}', "")
263
- ], separator=" "))
264
-
265
- if obj.actual_amount:
266
- details.append(self.html.inline([
267
- self.html.span("Actual Amount:", "font-semibold"),
268
- self.html.span(f'{obj.actual_amount:.8f} {obj.currency.token}', "")
269
- ], separator=" "))
270
-
271
- # URLs
272
- if obj.payment_url:
273
- details.append(self.html.inline([
274
- self.html.span("Payment URL:", "font-semibold"),
275
- self.html.span(f'<a href="{obj.payment_url}" target="_blank">Open</a>', "")
276
- ], separator=" "))
277
-
278
- # Expiration
279
- if obj.expires_at:
280
- if obj.is_expired:
281
- details.append(self.html.inline([
282
- self.html.span("Expired:", "font-semibold"),
283
- self.html.badge(f"Yes ({obj.expires_at})", variant="danger", icon=Icons.ERROR)
284
- ], separator=" "))
285
- else:
286
- details.append(self.html.inline([
287
- self.html.span("Expires At:", "font-semibold"),
288
- self.html.span(str(obj.expires_at), "")
289
- ], separator=" "))
290
-
291
- # Description
292
- if obj.description:
293
- details.append(self.html.inline([
294
- self.html.span("Description:", "font-semibold"),
295
- self.html.span(obj.description, "")
296
- ], separator=" "))
297
-
298
- return "<br>".join(details)
239
+ ) if obj.confirmations_count > 0 else None,
240
+ self.html.key_value(
241
+ "Pay Address",
242
+ self.html.code(obj.pay_address)
243
+ ) if obj.pay_address else None,
244
+ self.html.key_value(
245
+ "Pay Amount",
246
+ self.html.inline(
247
+ self.html.number(obj.pay_amount, precision=8),
248
+ obj.currency.token,
249
+ separator=" "
250
+ )
251
+ ) if obj.pay_amount else None,
252
+ self.html.key_value(
253
+ "Actual Amount",
254
+ self.html.inline(
255
+ self.html.number(obj.actual_amount, precision=8),
256
+ obj.currency.token,
257
+ separator=" "
258
+ )
259
+ ) if obj.actual_amount else None,
260
+ self.html.key_value(
261
+ "Payment URL",
262
+ self.html.link(obj.payment_url, "Open", target="_blank")
263
+ ) if obj.payment_url else None,
264
+ self.html.key_value(
265
+ "Expired",
266
+ self.html.badge(f"Yes ({obj.expires_at})", variant="danger", icon=Icons.ERROR)
267
+ ) if obj.expires_at and obj.is_expired else (
268
+ self.html.key_value("Expires At", str(obj.expires_at)) if obj.expires_at else None
269
+ ),
270
+ self.html.key_value(
271
+ "Description",
272
+ obj.description
273
+ ) if obj.description else None
274
+ )
299
275
 
300
276
  payment_details_display.short_description = "Payment Details"
301
277
 
@@ -306,8 +282,12 @@ class PaymentAdmin(PydanticAdmin):
306
282
 
307
283
  qr_url = obj.get_qr_code_url(size=200)
308
284
  if qr_url:
309
- return (
310
- f'<img src="{qr_url}" alt="QR Code" style="max-width:200px;"><br>'
311
- f'<small>Scan to pay: <code>{obj.pay_address}</code></small>'
285
+ from django.utils.html import format_html
286
+ img_html = format_html('<img src="{}" alt="QR Code" style="max-width:200px;">', qr_url)
287
+ caption = self.html.inline(
288
+ self.html.text("Scan to pay:", size="sm"),
289
+ self.html.code(obj.pay_address),
290
+ separator=" "
312
291
  )
313
- return self.html.span(f"Address: {obj.pay_address}", "text-sm")
292
+ return self.html.breakdown(img_html, caption)
293
+ return self.html.text(f"Address: {obj.pay_address}", size="sm")
@@ -251,105 +251,70 @@ class WithdrawalRequestAdmin(PydanticAdmin):
251
251
  if not obj.pk:
252
252
  return "Save to see details"
253
253
 
254
- # Build details list
255
- details = []
256
-
257
- details.append(self.html.inline([
258
- self.html.span("Withdrawal ID:", "font-semibold"),
259
- self.html.span(str(obj.id), "")
260
- ], separator=" "))
261
-
262
- details.append(self.html.inline([
263
- self.html.span("User:", "font-semibold"),
264
- self.html.span(f"{obj.user.username} ({obj.user.email})", "")
265
- ], separator=" "))
266
-
267
- details.append(self.html.inline([
268
- self.html.span("Amount:", "font-semibold"),
269
- self.html.span(f"${obj.amount_usd:.2f} USD", "")
270
- ], separator=" "))
271
-
272
- details.append(self.html.inline([
273
- self.html.span("Currency:", "font-semibold"),
274
- self.html.span(obj.currency.code, "")
275
- ], separator=" "))
276
-
277
- details.append(self.html.inline([
278
- self.html.span("Wallet Address:", "font-semibold"),
279
- self.html.span(f"<code>{obj.wallet_address}</code>", "")
280
- ], separator=" "))
281
-
282
- details.append(self.html.inline([
283
- self.html.span("Status:", "font-semibold"),
284
- self.html.span(obj.get_status_display(), "")
285
- ], separator=" "))
286
-
287
- if obj.network_fee_usd:
288
- details.append(self.html.inline([
289
- self.html.span("Network Fee:", "font-semibold"),
290
- self.html.span(f"${obj.network_fee_usd:.2f} USD", "")
291
- ], separator=" "))
292
-
293
- if obj.service_fee_usd:
294
- details.append(self.html.inline([
295
- self.html.span("Service Fee:", "font-semibold"),
296
- self.html.span(f"${obj.service_fee_usd:.2f} USD", "")
297
- ], separator=" "))
298
-
299
- if obj.total_fee_usd:
300
- details.append(self.html.inline([
301
- self.html.span("Total Fee:", "font-semibold"),
302
- self.html.span(f"${obj.total_fee_usd:.2f} USD", "")
303
- ], separator=" "))
304
-
305
- if obj.final_amount_usd:
306
- details.append(self.html.inline([
307
- self.html.span("Final Amount:", "font-semibold"),
308
- self.html.span(f"${obj.final_amount_usd:.2f} USD", "")
309
- ], separator=" "))
310
-
311
- if obj.admin_user:
312
- details.append(self.html.inline([
313
- self.html.span("Approved By:", "font-semibold"),
314
- self.html.span(obj.admin_user.username, "")
315
- ], separator=" "))
316
-
317
- if obj.admin_notes:
318
- details.append(self.html.inline([
319
- self.html.span("Admin Notes:", "font-semibold"),
320
- self.html.span(obj.admin_notes, "")
321
- ], separator=" "))
322
-
323
- if obj.transaction_hash:
324
- details.append(self.html.inline([
325
- self.html.span("Transaction Hash:", "font-semibold"),
326
- self.html.span(f"<code>{obj.transaction_hash}</code>", "")
327
- ], separator=" "))
328
-
329
- if obj.crypto_amount:
330
- details.append(self.html.inline([
331
- self.html.span("Crypto Amount:", "font-semibold"),
332
- self.html.span(f"{obj.crypto_amount:.8f} {obj.currency.token}", "")
333
- ], separator=" "))
334
-
335
- if obj.approved_at:
336
- details.append(self.html.inline([
337
- self.html.span("Approved At:", "font-semibold"),
338
- self.html.span(str(obj.approved_at), "")
339
- ], separator=" "))
340
-
341
- if obj.completed_at:
342
- details.append(self.html.inline([
343
- self.html.span("Completed At:", "font-semibold"),
344
- self.html.span(str(obj.completed_at), "")
345
- ], separator=" "))
346
-
347
- if obj.rejected_at:
348
- details.append(self.html.inline([
349
- self.html.span("Rejected At:", "font-semibold"),
350
- self.html.span(str(obj.rejected_at), "")
351
- ], separator=" "))
352
-
353
- return "<br>".join(details)
254
+ return self.html.breakdown(
255
+ self.html.key_value("Withdrawal ID", str(obj.id)),
256
+ self.html.key_value(
257
+ "User",
258
+ f"{obj.user.username} ({obj.user.email})"
259
+ ),
260
+ self.html.key_value(
261
+ "Amount",
262
+ self.html.number(obj.amount_usd, precision=2, prefix="$", suffix=" USD")
263
+ ),
264
+ self.html.key_value("Currency", obj.currency.code),
265
+ self.html.key_value(
266
+ "Wallet Address",
267
+ self.html.code(obj.wallet_address)
268
+ ),
269
+ self.html.key_value("Status", obj.get_status_display()),
270
+ self.html.key_value(
271
+ "Network Fee",
272
+ self.html.number(obj.network_fee_usd, precision=2, prefix="$", suffix=" USD")
273
+ ) if obj.network_fee_usd else None,
274
+ self.html.key_value(
275
+ "Service Fee",
276
+ self.html.number(obj.service_fee_usd, precision=2, prefix="$", suffix=" USD")
277
+ ) if obj.service_fee_usd else None,
278
+ self.html.key_value(
279
+ "Total Fee",
280
+ self.html.number(obj.total_fee_usd, precision=2, prefix="$", suffix=" USD")
281
+ ) if obj.total_fee_usd else None,
282
+ self.html.key_value(
283
+ "Final Amount",
284
+ self.html.number(obj.final_amount_usd, precision=2, prefix="$", suffix=" USD")
285
+ ) if obj.final_amount_usd else None,
286
+ self.html.key_value(
287
+ "Approved By",
288
+ obj.admin_user.username
289
+ ) if obj.admin_user else None,
290
+ self.html.key_value(
291
+ "Admin Notes",
292
+ obj.admin_notes
293
+ ) if obj.admin_notes else None,
294
+ self.html.key_value(
295
+ "Transaction Hash",
296
+ self.html.code(obj.transaction_hash)
297
+ ) if obj.transaction_hash else None,
298
+ self.html.key_value(
299
+ "Crypto Amount",
300
+ self.html.inline(
301
+ self.html.number(obj.crypto_amount, precision=8),
302
+ obj.currency.token,
303
+ separator=" "
304
+ )
305
+ ) if obj.crypto_amount else None,
306
+ self.html.key_value(
307
+ "Approved At",
308
+ str(obj.approved_at)
309
+ ) if obj.approved_at else None,
310
+ self.html.key_value(
311
+ "Completed At",
312
+ str(obj.completed_at)
313
+ ) if obj.completed_at else None,
314
+ self.html.key_value(
315
+ "Rejected At",
316
+ str(obj.rejected_at)
317
+ ) if obj.rejected_at else None
318
+ )
354
319
 
355
320
  withdrawal_details_display.short_description = "Withdrawal Details"
@@ -104,7 +104,7 @@ class TaskLogAdmin(PydanticAdmin):
104
104
  data["kwargs"] = obj.kwargs
105
105
 
106
106
  formatted = json.dumps(data, indent=2)
107
- return f'<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; max-height: 400px; overflow: auto; font-size: 12px; line-height: 1.5;">{formatted}</pre>'
107
+ return self.html.code_block(formatted, language="json", max_height="400px")
108
108
  except Exception:
109
109
  return str(data)
110
110
 
@@ -114,20 +114,18 @@ class TaskLogAdmin(PydanticAdmin):
114
114
  """Display error information if task failed."""
115
115
  if obj.is_successful or obj.status in ["queued", "in_progress"]:
116
116
  return self.html.inline(
117
- [
118
- self.html.icon(Icons.CHECK_CIRCLE, size="sm"),
119
- self.html.span("No errors", "text-green-600"),
120
- ]
117
+ self.html.icon(Icons.CHECK_CIRCLE, size="sm"),
118
+ self.html.text("No errors", variant="success"),
119
+ separator=" "
121
120
  )
122
121
 
123
122
  if not obj.error_message:
124
123
  return self.html.empty("No error message")
125
124
 
126
125
  return self.html.inline(
127
- [
128
- self.html.icon(Icons.ERROR, size="sm"),
129
- self.html.span(obj.error_message, "text-red-600 font-mono text-sm"),
130
- ]
126
+ self.html.icon(Icons.ERROR, size="sm"),
127
+ self.html.code(obj.error_message, css_class="text-font-danger-light dark:text-font-danger-dark text-sm"),
128
+ separator=" "
131
129
  )
132
130
 
133
131
  error_details_display.short_description = "Error Details"
@@ -136,10 +134,9 @@ class TaskLogAdmin(PydanticAdmin):
136
134
  """Display retry information."""
137
135
  if obj.retry_count == 0:
138
136
  return self.html.inline(
139
- [
140
- self.html.icon(Icons.CHECK_CIRCLE, size="sm"),
141
- self.html.span("No retries", "text-gray-600"),
142
- ]
137
+ self.html.icon(Icons.CHECK_CIRCLE, size="sm"),
138
+ self.html.text("No retries", muted=True),
139
+ separator=" "
143
140
  )
144
141
 
145
142
  # Show retry count with warning if high
@@ -153,59 +150,35 @@ class TaskLogAdmin(PydanticAdmin):
153
150
  variant = "info"
154
151
  icon = Icons.TIMER
155
152
 
156
- return self.html.inline(
157
- [
158
- self.html.badge(f"{obj.retry_count} retries", variant=variant, icon=icon),
159
- ]
160
- )
153
+ return self.html.badge(f"{obj.retry_count} retries", variant=variant, icon=icon)
161
154
 
162
155
  retry_info_display.short_description = "Retry Info"
163
156
 
164
157
  def performance_summary(self, obj):
165
158
  """Display performance summary."""
166
- stats = []
167
-
168
159
  # Duration
160
+ duration_line = None
169
161
  if obj.duration_ms is not None:
170
162
  if obj.duration_ms < 1000:
171
163
  duration_str = f"{obj.duration_ms}ms"
172
164
  else:
173
165
  duration_str = f"{obj.duration_ms / 1000:.2f}s"
174
- stats.append(
175
- self.html.inline(
176
- [
177
- self.html.span("Duration:", "font-semibold"),
178
- self.html.span(duration_str, "text-gray-600"),
179
- ],
180
- separator=" ",
181
- )
182
- )
166
+ duration_line = self.html.key_value("Duration", duration_str)
183
167
 
184
168
  # Retry count
169
+ retry_line = None
185
170
  if obj.retry_count > 0:
186
- stats.append(
187
- self.html.inline(
188
- [
189
- self.html.span("Retries:", "font-semibold"),
190
- self.html.badge(str(obj.retry_count), variant="warning"),
191
- ],
192
- separator=" ",
193
- )
171
+ retry_line = self.html.key_value(
172
+ "Retries",
173
+ self.html.badge(str(obj.retry_count), variant="warning")
194
174
  )
195
175
 
196
176
  # Worker ID
177
+ worker_line = None
197
178
  if obj.worker_id:
198
- stats.append(
199
- self.html.inline(
200
- [
201
- self.html.span("Worker:", "font-semibold"),
202
- self.html.span(obj.worker_id, "text-gray-600 font-mono text-xs"),
203
- ],
204
- separator=" ",
205
- )
206
- )
179
+ worker_line = self.html.key_value("Worker", self.html.code(obj.worker_id, css_class="text-xs"))
207
180
 
208
- return "<br>".join(stats) if stats else self.html.empty()
181
+ return self.html.breakdown(duration_line, retry_line, worker_line) if (duration_line or retry_line or worker_line) else self.html.empty()
209
182
 
210
183
  performance_summary.short_description = "Performance"
211
184
 
django_cfg/apps/urls.py CHANGED
@@ -53,6 +53,9 @@ def get_enabled_cfg_apps() -> List[str]:
53
53
  if base_module.is_centrifugo_enabled():
54
54
  enabled_apps.append("django_cfg.apps.centrifugo")
55
55
 
56
+ if base_module.is_grpc_enabled():
57
+ enabled_apps.append("django_cfg.apps.grpc")
58
+
56
59
  return enabled_apps
57
60
 
58
61
 
@@ -84,7 +87,7 @@ def get_default_cfg_group():
84
87
  name="cfg",
85
88
  apps=get_enabled_cfg_apps(),
86
89
  title="Django-CFG API",
87
- description="Authentication (OTP), Support, Newsletter, Leads, Knowledge Base, AI Agents, Tasks, Payments, Dashboard",
90
+ description="Authentication (OTP), Support, Newsletter, Leads, Knowledge Base, AI Agents, Tasks, Payments, Centrifugo, gRPC, Dashboard",
88
91
  version="1.0.0",
89
92
  )
90
93
 
@@ -183,6 +186,9 @@ APP_URL_MAP = {
183
186
  "django_cfg.apps.centrifugo": [
184
187
  ("cfg/centrifugo/", "django_cfg.apps.centrifugo.urls"),
185
188
  ],
189
+ "django_cfg.apps.grpc": [
190
+ ("cfg/grpc/", "django_cfg.apps.grpc.urls"),
191
+ ],
186
192
  }
187
193
 
188
194
  # Register URLs for enabled apps only
django_cfg/config.py CHANGED
@@ -45,3 +45,109 @@ def get_default_dropdown_items() -> List[SiteDropdownItem]:
45
45
  link=LIB_SITE_URL,
46
46
  ),
47
47
  ]
48
+
49
+
50
+ # ==============================================================================
51
+ # Feature Detection System
52
+ # ==============================================================================
53
+
54
+ import logging
55
+ from typing import Dict, Callable
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ # Feature registry
60
+ FEATURES: Dict[str, Callable[[], bool]] = {}
61
+
62
+
63
+ def register_feature(name: str, check_func: Callable[[], bool]) -> None:
64
+ """
65
+ Register a feature check function.
66
+
67
+ Args:
68
+ name: Feature name (e.g., 'grpc', 'graphql')
69
+ check_func: Function that returns True if feature dependencies are installed
70
+
71
+ Example:
72
+ >>> def check_grpc():
73
+ ... try:
74
+ ... import grpcio
75
+ ... return True
76
+ ... except ImportError:
77
+ ... return False
78
+ >>> register_feature('grpc', check_grpc)
79
+ """
80
+ FEATURES[name] = check_func
81
+ logger.debug(f"Registered feature: {name}")
82
+
83
+
84
+ def is_feature_available(feature: str) -> bool:
85
+ """
86
+ Check if optional feature is available.
87
+
88
+ Args:
89
+ feature: Feature name (e.g., 'grpc', 'graphql')
90
+
91
+ Returns:
92
+ True if feature dependencies are installed, False otherwise
93
+
94
+ Example:
95
+ >>> if is_feature_available('grpc'):
96
+ ... from django_cfg.models.api.grpc import GRPCConfig
97
+ """
98
+ check_func = FEATURES.get(feature)
99
+ if not check_func:
100
+ logger.warning(f"Unknown feature: {feature}")
101
+ return False
102
+
103
+ try:
104
+ result = check_func()
105
+ logger.debug(f"Feature '{feature}' available: {result}")
106
+ return result
107
+ except Exception as e:
108
+ logger.debug(f"Feature '{feature}' not available: {e}")
109
+ return False
110
+
111
+
112
+ def require_feature(feature: str, error_message: str = None) -> None:
113
+ """
114
+ Require a feature or raise ImportError.
115
+
116
+ Args:
117
+ feature: Feature name
118
+ error_message: Custom error message
119
+
120
+ Raises:
121
+ ImportError: If feature not available
122
+
123
+ Example:
124
+ >>> require_feature('grpc') # Raises if not installed
125
+ >>> from django_cfg.models.api.grpc import GRPCConfig # Safe to import
126
+ """
127
+ if not is_feature_available(feature):
128
+ if error_message is None:
129
+ error_message = (
130
+ f"Feature '{feature}' requires additional dependencies. "
131
+ f"Install with: pip install django-cfg[{feature}]"
132
+ )
133
+ raise ImportError(error_message)
134
+
135
+
136
+ # ==============================================================================
137
+ # Built-in Feature Checks
138
+ # ==============================================================================
139
+
140
+ def _check_grpc_available() -> bool:
141
+ """Check if gRPC dependencies are installed."""
142
+ try:
143
+ import grpc as _grpc # noqa: F401
144
+ import grpc_tools as _grpc_tools # noqa: F401
145
+ import django_grpc_framework as _dgf # noqa: F401
146
+ import google.protobuf as _protobuf # noqa: F401
147
+ return True
148
+ except ImportError:
149
+ return False
150
+
151
+
152
+ # Register built-in features
153
+ register_feature('grpc', _check_grpc_available)
@@ -30,6 +30,7 @@ from ...models import (
30
30
  TelegramConfig,
31
31
  UnfoldConfig,
32
32
  )
33
+ from ...models.api.grpc import GRPCConfig
33
34
  from ...models.ngrok import NgrokConfig
34
35
  from ...models.payments import PaymentsConfig
35
36
  from ...models.tasks import TaskConfig
@@ -352,6 +353,11 @@ class DjangoConfig(BaseModel):
352
353
  description="Extended DRF Spectacular configuration (supplements OpenAPI Client)",
353
354
  )
354
355
 
356
+ grpc: Optional[GRPCConfig] = Field(
357
+ default=None,
358
+ description="gRPC framework configuration (server, authentication, proto generation)",
359
+ )
360
+
355
361
  # === Limits Configuration ===
356
362
  limits: Optional[LimitsConfig] = Field(
357
363
  default=None,
@@ -140,6 +140,9 @@ class InstalledAppsBuilder:
140
140
  if self.config.centrifugo and self.config.centrifugo.enabled:
141
141
  apps.append("django_cfg.apps.centrifugo")
142
142
 
143
+ if self.config.grpc and self.config.grpc.enabled:
144
+ apps.append("django_cfg.apps.grpc")
145
+
143
146
  if self.config.crypto_fields and self.config.crypto_fields.enabled:
144
147
  apps.append("django_crypto_fields.apps.AppConfig")
145
148