django-unicom 25.3.45.dev0__tar.gz → 25.3.45.dev2__tar.gz

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.
Files changed (305) hide show
  1. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/PKG-INFO +44 -2
  2. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/README.md +43 -1
  3. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/django_unicom.egg-info/PKG-INFO +44 -2
  4. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/django_unicom.egg-info/SOURCES.txt +13 -0
  5. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/models.py +11 -0
  6. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/services/llm_handler.py +41 -16
  7. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/_version.py +2 -2
  8. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/draft_message.py +2 -0
  9. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/message.py +34 -6
  10. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/send_email_message.py +46 -0
  11. django_unicom-25.3.45.dev2/unicom/services/template_renderer.py +190 -0
  12. django_unicom-25.3.45.dev2/unicom/tests/test_email_tracking_unsubscribe.py +10 -0
  13. django_unicom-25.3.45.dev2/unicom/tests/test_template_renderer_context.py +35 -0
  14. django_unicom-25.3.45.dev2/unicom/tests/test_template_renderer_unicom.py +31 -0
  15. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/compose_view.py +2 -0
  16. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/email_tracking.py +28 -0
  17. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/admin.py +6 -13
  18. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/apps.py +19 -31
  19. django_unicom-25.3.45.dev2/unicrm/forms.py +267 -0
  20. django_unicom-25.3.45.dev2/unicrm/migrations/0021_fix_unsubscribe_counts.py +22 -0
  21. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/models.py +9 -11
  22. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/seed_data.py +81 -8
  23. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/__init__.py +7 -0
  24. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/audience.py +7 -3
  25. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/audience_preview.py +7 -2
  26. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/communication_scheduler.py +49 -0
  27. django_unicom-25.3.45.dev2/unicrm/services/disk_usage_monitor.py +84 -0
  28. django_unicom-25.3.45.dev2/unicrm/services/disk_usage_monitor_runner.py +99 -0
  29. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/getprospect_email_poller.py +22 -2
  30. django_unicom-25.3.45.dev2/unicrm/services/lead_search.py +279 -0
  31. django_unicom-25.3.45.dev2/unicrm/services/red_alerts.py +243 -0
  32. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/template_renderer.py +8 -56
  33. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_communication_scheduler.py +53 -9
  34. django_unicom-25.3.45.dev2/unicrm/tests/test_disk_usage_monitor.py +43 -0
  35. django_unicom-25.3.45.dev2/unicrm/tests/test_disk_usage_monitor_runner.py +40 -0
  36. django_unicom-25.3.45.dev2/unicrm/tests/test_red_alerts.py +122 -0
  37. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_retry_delivery_api.py +26 -0
  38. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/urls.py +3 -0
  39. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/views_api.py +7 -1
  40. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/views_communication.py +42 -3
  41. django_unicom-25.3.45.dev2/unicrm/views_lead_search.py +125 -0
  42. django_unicom-25.3.45.dev0/unicrm/forms.py +0 -139
  43. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/.cursor/rules/match_codebase_style.mdc +0 -0
  44. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/.cursor/rules/preserve_comments_and_irrelevant_code.mdc +0 -0
  45. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/.cursor/rules/require_context_before_changes.mdc +0 -0
  46. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/Dockerfile +0 -0
  47. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/MANIFEST.in +0 -0
  48. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/Makefile +0 -0
  49. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/conftest.py +0 -0
  50. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/data/definitions/bots/__init__.py +0 -0
  51. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/data/definitions/tools/__init__.py +0 -0
  52. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/django_unicom.egg-info/dependency_links.txt +0 -0
  53. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/django_unicom.egg-info/requires.txt +0 -0
  54. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/django_unicom.egg-info/top_level.txt +0 -0
  55. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/docker-compose.yaml +0 -0
  56. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/entrypoint.sh +0 -0
  57. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/internal-channel-modernization.md +0 -0
  58. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/manage.py +0 -0
  59. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/pyproject.toml +0 -0
  60. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/pytest.ini +0 -0
  61. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/requirements.txt +0 -0
  62. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/scripts/reacher_validate.sh +0 -0
  63. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/setup.cfg +0 -0
  64. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/setup.py +0 -0
  65. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/__init__.py +0 -0
  66. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_cross_platform_buttons.py +0 -0
  67. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_email_authentication.py +0 -0
  68. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_email_live.py +0 -0
  69. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_email_request_processing.py +0 -0
  70. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_telegram_live.py +0 -0
  71. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_webchat.py +0 -0
  72. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/test_webchat_branching.py +0 -0
  73. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/tests/utils.py +0 -0
  74. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/todo.md +0 -0
  75. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/__init__.py +0 -0
  76. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/admin.py +0 -0
  77. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/apps.py +0 -0
  78. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/migrations/0001_initial.py +0 -0
  79. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/migrations/0002_tool_bot_tools.py +0 -0
  80. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/migrations/0003_encryptedcredential.py +0 -0
  81. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/migrations/0004_credentialfielddefinition_and_more.py +0 -0
  82. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/migrations/0005_alter_credentialfielddefinition_key.py +0 -0
  83. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/migrations/__init__.py +0 -0
  84. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/services/__init__.py +0 -0
  85. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/services/credentials.py +0 -0
  86. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/services/tool_exceptions.py +0 -0
  87. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/services/tool_result_handler.py +0 -0
  88. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/templatetags/__init__.py +0 -0
  89. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/templatetags/unibot_extras.py +0 -0
  90. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/tests.py +0 -0
  91. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/urls.py +0 -0
  92. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unibot/views.py +0 -0
  93. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/__init__.py +0 -0
  94. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/__init__.py +0 -0
  95. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/account_admin.py +0 -0
  96. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/channel_admin.py +0 -0
  97. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/chat_admin.py +0 -0
  98. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/draft_message_admin.py +0 -0
  99. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/email_inline_image_admin.py +0 -0
  100. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/filters.py +0 -0
  101. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/member_admin.py +0 -0
  102. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/message_admin.py +0 -0
  103. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/message_template_admin.py +0 -0
  104. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/admin/request_admin.py +0 -0
  105. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/apps.py +0 -0
  106. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/consumers/__init__.py +0 -0
  107. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/consumers/webchat_consumer.py +0 -0
  108. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/management/__init__.py +0 -0
  109. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/management/commands/__init__.py +0 -0
  110. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/management/commands/mail_inbox_listen.py +0 -0
  111. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/management/commands/run_as_llm_chat.py +0 -0
  112. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/management/commands/send_scheduled_messages.py +0 -0
  113. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/management/commands/start_imap_listeners.py +0 -0
  114. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0001_initial.py +0 -0
  115. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0002_emailinlineimage.py +0 -0
  116. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0003_alter_emailinlineimage_email_message.py +0 -0
  117. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0004_messagetemplateinlineimage.py +0 -0
  118. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0005_emailinlineimage_hash_and_more.py +0 -0
  119. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0006_channel_created_at_channel_created_by_and_more.py +0 -0
  120. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0007_message_imap_uid.py +0 -0
  121. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0008_add_all_members_group.py +0 -0
  122. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0009_add_tool_call_types.py +0 -0
  123. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0010_request_initial_request_request_llm_calls_count_and_more.py +0 -0
  124. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0011_toolcall_add_message_reference.py +0 -0
  125. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0012_message_response_to_tool_call_and_more.py +0 -0
  126. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0013_callbackexecution.py +0 -0
  127. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0014_rename_authorized_user_to_intended_account.py +0 -0
  128. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0015_simplify_callback_execution.py +0 -0
  129. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0016_callbackexecution_tool_call.py +0 -0
  130. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0017_add_chat_metadata.py +0 -0
  131. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0018_message_bounce_details_message_bounce_reason_and_more.py +0 -0
  132. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0019_draftmessage_skip_reacher_validation.py +0 -0
  133. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0020_message_provider_message_id.py +0 -0
  134. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0021_message_open_count.py +0 -0
  135. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0022_toolcall_result_status.py +0 -0
  136. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/0023_toolcall_progress_updates_for_user.py +0 -0
  137. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/migrations/__init__.py +0 -0
  138. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/__init__.py +0 -0
  139. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/account.py +0 -0
  140. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/account_chat.py +0 -0
  141. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/callback_execution.py +0 -0
  142. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/channel.py +0 -0
  143. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/chat.py +0 -0
  144. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/constants.py +0 -0
  145. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/fields.py +0 -0
  146. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/member.py +0 -0
  147. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/member_group.py +0 -0
  148. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/message_template.py +0 -0
  149. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/request.py +0 -0
  150. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/request_category.py +0 -0
  151. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/tool_call.py +0 -0
  152. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/models/update.py +0 -0
  153. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/__init__.py +0 -0
  154. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/chat_summary.py +0 -0
  155. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/crossplatform/__init__.py +0 -0
  156. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/crossplatform/reply_to_message.py +0 -0
  157. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/crossplatform/scheduler.py +0 -0
  158. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/crossplatform/send_message.py +0 -0
  159. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/decode_base64_image.py +0 -0
  160. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/IMAP_thread_manager.py +0 -0
  161. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/__init__.py +0 -0
  162. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/email_tracking.py +0 -0
  163. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/listen_to_IMAP.py +0 -0
  164. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/quote_filter.py +0 -0
  165. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/replace_cid_images_with_base64.py +0 -0
  166. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/save_email_message.py +0 -0
  167. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/email/validate_email_config.py +0 -0
  168. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/get_public_origin.py +0 -0
  169. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/html_inline_images.py +0 -0
  170. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/internal/__init__.py +0 -0
  171. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/internal/generate_text_message_data.py +0 -0
  172. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/internal/save_internal_message.py +0 -0
  173. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/internal/send_internal_message.py +0 -0
  174. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/llm/README.md +0 -0
  175. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/llm/__init__.py +0 -0
  176. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/llm/tool_calls.py +0 -0
  177. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/__init__.py +0 -0
  178. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/answer_callback_query.py +0 -0
  179. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/create_inline_keyboard.py +0 -0
  180. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/download_file.py +0 -0
  181. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/edit_telegram_message.py +0 -0
  182. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/escape_markdown.py +0 -0
  183. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/get_file_path.py +0 -0
  184. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/handle_telegram_callback.py +0 -0
  185. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/save_telegram_message.py +0 -0
  186. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/send_telegram_message.py +0 -0
  187. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/set_telegram_webhook.py +0 -0
  188. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/start_typing_in_telegram.py +0 -0
  189. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/telegram/stop_typing_in_telegram.py +0 -0
  190. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/webchat/__init__.py +0 -0
  191. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/webchat/generate_chat_title.py +0 -0
  192. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/webchat/get_or_create_account.py +0 -0
  193. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/webchat/migrate_guest_to_user.py +0 -0
  194. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/webchat/save_webchat_message.py +0 -0
  195. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/webchat/send_webchat_message.py +0 -0
  196. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/whatsapp/__init__.py +0 -0
  197. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/whatsapp/get_template.py +0 -0
  198. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/whatsapp/save_whatsapp_message.py +0 -0
  199. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/whatsapp/save_whatsapp_message_status.py +0 -0
  200. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/services/whatsapp/send_whatsapp_message.py +0 -0
  201. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/signals.py +0 -0
  202. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/css/bootstrap_scoped.css +0 -0
  203. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/css/draft_message_mobile.css +0 -0
  204. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/js/channel_config.js +0 -0
  205. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/js/tinymce_ai_template.js +0 -0
  206. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/js/tinymce_init.js +0 -0
  207. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/components/chat-list.js +0 -0
  208. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/components/media-preview.js +0 -0
  209. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/components/message-input.js +0 -0
  210. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/components/message-item.js +0 -0
  211. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/components/message-list.js +0 -0
  212. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/components/voice-recorder.js +0 -0
  213. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/utils/api-client.js +0 -0
  214. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/utils/datetime-formatter.js +0 -0
  215. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/utils/font-awesome-loader.js +0 -0
  216. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/utils/realtime-client.js +0 -0
  217. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/webchat-component.js +0 -0
  218. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/webchat-styles.js +0 -0
  219. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/static/unicom/webchat/webchat-with-sidebar.js +0 -0
  220. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/chat/change_list.html +0 -0
  221. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/chat/compose.html +0 -0
  222. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/chat_history.html +0 -0
  223. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/forms/email_message_form.html +0 -0
  224. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/forms/text_message_form.html +0 -0
  225. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/includes/ai_template_modal.html +0 -0
  226. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/includes/loading_indicators.html +0 -0
  227. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/includes/message_actions_menu.html +0 -0
  228. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/admin/unicom/messagetemplate/change_form.html +0 -0
  229. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/code_templates/category_processor.py +0 -0
  230. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templates/unicom/webchat_demo.html +0 -0
  231. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templatetags/__init__.py +0 -0
  232. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/templatetags/unicom_tags.py +0 -0
  233. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/urls.py +0 -0
  234. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/__init__.py +0 -0
  235. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/chat_history_view.py +0 -0
  236. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/inline_image.py +0 -0
  237. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/message_template.py +0 -0
  238. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/telegram_webhook.py +0 -0
  239. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/webchat_demo_view.py +0 -0
  240. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/webchat_views.py +0 -0
  241. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom/views/whatsapp_webhook.py +0 -0
  242. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/__init__.py +0 -0
  243. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/apps.py +0 -0
  244. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/asgi.py +0 -0
  245. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/callback_handlers.py +0 -0
  246. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/__init__.py +0 -0
  247. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/bots/__init__.py +0 -0
  248. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/bots/assistant_bot.py +0 -0
  249. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/__init__.py +0 -0
  250. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/cross_platform_buttons.py +0 -0
  251. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/get_system_info.py +0 -0
  252. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/interactive_menu.py +0 -0
  253. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/interval_alarm.py +0 -0
  254. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/ip_lookup.py +0 -0
  255. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/definitions/tools/simple_timer.py +0 -0
  256. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/settings.py +0 -0
  257. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/test_button_handlers.py +0 -0
  258. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/urls.py +0 -0
  259. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicom_project/wsgi.py +0 -0
  260. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/__init__.py +0 -0
  261. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/management/__init__.py +0 -0
  262. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/management/commands/__init__.py +0 -0
  263. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/management/commands/refresh_contact_cache.py +0 -0
  264. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/management/commands/send_scheduled_communications.py +0 -0
  265. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/management/commands/sync_getprospect_saved_contacts.py +0 -0
  266. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0001_initial.py +0 -0
  267. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0002_alter_communicationmessage_unique_together_and_more.py +0 -0
  268. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0003_remove_template_add_content.py +0 -0
  269. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0004_alter_contact_email.py +0 -0
  270. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0005_contact_user_alter_communication_status_summary.py +0 -0
  271. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0006_contact_email_bounce_type_contact_email_bounced_and_more.py +0 -0
  272. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0007_auto_enroll_and_status.py +0 -0
  273. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0008_rename_unicrm_msg_status_sched_idx_unicrm_comm_status_2f3c8e_idx_and_more.py +0 -0
  274. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0009_remove_unused_draft_field.py +0 -0
  275. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0010_communication_evergreen_refreshed_at.py +0 -0
  276. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0011_add_unique_constraints_to_company.py +0 -0
  277. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0012_company_attributes_company_industry.py +0 -0
  278. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0013_contact_insight_id.py +0 -0
  279. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0014_contact_gp_fields.py +0 -0
  280. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0015_contact_history_unsubscribe_all.py +0 -0
  281. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0016_add_unsubscribe_template_variable.py +0 -0
  282. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0017_unsubscribe_link_html_variable.py +0 -0
  283. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0018_communication_mailing_list.py +0 -0
  284. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0019_communication_follow_up_for_segment_for_followup.py +0 -0
  285. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/0020_communication_skip_antispam_guards.py +0 -0
  286. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/migrations/__init__.py +0 -0
  287. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/communication_dispatcher.py +0 -0
  288. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/communication_enrollment.py +0 -0
  289. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/communication_runner.py +0 -0
  290. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/contact_interaction_cache.py +0 -0
  291. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/save_new_leads_callbacks.py +0 -0
  292. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/unsubscribe_links.py +0 -0
  293. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/services/user_contact_sync.py +0 -0
  294. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/signals.py +0 -0
  295. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/templatetags/__init__.py +0 -0
  296. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/templatetags/unicrm_extras.py +0 -0
  297. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/__init__.py +0 -0
  298. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_communication_compose_view.py +0 -0
  299. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_communication_dispatcher.py +0 -0
  300. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_contact_interactions.py +0 -0
  301. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_template_renderer.py +0 -0
  302. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_unsubscribe_view.py +0 -0
  303. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/tests/test_user_contact_sync.py +0 -0
  304. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/views.py +0 -0
  305. {django_unicom-25.3.45.dev0 → django_unicom-25.3.45.dev2}/unicrm/views_public.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-unicom
3
- Version: 25.3.45.dev0
3
+ Version: 25.3.45.dev2
4
4
  Summary: Unified communication layer for Django (Telegram, WhatsApp, Email)
5
5
  Home-page: https://github.com/meena-erian/unicom
6
6
  Author: Meena (Menas) Erian
@@ -539,7 +539,7 @@ reply = chat.send_message({
539
539
 
540
540
  ### Template System
541
541
 
542
- Create reusable message templates for consistent communication.
542
+ Create reusable message templates for consistent communication, and optionally render Jinja-style template variables for richer personalization.
543
543
 
544
544
  #### Creating Templates Programmatically
545
545
 
@@ -595,6 +595,48 @@ message = channel.send_message({
595
595
  })
596
596
  ```
597
597
 
598
+ #### Template Variables & Rendering
599
+
600
+ There are two layers of templating:
601
+ - **Unicom rendering (standalone messages):** Jinja2 rendering with a safe Unicom context. It is used automatically for ad-hoc/admin email compose and scheduled drafts. Context exposed to templates: `message` (subject/html/text/to/cc/bcc/attachments/chat_id/reply_to_message_id/timestamp), `channel` (id/name/platform), and `sender` (id/username/email when available). You can add more by passing `render_variables` (merged into `variables.*`) or `render_context` when calling `channel.send_message`.
602
+ - **Unicrm rendering (mass-mail):** Jinja2 rendering with CRM data. Context exposed: `contact` (and nested `company`, `subscriptions`), `communication`, and `variables` (all active CRM TemplateVariables), plus `variables.unsubscribe_link` (HTML link) when a Communication is provided. This path is unchanged and remains tied to CRM mailings.
603
+
604
+ Using variables in templates (applies to both renderers):
605
+ ```html
606
+ <h1>Hello {{ variables.first_name }}</h1>
607
+ <p>You’re receiving this on {{ now()|datetime("%Y-%m-%d") }}</p>
608
+ ```
609
+
610
+ Creating CRM TemplateVariables (only available in CRM Communications):
611
+ ```python
612
+ from unicrm.models import TemplateVariable
613
+
614
+ TemplateVariable.objects.create(
615
+ key="contact_first_name",
616
+ label="Contact first name",
617
+ description="Returns the contact's first name",
618
+ code="""
619
+ def compute(contact):
620
+ return (contact.first_name or '').strip() or 'there'
621
+ """,
622
+ is_active=True,
623
+ )
624
+ ```
625
+ The callable must be `compute(contact)` and can access `contact`, `contact.company`, and helpers like `build_unsubscribe_link` (for unsubscribe variables). Values become available as `{{ variables.contact_first_name }}` in CRM emails.
626
+
627
+ Opting into rendering for custom sends (programmatic):
628
+ ```python
629
+ channel.send_message({
630
+ 'to': ['a@example.com'],
631
+ 'subject': 'Hello',
632
+ 'html': '<p>Hello {{ variables.name }}</p>',
633
+ 'render_template': True, # enable rendering (admin email compose & scheduled drafts set this for you)
634
+ 'render_variables': {'name': 'Alice'}, # merged into variables.*
635
+ # Optionally pass a custom render_context if you want to add more fields
636
+ })
637
+ ```
638
+ If you omit `render_template`/context/variables, the HTML is sent as-is. The Django admin email composer and scheduled drafts already enable rendering by default; you only need to pass the flag when sending programmatically.
639
+
598
640
  ### Draft Messages & Scheduling
599
641
 
600
642
  Create draft messages and schedule them for later sending.
@@ -495,7 +495,7 @@ reply = chat.send_message({
495
495
 
496
496
  ### Template System
497
497
 
498
- Create reusable message templates for consistent communication.
498
+ Create reusable message templates for consistent communication, and optionally render Jinja-style template variables for richer personalization.
499
499
 
500
500
  #### Creating Templates Programmatically
501
501
 
@@ -551,6 +551,48 @@ message = channel.send_message({
551
551
  })
552
552
  ```
553
553
 
554
+ #### Template Variables & Rendering
555
+
556
+ There are two layers of templating:
557
+ - **Unicom rendering (standalone messages):** Jinja2 rendering with a safe Unicom context. It is used automatically for ad-hoc/admin email compose and scheduled drafts. Context exposed to templates: `message` (subject/html/text/to/cc/bcc/attachments/chat_id/reply_to_message_id/timestamp), `channel` (id/name/platform), and `sender` (id/username/email when available). You can add more by passing `render_variables` (merged into `variables.*`) or `render_context` when calling `channel.send_message`.
558
+ - **Unicrm rendering (mass-mail):** Jinja2 rendering with CRM data. Context exposed: `contact` (and nested `company`, `subscriptions`), `communication`, and `variables` (all active CRM TemplateVariables), plus `variables.unsubscribe_link` (HTML link) when a Communication is provided. This path is unchanged and remains tied to CRM mailings.
559
+
560
+ Using variables in templates (applies to both renderers):
561
+ ```html
562
+ <h1>Hello {{ variables.first_name }}</h1>
563
+ <p>You’re receiving this on {{ now()|datetime("%Y-%m-%d") }}</p>
564
+ ```
565
+
566
+ Creating CRM TemplateVariables (only available in CRM Communications):
567
+ ```python
568
+ from unicrm.models import TemplateVariable
569
+
570
+ TemplateVariable.objects.create(
571
+ key="contact_first_name",
572
+ label="Contact first name",
573
+ description="Returns the contact's first name",
574
+ code="""
575
+ def compute(contact):
576
+ return (contact.first_name or '').strip() or 'there'
577
+ """,
578
+ is_active=True,
579
+ )
580
+ ```
581
+ The callable must be `compute(contact)` and can access `contact`, `contact.company`, and helpers like `build_unsubscribe_link` (for unsubscribe variables). Values become available as `{{ variables.contact_first_name }}` in CRM emails.
582
+
583
+ Opting into rendering for custom sends (programmatic):
584
+ ```python
585
+ channel.send_message({
586
+ 'to': ['a@example.com'],
587
+ 'subject': 'Hello',
588
+ 'html': '<p>Hello {{ variables.name }}</p>',
589
+ 'render_template': True, # enable rendering (admin email compose & scheduled drafts set this for you)
590
+ 'render_variables': {'name': 'Alice'}, # merged into variables.*
591
+ # Optionally pass a custom render_context if you want to add more fields
592
+ })
593
+ ```
594
+ If you omit `render_template`/context/variables, the HTML is sent as-is. The Django admin email composer and scheduled drafts already enable rendering by default; you only need to pass the flag when sending programmatically.
595
+
554
596
  ### Draft Messages & Scheduling
555
597
 
556
598
  Create draft messages and schedule them for later sending.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-unicom
3
- Version: 25.3.45.dev0
3
+ Version: 25.3.45.dev2
4
4
  Summary: Unified communication layer for Django (Telegram, WhatsApp, Email)
5
5
  Home-page: https://github.com/meena-erian/unicom
6
6
  Author: Meena (Menas) Erian
@@ -539,7 +539,7 @@ reply = chat.send_message({
539
539
 
540
540
  ### Template System
541
541
 
542
- Create reusable message templates for consistent communication.
542
+ Create reusable message templates for consistent communication, and optionally render Jinja-style template variables for richer personalization.
543
543
 
544
544
  #### Creating Templates Programmatically
545
545
 
@@ -595,6 +595,48 @@ message = channel.send_message({
595
595
  })
596
596
  ```
597
597
 
598
+ #### Template Variables & Rendering
599
+
600
+ There are two layers of templating:
601
+ - **Unicom rendering (standalone messages):** Jinja2 rendering with a safe Unicom context. It is used automatically for ad-hoc/admin email compose and scheduled drafts. Context exposed to templates: `message` (subject/html/text/to/cc/bcc/attachments/chat_id/reply_to_message_id/timestamp), `channel` (id/name/platform), and `sender` (id/username/email when available). You can add more by passing `render_variables` (merged into `variables.*`) or `render_context` when calling `channel.send_message`.
602
+ - **Unicrm rendering (mass-mail):** Jinja2 rendering with CRM data. Context exposed: `contact` (and nested `company`, `subscriptions`), `communication`, and `variables` (all active CRM TemplateVariables), plus `variables.unsubscribe_link` (HTML link) when a Communication is provided. This path is unchanged and remains tied to CRM mailings.
603
+
604
+ Using variables in templates (applies to both renderers):
605
+ ```html
606
+ <h1>Hello {{ variables.first_name }}</h1>
607
+ <p>You’re receiving this on {{ now()|datetime("%Y-%m-%d") }}</p>
608
+ ```
609
+
610
+ Creating CRM TemplateVariables (only available in CRM Communications):
611
+ ```python
612
+ from unicrm.models import TemplateVariable
613
+
614
+ TemplateVariable.objects.create(
615
+ key="contact_first_name",
616
+ label="Contact first name",
617
+ description="Returns the contact's first name",
618
+ code="""
619
+ def compute(contact):
620
+ return (contact.first_name or '').strip() or 'there'
621
+ """,
622
+ is_active=True,
623
+ )
624
+ ```
625
+ The callable must be `compute(contact)` and can access `contact`, `contact.company`, and helpers like `build_unsubscribe_link` (for unsubscribe variables). Values become available as `{{ variables.contact_first_name }}` in CRM emails.
626
+
627
+ Opting into rendering for custom sends (programmatic):
628
+ ```python
629
+ channel.send_message({
630
+ 'to': ['a@example.com'],
631
+ 'subject': 'Hello',
632
+ 'html': '<p>Hello {{ variables.name }}</p>',
633
+ 'render_template': True, # enable rendering (admin email compose & scheduled drafts set this for you)
634
+ 'render_variables': {'name': 'Alice'}, # merged into variables.*
635
+ # Optionally pass a custom render_context if you want to add more fields
636
+ })
637
+ ```
638
+ If you omit `render_template`/context/variables, the HTML is sent as-is. The Django admin email composer and scheduled drafts already enable rendering by default; you only need to pass the flag when sending programmatically.
639
+
598
640
  ### Draft Messages & Scheduling
599
641
 
600
642
  Create draft messages and schedule them for later sending.
@@ -122,6 +122,7 @@ unicom/services/chat_summary.py
122
122
  unicom/services/decode_base64_image.py
123
123
  unicom/services/get_public_origin.py
124
124
  unicom/services/html_inline_images.py
125
+ unicom/services/template_renderer.py
125
126
  unicom/services/crossplatform/__init__.py
126
127
  unicom/services/crossplatform/reply_to_message.py
127
128
  unicom/services/crossplatform/scheduler.py
@@ -197,6 +198,9 @@ unicom/templates/code_templates/category_processor.py
197
198
  unicom/templates/unicom/webchat_demo.html
198
199
  unicom/templatetags/__init__.py
199
200
  unicom/templatetags/unicom_tags.py
201
+ unicom/tests/test_email_tracking_unsubscribe.py
202
+ unicom/tests/test_template_renderer_context.py
203
+ unicom/tests/test_template_renderer_unicom.py
200
204
  unicom/views/__init__.py
201
205
  unicom/views/chat_history_view.py
202
206
  unicom/views/compose_view.py
@@ -236,6 +240,7 @@ unicrm/urls.py
236
240
  unicrm/views.py
237
241
  unicrm/views_api.py
238
242
  unicrm/views_communication.py
243
+ unicrm/views_lead_search.py
239
244
  unicrm/views_public.py
240
245
  unicrm/management/__init__.py
241
246
  unicrm/management/commands/__init__.py
@@ -262,6 +267,7 @@ unicrm/migrations/0017_unsubscribe_link_html_variable.py
262
267
  unicrm/migrations/0018_communication_mailing_list.py
263
268
  unicrm/migrations/0019_communication_follow_up_for_segment_for_followup.py
264
269
  unicrm/migrations/0020_communication_skip_antispam_guards.py
270
+ unicrm/migrations/0021_fix_unsubscribe_counts.py
265
271
  unicrm/migrations/__init__.py
266
272
  unicrm/services/__init__.py
267
273
  unicrm/services/audience.py
@@ -271,7 +277,11 @@ unicrm/services/communication_enrollment.py
271
277
  unicrm/services/communication_runner.py
272
278
  unicrm/services/communication_scheduler.py
273
279
  unicrm/services/contact_interaction_cache.py
280
+ unicrm/services/disk_usage_monitor.py
281
+ unicrm/services/disk_usage_monitor_runner.py
274
282
  unicrm/services/getprospect_email_poller.py
283
+ unicrm/services/lead_search.py
284
+ unicrm/services/red_alerts.py
275
285
  unicrm/services/save_new_leads_callbacks.py
276
286
  unicrm/services/template_renderer.py
277
287
  unicrm/services/unsubscribe_links.py
@@ -283,6 +293,9 @@ unicrm/tests/test_communication_compose_view.py
283
293
  unicrm/tests/test_communication_dispatcher.py
284
294
  unicrm/tests/test_communication_scheduler.py
285
295
  unicrm/tests/test_contact_interactions.py
296
+ unicrm/tests/test_disk_usage_monitor.py
297
+ unicrm/tests/test_disk_usage_monitor_runner.py
298
+ unicrm/tests/test_red_alerts.py
286
299
  unicrm/tests/test_retry_delivery_api.py
287
300
  unicrm/tests/test_template_renderer.py
288
301
  unicrm/tests/test_unsubscribe_view.py
@@ -441,6 +441,17 @@ class Bot(models.Model):
441
441
  Process a single Request object using this bot's code, updating status and error fields as needed.
442
442
  Passes the bot instance and its associated tools to the handler.
443
443
  """
444
+ # If this is a user interrupt immediately after a tool_call or tool_response (within 5 minutes), skip LLM processing. (The
445
+ # LLM will respond through the request created when the tool response arrives and this is to avoid double processing.)
446
+ if req.message.is_outgoing is False and req.message.media_type not in ['tool_call', 'tool_response']:
447
+ prev_msg = req.message.chat.messages.filter(timestamp__lt=req.message.timestamp).order_by('-timestamp').first()
448
+ if prev_msg and prev_msg.media_type in ['tool_call', 'tool_response']:
449
+ delta = (req.message.timestamp - prev_msg.timestamp).total_seconds()
450
+ if delta < 300:
451
+ req.status = 'COMPLETED'
452
+ req.save(update_fields=['status'])
453
+ return
454
+
444
455
  if hasattr(req, 'parent_request') and req.parent_request:
445
456
  print(f"[DEBUG] Processing child request {req.id} from parent {req.parent_request.id}")
446
457
  bot_code = self.code or Bot.get_default_code()
@@ -59,31 +59,56 @@ def build_openai_tools(tools_list: Optional[List[object]], bot=None, message=Non
59
59
 
60
60
  if auto_params:
61
61
  auto_params_map[name] = auto_params
62
- props = {}
63
- required = []
62
+ props: dict = {}
63
+ required: list = []
64
64
  for pname, pinfo in raw_params.items():
65
65
  ptype = pinfo.get('type', 'string')
66
- if ptype in ('str', 'string'):
67
- otype = 'string'
68
- elif ptype in ('int', 'integer'):
69
- otype = 'integer'
70
- elif ptype in ('float', 'number'):
71
- otype = 'number'
72
- elif ptype in ('bool', 'boolean'):
73
- otype = 'boolean'
66
+ # Preserve enum/items for the LLM; map common aliases for scalars.
67
+ if isinstance(ptype, str):
68
+ if ptype in ('str', 'string'):
69
+ otype = 'string'
70
+ elif ptype in ('int', 'integer'):
71
+ otype = 'integer'
72
+ elif ptype in ('float', 'number'):
73
+ otype = 'number'
74
+ elif ptype in ('bool', 'boolean'):
75
+ otype = 'boolean'
76
+ else:
77
+ otype = ptype
74
78
  else:
75
- otype = 'string'
79
+ # Allow JSON Schema style: list/union types
80
+ otype = ptype
81
+
76
82
  prop = {'type': otype}
77
- if 'description' in pinfo:
78
- prop['description'] = pinfo['description']
83
+ # Carry through common JSON Schema fields if present.
84
+ for key in (
85
+ 'description',
86
+ 'enum',
87
+ 'items',
88
+ 'properties',
89
+ 'additionalProperties',
90
+ 'format',
91
+ 'minimum',
92
+ 'maximum',
93
+ 'minItems',
94
+ 'maxItems',
95
+ 'anyOf',
96
+ 'oneOf',
97
+ 'allOf',
98
+ ):
99
+ if key in pinfo:
100
+ prop[key] = pinfo[key]
79
101
  if 'default' in pinfo:
80
102
  prop['default'] = pinfo['default']
81
103
  else:
82
104
  required.append(pname)
83
105
  props[pname] = prop
84
- param_schema = { 'type': 'object',
85
- 'properties': props,
86
- 'additionalProperties': False }
106
+
107
+ param_schema = {
108
+ 'type': 'object',
109
+ 'properties': props,
110
+ 'additionalProperties': False,
111
+ }
87
112
  if required:
88
113
  param_schema['required'] = required
89
114
  openai_tools.append({
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '25.3.45.dev0'
32
- __version_tuple__ = version_tuple = (25, 3, 45, 'dev0')
31
+ __version__ = version = '25.3.45.dev2'
32
+ __version_tuple__ = version_tuple = (25, 3, 45, 'dev2')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -180,6 +180,8 @@ class DraftMessage(models.Model):
180
180
  msg_dict['chat_id'] = self.chat_id
181
181
  if self.skip_reacher_validation:
182
182
  msg_dict['skip_reacher'] = True
183
+ # Enable template rendering with the built-in Unicom context.
184
+ msg_dict['render_template'] = True
183
185
  else:
184
186
  msg_dict['chat_id'] = self.chat_id
185
187
  msg_dict['text'] = self.text
@@ -445,6 +445,8 @@ class Message(models.Model):
445
445
  break
446
446
  chain.append(cur)
447
447
  cur = cur.reply_to_message
448
+ # Sort chronologically to preserve call/response order
449
+ chain = sorted(chain, key=lambda m: m.timestamp)
448
450
 
449
451
  # Handle user interruption for tool response messages in thread mode
450
452
  if self.media_type == "tool_response":
@@ -465,14 +467,40 @@ class Message(models.Model):
465
467
  ).exclude(
466
468
  media_type__in=['tool_call', 'tool_response']
467
469
  ).order_by('-timestamp').first()
470
+
471
+ # Fallback: any user message in that window, even without reply_to
472
+ if not user_interrupt:
473
+ user_interrupt = self.chat.messages.filter(
474
+ is_outgoing=False,
475
+ timestamp__gt=latest_tool_call_time,
476
+ timestamp__lt=self.timestamp
477
+ ).exclude(
478
+ media_type__in=['tool_call', 'tool_response']
479
+ ).order_by('-timestamp').first()
468
480
 
469
481
  if user_interrupt:
470
- # Found user interrupt - get conversation from that user message
471
- return user_interrupt.as_llm_chat(depth=depth, mode=mode,
472
- system_instruction=system_instruction,
473
- multimodal=multimodal)
474
-
475
- for m in reversed(chain):
482
+ if user_interrupt not in chain:
483
+ chain.append(user_interrupt)
484
+ chain = sorted(chain, key=lambda m: m.timestamp)
485
+ # Ensure tool_call and tool_response are adjacent
486
+ if self.media_type == "tool_response":
487
+ try:
488
+ call_id = self.raw.get('tool_response', {}).get('call_id')
489
+ if call_id:
490
+ tc_idx = next((i for i, m in enumerate(chain)
491
+ if m.media_type == "tool_call"
492
+ and m.raw.get('tool_call', {}).get('id') == call_id), None)
493
+ tr_idx = next((i for i, m in enumerate(chain)
494
+ if m.media_type == "tool_response"
495
+ and m.raw.get('tool_response', {}).get('call_id') == call_id), None)
496
+ if tc_idx is not None and tr_idx is not None and tr_idx != tc_idx + 1:
497
+ # Move tool_response directly after tool_call
498
+ tr = chain.pop(tr_idx)
499
+ chain.insert(tc_idx + 1, tr)
500
+ except Exception:
501
+ pass
502
+
503
+ for m in chain:
476
504
  messages.append(msg_to_dict(m))
477
505
  else:
478
506
  raise ValueError(f"Unknown mode: {mode}")
@@ -17,6 +17,12 @@ import requests
17
17
  from urllib.parse import urljoin
18
18
  from django.utils import timezone
19
19
  from unicom.services.html_inline_images import html_shortlinks_to_base64_images, html_base64_images_to_shortlinks
20
+ from unicom.services.template_renderer import (
21
+ render_template as render_unicom_template,
22
+ build_unicom_message_context,
23
+ extract_variable_keys,
24
+ compute_crm_variables,
25
+ )
20
26
 
21
27
  logger = logging.getLogger(__name__)
22
28
 
@@ -275,6 +281,46 @@ def send_email_message(channel: Channel, params: dict, user: User=None):
275
281
  html_content = convert_text_to_html(text_content)
276
282
  logger.debug("Converted plain text to HTML")
277
283
 
284
+ # Optionally render templates for non-CRM use-cases when explicitly requested.
285
+ render_context = params.get('render_context')
286
+ render_variables = params.get('render_variables') or {}
287
+ render_requested = params.get('render_template') or render_context or render_variables
288
+ if render_requested and html_content:
289
+ crm_variables: dict[str, object] = {}
290
+ requested_keys = extract_variable_keys(html_content)
291
+ if requested_keys:
292
+ # Use first To address (if any) to resolve contact for CRM variables.
293
+ primary_email = None
294
+ to_addrs_for_crm = params.get('to') or to_addrs
295
+ if to_addrs_for_crm:
296
+ primary_email = (to_addrs_for_crm[0] or '').strip()
297
+ crm_variables = compute_crm_variables(requested_keys, primary_email)
298
+
299
+ base_context = render_context or build_unicom_message_context(
300
+ params=params,
301
+ channel={
302
+ 'id': channel.id,
303
+ 'name': channel.name,
304
+ 'platform': channel.platform,
305
+ },
306
+ user={
307
+ 'id': getattr(user, 'id', None),
308
+ 'username': getattr(user, 'username', None),
309
+ 'email': getattr(user, 'email', None),
310
+ } if user else {},
311
+ )
312
+ merged_variables = {}
313
+ merged_variables.update(crm_variables)
314
+ merged_variables.update(render_variables)
315
+ render_result = render_unicom_template(
316
+ html_content,
317
+ base_context=base_context,
318
+ variables=merged_variables,
319
+ )
320
+ if render_result.errors:
321
+ logger.warning("Template rendering errors: %s", "; ".join(render_result.errors))
322
+ html_content = render_result.html
323
+
278
324
  # Add tracking
279
325
  if html_content:
280
326
  html_content = to_inline_png_img(html_content) # Convert FontAwesome to inline images
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from typing import Any, Dict, Mapping
6
+ import re
7
+ from urllib.parse import unquote
8
+
9
+ from django.utils import timezone
10
+ from jinja2 import StrictUndefined, TemplateError
11
+ from jinja2.sandbox import SandboxedEnvironment
12
+
13
+
14
+ # Matches TinyMCE-protected placeholders such as "{{ variables.x }}" that TinyMCE wraps.
15
+ _PROTECTED_PLACEHOLDER_RE = re.compile(r'<!--\s*mce:protected\s+([^>]+?)-->')
16
+
17
+
18
+ def _datetime_format(value, fmt: str = "%Y-%m-%d %H:%M %Z") -> str:
19
+ if not value:
20
+ return ""
21
+ if timezone.is_naive(value): # pragma: no cover - defensive branch
22
+ value = timezone.make_aware(value)
23
+ return timezone.localtime(value).strftime(fmt)
24
+
25
+
26
+ @lru_cache(maxsize=1)
27
+ def get_jinja_environment() -> SandboxedEnvironment:
28
+ """
29
+ Returns a singleton sandboxed environment for rendering email templates.
30
+ Shared between Unicom and Unicrm to keep behavior consistent.
31
+ """
32
+ env = SandboxedEnvironment(
33
+ autoescape=True,
34
+ trim_blocks=True,
35
+ lstrip_blocks=True,
36
+ undefined=StrictUndefined,
37
+ )
38
+ env.filters['datetime'] = _datetime_format
39
+ env.globals.update({
40
+ 'now': timezone.now,
41
+ })
42
+ return env
43
+
44
+
45
+ def unprotect_tinymce_markup(content: str | None) -> str:
46
+ """
47
+ Restore TinyMCE protected placeholders ({{ ... }}) back to plain Jinja markup.
48
+ """
49
+ if not content:
50
+ return content or ''
51
+
52
+ def _restore(match: re.Match[str]) -> str:
53
+ encoded = match.group(1).strip()
54
+ try:
55
+ return unquote(encoded)
56
+ except Exception: # pragma: no cover - defensive
57
+ return encoded
58
+
59
+ return _PROTECTED_PLACEHOLDER_RE.sub(_restore, content)
60
+
61
+
62
+ @dataclass
63
+ class RenderResult:
64
+ html: str
65
+ context: Dict[str, Any]
66
+ variables: Dict[str, Any]
67
+ errors: list[str]
68
+
69
+
70
+ def render_template(
71
+ template_html: str,
72
+ *,
73
+ base_context: Mapping[str, Any] | None = None,
74
+ variables: Mapping[str, Any] | None = None,
75
+ extra_context: Mapping[str, Any] | None = None,
76
+ ) -> RenderResult:
77
+ """
78
+ Render arbitrary HTML with a sandboxed Jinja2 environment.
79
+
80
+ - Does nothing destructive to callers: on template error, returns original HTML and records the error.
81
+ - Callers can pass `variables` to expose as `variables.*` in templates; optional extra context merges in.
82
+ """
83
+ env = get_jinja_environment()
84
+ rendered_context: Dict[str, Any] = {}
85
+ if base_context:
86
+ rendered_context.update(base_context)
87
+ existing_vars = dict(rendered_context.get('variables') or {})
88
+ existing_vars.update(variables or {})
89
+ rendered_context['variables'] = existing_vars
90
+
91
+ if extra_context:
92
+ rendered_context.update(extra_context)
93
+
94
+ template_html = unprotect_tinymce_markup(template_html)
95
+ template = env.from_string(template_html)
96
+ errors: list[str] = []
97
+ try:
98
+ html = template.render(rendered_context)
99
+ except TemplateError as exc:
100
+ errors.append(str(exc))
101
+ html = template_html
102
+ return RenderResult(
103
+ html=html,
104
+ context=rendered_context,
105
+ variables=rendered_context['variables'],
106
+ errors=errors,
107
+ )
108
+
109
+
110
+ def build_unicom_message_context(
111
+ *,
112
+ params: Mapping[str, Any],
113
+ channel: Mapping[str, Any] | None = None,
114
+ user: Mapping[str, Any] | None = None,
115
+ extra: Mapping[str, Any] | None = None,
116
+ ) -> Dict[str, Any]:
117
+ """
118
+ Construct a safe, serialisable context for Unicom standalone messages.
119
+ Intentionally excludes CRM-only data (contacts, unsubscribe links).
120
+ """
121
+ message_ctx = {
122
+ 'subject': params.get('subject'),
123
+ 'text': params.get('text'),
124
+ 'html': params.get('html'),
125
+ 'to': params.get('to'),
126
+ 'cc': params.get('cc'),
127
+ 'bcc': params.get('bcc'),
128
+ 'attachments': params.get('attachments'),
129
+ 'chat_id': params.get('chat_id'),
130
+ 'reply_to_message_id': params.get('reply_to_message_id'),
131
+ 'timestamp': timezone.now(),
132
+ }
133
+ ctx: Dict[str, Any] = {
134
+ 'message': message_ctx,
135
+ 'channel': channel or {},
136
+ 'sender': user or {},
137
+ }
138
+ if extra:
139
+ ctx.update(extra)
140
+ return ctx
141
+
142
+
143
+ _VARIABLE_PLACEHOLDER_RE = re.compile(r"\{\{\s*variables\.([^\s\}]+)\s*\}\}")
144
+
145
+
146
+ def extract_variable_keys(template_html: str | None) -> set[str]:
147
+ """
148
+ Return the set of variable keys referenced as {{ variables.* }} in the template.
149
+ """
150
+ if not template_html:
151
+ return set()
152
+ unprotected = unprotect_tinymce_markup(template_html)
153
+ return {m.group(1) for m in _VARIABLE_PLACEHOLDER_RE.finditer(unprotected)}
154
+
155
+
156
+ def _load_crm_models():
157
+ """
158
+ Dynamically load CRM models if unicrm is installed; otherwise return (None, None).
159
+ """
160
+ try:
161
+ from django.apps import apps
162
+ Contact = apps.get_model('unicrm', 'Contact')
163
+ TemplateVariable = apps.get_model('unicrm', 'TemplateVariable')
164
+ if Contact is None or TemplateVariable is None:
165
+ return None, None
166
+ return Contact, TemplateVariable
167
+ except Exception:
168
+ return None, None
169
+
170
+
171
+ def compute_crm_variables(keys: set[str], contact_email: str | None) -> Dict[str, Any]:
172
+ """
173
+ Best-effort evaluation of CRM TemplateVariables for a contact resolved by email.
174
+ Safe to call when unicrm is not installed (returns {}).
175
+ """
176
+ if not keys or not contact_email:
177
+ return {}
178
+ Contact, TemplateVariable = _load_crm_models()
179
+ if Contact is None or TemplateVariable is None:
180
+ return {}
181
+ contact = Contact.objects.filter(email__iexact=contact_email).first()
182
+ if not contact:
183
+ return {}
184
+ results: Dict[str, Any] = {}
185
+ for variable in TemplateVariable.objects.filter(is_active=True, key__in=keys):
186
+ try:
187
+ results[variable.key] = variable.get_callable()(contact)
188
+ except Exception as exc: # pragma: no cover - defensive
189
+ results[variable.key] = f"<error: {exc}>"
190
+ return results