django-forms-workflows 0.75.1__tar.gz → 0.76.0__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 (223) hide show
  1. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/CHANGELOG.md +17 -11
  2. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/PKG-INFO +1 -3
  3. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/email_backends/gmail_api.py +3 -1
  4. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/sync_ldap_users.py +1 -1
  5. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sso_backends.py +1 -1
  6. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/tasks.py +6 -21
  7. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/views.py +6 -1
  8. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/pyproject.toml +1 -5
  9. django_forms_workflows-0.75.1/django_forms_workflows/reconciliation.py +0 -379
  10. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/LICENSE +0 -0
  11. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/README.md +0 -0
  12. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/__init__.py +0 -0
  13. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/admin.py +0 -0
  14. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/api_urls.py +0 -0
  15. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/api_views.py +0 -0
  16. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/apps.py +0 -0
  17. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/callback_registry.py +0 -0
  18. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/conditions.py +0 -0
  19. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/context_processors.py +0 -0
  20. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/__init__.py +0 -0
  21. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/base.py +0 -0
  22. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/database_source.py +0 -0
  23. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/ldap_source.py +0 -0
  24. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/user_source.py +0 -0
  25. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/diff_views.py +0 -0
  26. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/email_backends/__init__.py +0 -0
  27. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/form_builder_urls.py +0 -0
  28. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/form_builder_views.py +0 -0
  29. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/forms.py +0 -0
  30. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/__init__.py +0 -0
  31. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/api_handler.py +0 -0
  32. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/base.py +0 -0
  33. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/database_handler.py +0 -0
  34. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/email_handler.py +0 -0
  35. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/executor.py +0 -0
  36. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/file_handler.py +0 -0
  37. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/ldap_handler.py +0 -0
  38. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/history.py +0 -0
  39. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/ldap_backend.py +0 -0
  40. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/create_default_templates.py +0 -0
  41. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/pull_forms.py +0 -0
  42. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/push_forms.py +0 -0
  43. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/seed_farm_demo.py +0 -0
  44. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/seed_pcn_form.py +0 -0
  45. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/seed_prefill_sources.py +0 -0
  46. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/sync_ldap_profiles.py +0 -0
  47. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/test_db_connection.py +0 -0
  48. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0001_initial.py +0 -0
  49. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0002_prefillsource_alter_formfield_prefill_source_and_more.py +0 -0
  50. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0003_postsubmissionaction.py +0 -0
  51. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0004_formtemplate.py +0 -0
  52. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0005_advanced_conditional_logic.py +0 -0
  53. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0006_workflowdefinition_visual_workflow_data_and_more.py +0 -0
  54. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0007_add_userprofile_ldap_enhancements.py +0 -0
  55. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0008_add_file_workflow_models.py +0 -0
  56. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0009_add_formfield_readonly.py +0 -0
  57. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0010_add_prefillsource_template_fields.py +0 -0
  58. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0011_postsubmissionaction_email_body_template_and_more.py +0 -0
  59. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0012_add_approval_step_fields.py +0 -0
  60. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0013_add_database_query_key.py +0 -0
  61. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0014_add_form_category.py +0 -0
  62. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0015_add_workflowstage_staged_approvals.py +0 -0
  63. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0016_add_pdf_generation.py +0 -0
  64. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0017_add_notification_batching.py +0 -0
  65. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0018_add_form_category_parent.py +0 -0
  66. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0019_add_ldap_group_profile.py +0 -0
  67. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0020_add_bulk_export_field.py +0 -0
  68. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0021_alter_pendingnotification_scheduled_for_and_more.py +0 -0
  69. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0022_add_bulk_pdf_export.py +0 -0
  70. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0023_add_performance_indexes.py +0 -0
  71. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0024_add_allow_resubmit.py +0 -0
  72. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0025_workflowstage_approve_label.py +0 -0
  73. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0026_alter_formfield_width.py +0 -0
  74. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0027_formsubmission_form_data_gin.py +0 -0
  75. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0028_workflowstagegroupconfig.py +0 -0
  76. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0029_formfield_workflow_stage_fk_drop_groupconfig.py +0 -0
  77. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0030_formfield_phone_type.py +0 -0
  78. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0031_sub_workflows.py +0 -0
  79. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0032_approved_pending_detached_sub_workflow.py +0 -0
  80. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0033_add_reject_parent_to_subworkflowdefinition.py +0 -0
  81. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0034_add_notificationlog.py +0 -0
  82. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0035_migrate_prefill_source_strings.py +0 -0
  83. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0036_add_workflowstage_auto_created.py +0 -0
  84. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0037_migrate_flat_workflows_to_staged.py +0 -0
  85. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0038_remove_legacy_prefill_source_and_approval_step.py +0 -0
  86. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0039_update_help_text.py +0 -0
  87. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0040_migrate_deprecated_features.py +0 -0
  88. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0041_remove_deprecated_fields.py +0 -0
  89. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0042_add_sub_workflow_section_label.py +0 -0
  90. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0043_fix_form_data_decimal_encoding.py +0 -0
  91. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0044_remove_subworkflowinstance_label.py +0 -0
  92. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0045_stageapprovalgroup_workflow_name_label.py +0 -0
  93. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0046_workflow_definition_one_to_many.py +0 -0
  94. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0047_add_hide_approval_history.py +0 -0
  95. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0048_workflowdefinition_trigger_conditions_and_more.py +0 -0
  96. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0049_formfield_multifile_type.py +0 -0
  97. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0050_workflowdefinition_collapse_parallel_stages.py +0 -0
  98. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0051_formdefinition_is_listed.py +0 -0
  99. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0052_dynamic_assignee_and_form_field_notifications.py +0 -0
  100. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0053_stageformfieldnotification_static_emails.py +0 -0
  101. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0054_rename_approved_pending_to_pending_approval.py +0 -0
  102. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0055_add_send_back.py +0 -0
  103. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0056_calculated_formula_spreadsheet.py +0 -0
  104. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0057_add_currency_field_type.py +0 -0
  105. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0058_rename_assignee_email_field_add_lookup_type.py +0 -0
  106. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0059_add_multiselect_list_field_type.py +0 -0
  107. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0060_alter_workflowstage_assignee_lookup_type_helptext.py +0 -0
  108. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0061_add_country_us_state_field_types.py +0 -0
  109. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0062_add_batch_import_to_formdefinition.py +0 -0
  110. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0063_formdefinition_api_enabled_apitoken.py +0 -0
  111. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0064_add_reviewer_groups.py +0 -0
  112. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0065_workflownotification.py +0 -0
  113. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0066_add_notify_submitter_to_workflownotification.py +0 -0
  114. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0067_migrate_legacy_notify_flags.py +0 -0
  115. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0068_drop_legacy_notify_fields.py +0 -0
  116. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0069_add_signature_field_type.py +0 -0
  117. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0070_workflowstage_allow_edit_form_data.py +0 -0
  118. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0071_alter_pendingnotification_notification_type.py +0 -0
  119. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0072_add_change_history.py +0 -0
  120. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0073_add_stage_notify_assignee_on_final_decision.py +0 -0
  121. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0074_add_notification_rule.py +0 -0
  122. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0075_migrate_to_notification_rules.py +0 -0
  123. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0076_update_notification_event_choices.py +0 -0
  124. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0077_drop_legacy_notification_models.py +0 -0
  125. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0078_allow_anonymous_submissions.py +0 -0
  126. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0079_webhookendpoint_webhookdeliverylog.py +0 -0
  127. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0080_add_rating_matrix_address_slider_fields_and_form_controls.py +0 -0
  128. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0081_add_success_page_fields.py +0 -0
  129. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0082_add_document_template_model.py +0 -0
  130. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0083_workflowdefinition_start_trigger.py +0 -0
  131. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0084_add_shared_option_list.py +0 -0
  132. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0085_add_payment_system.py +0 -0
  133. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0086_add_embed_enabled.py +0 -0
  134. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0087_notificationrule_use_triggering_stage.py +0 -0
  135. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0088_notificationrule_body_template_and_events.py +0 -0
  136. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0089_workflowstage_hide_comment_field.py +0 -0
  137. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0090_alter_stageapprovalgroup_options_and_more.py +0 -0
  138. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0091_add_uuid_fields.py +0 -0
  139. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0092_add_user_notification_preference.py +0 -0
  140. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0093_normalize_conditional_operators.py +0 -0
  141. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0094_formfield_show_help_text_in_detail.py +0 -0
  142. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0095_notificationrule_cc_fields.py +0 -0
  143. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0096_notificationlog_cc_bcc_emails.py +0 -0
  144. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0097_notificationlog_delivery_reconciliation.py +0 -0
  145. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/__init__.py +0 -0
  146. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/models.py +0 -0
  147. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/__init__.py +0 -0
  148. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/base.py +0 -0
  149. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/registry.py +0 -0
  150. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/stripe_provider.py +0 -0
  151. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/views.py +0 -0
  152. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/reporting_views.py +0 -0
  153. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/signals.py +0 -0
  154. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sso_urls.py +0 -0
  155. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sso_views.py +0 -0
  156. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/api/swagger-ui-bundle.js +0 -0
  157. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/api/swagger-ui.css +0 -0
  158. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/css/forms.css +0 -0
  159. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/admin_notification_rule_stage_filter.js +0 -0
  160. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/dfw-embed.js +0 -0
  161. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/form-builder.js +0 -0
  162. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/form-enhancements.js +0 -0
  163. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/payment-stripe.js +0 -0
  164. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/signature-pad.js +0 -0
  165. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/js/workflow-builder.js +0 -0
  166. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sync_api.py +0 -0
  167. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sync_views.py +0 -0
  168. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/_sync_diff.html +0 -0
  169. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/diff_forms.html +0 -0
  170. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/form_builder.html +0 -0
  171. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/formdef_change_form.html +0 -0
  172. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/formdefinition/change_list.html +0 -0
  173. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/sync_import.html +0 -0
  174. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/sync_pull.html +0 -0
  175. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/sync_push.html +0 -0
  176. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/admin/django_forms_workflows/workflow_builder.html +0 -0
  177. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/_category_node.html +0 -0
  178. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/_field_value.html +0 -0
  179. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/_form_data_rows.html +0 -0
  180. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/analytics_dashboard.html +0 -0
  181. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/api/docs.html +0 -0
  182. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/approval_inbox.html +0 -0
  183. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/approve.html +0 -0
  184. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/base.html +0 -0
  185. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/batch_import_result.html +0 -0
  186. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/completed_approvals.html +0 -0
  187. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/discard_draft_confirm.html +0 -0
  188. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/embed_base.html +0 -0
  189. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/embed_success.html +0 -0
  190. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_categories.html +0 -0
  191. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_embed.html +0 -0
  192. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_list.html +0 -0
  193. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_submit.html +0 -0
  194. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/my_submissions.html +0 -0
  195. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/notification_preferences.html +0 -0
  196. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/payment_collect.html +0 -0
  197. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/payment_error.html +0 -0
  198. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/public_submission_confirmation.html +0 -0
  199. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/rate_limited.html +0 -0
  200. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/reassign_task.html +0 -0
  201. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/resubmit_confirm.html +0 -0
  202. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/sso/login.html +0 -0
  203. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/sub_workflow_detail.html +0 -0
  204. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_bulk_pdf.html +0 -0
  205. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_detail.html +0 -0
  206. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_pdf.html +0 -0
  207. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_success.html +0 -0
  208. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/withdraw_confirm.html +0 -0
  209. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/approval_notification.html +0 -0
  210. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/approval_reminder.html +0 -0
  211. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/approval_request.html +0 -0
  212. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/email_styles.html +0 -0
  213. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/escalation_notification.html +0 -0
  214. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/notification_digest.html +0 -0
  215. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/rejection_notification.html +0 -0
  216. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/submission_notification.html +0 -0
  217. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/registration/login.html +0 -0
  218. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templatetags/__init__.py +0 -0
  219. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templatetags/forms_workflows_tags.py +0 -0
  220. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/urls.py +0 -0
  221. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/utils.py +0 -0
  222. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/workflow_builder_views.py +0 -0
  223. {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/workflow_engine.py +0 -0
@@ -7,19 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.75.1] - 2026-06-08
10
+ ## [0.76.0] - 2026-06-09
11
11
 
12
- ### Fixed
13
- - **Removed deployment-specific defaults from the reconciliation module.**
14
- 0.75.0 shipped a consuming institution's GCP project, BigQuery table,
15
- sender address, and staff alert email addresses as hard-coded fallbacks in
16
- `reconciliation.py`/`tasks.py`. These never belonged in a reusable package.
17
- All such values now default to empty/generic and MUST be supplied by the
18
- consuming project via `settings.EMAIL_RECONCILIATION`. Reconciliation no-ops
19
- with a clear message if `bigquery_project`/`bigquery_table` are unset.
20
- **0.75.0 is yanked; use 0.75.1.**
12
+ ### Removed
13
+ - **BigQuery delivery reconciliation.** Removed `reconciliation.py`, the
14
+ `reconcile_email_delivery` Celery task, and the optional `reconciliation`
15
+ extra (`google-cloud-bigquery`). Delivery truth no longer comes from the
16
+ Workspace Gmail log: mail is moving off the Gmail API onto an authenticated
17
+ ESP (Mailjet) whose own event feed reports delivered/bounced/blocked
18
+ directly.
19
+ - Consuming projects should drop the `EMAIL_RECONCILIATION` setting and any
20
+ Celery beat entry for `django_forms_workflows.reconcile_email_delivery`.
21
+
22
+ ### Retained
23
+ - The `NotificationLog` delivery-tracking columns (`rfc2822_message_id`,
24
+ `delivery_state`, `delivery_checked_at`, `delivery_detail`) and the
25
+ Message-ID stamping are kept — they are ESP-agnostic and will be repopulated
26
+ from the Mailjet event feed. No migration change.
21
27
 
22
- ## [0.75.0] - 2026-06-08
28
+ ## [0.75.1] - 2026-06-08
23
29
 
24
30
  ### Added
25
31
  - **Email delivery reconciliation against the Google Workspace Gmail log.**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-forms-workflows
3
- Version: 0.75.1
3
+ Version: 0.76.0
4
4
  Summary: Enterprise-grade, database-driven form builder with approval workflows and external data integration
5
5
  License: LGPL-3.0-only
6
6
  License-File: LICENSE
@@ -28,7 +28,6 @@ Provides-Extra: pdf
28
28
  Provides-Extra: picklists
29
29
  Provides-Extra: postgresql
30
30
  Provides-Extra: qr
31
- Provides-Extra: reconciliation
32
31
  Provides-Extra: saml
33
32
  Provides-Extra: sso
34
33
  Requires-Dist: Django (>=5.1,<7.0)
@@ -41,7 +40,6 @@ Requires-Dist: django-localflavor (>=4.0) ; extra == "picklists" or extra == "al
41
40
  Requires-Dist: django-nested-admin (>=4.0)
42
41
  Requires-Dist: google-api-python-client (>=2.100) ; extra == "gmail" or extra == "all"
43
42
  Requires-Dist: google-auth (>=2.20) ; extra == "gmail" or extra == "all"
44
- Requires-Dist: google-cloud-bigquery (>=3.11) ; extra == "reconciliation"
45
43
  Requires-Dist: markdown (>=3.4) ; extra == "markdown" or extra == "all"
46
44
  Requires-Dist: mssql-django (>=1.3) ; extra == "mssql" or extra == "all"
47
45
  Requires-Dist: mysqlclient (>=2.2) ; extra == "mysql" or extra == "all"
@@ -267,7 +267,9 @@ class GmailAPIBackend(BaseEmailBackend):
267
267
  # one we never see. Skip headers already set above so a caller can't
268
268
  # accidentally duplicate To/From/Subject.
269
269
  already_set = {k.lower() for k in msg.keys()}
270
- for header, value in (getattr(email_message, "extra_headers", None) or {}).items():
270
+ for header, value in (
271
+ getattr(email_message, "extra_headers", None) or {}
272
+ ).items():
271
273
  if not value or header.lower() in already_set:
272
274
  continue
273
275
  msg[header] = value
@@ -39,7 +39,7 @@ class Command(BaseCommand):
39
39
  parser.add_argument(
40
40
  "--ou",
41
41
  type=str,
42
- help="Organisational Unit to search within (e.g. 'OU=Faculty,DC=sjcme,DC=edu'). "
42
+ help="Organisational Unit to search within (e.g. 'OU=Faculty,DC=example,DC=com'). "
43
43
  "If not provided, uses the default LDAP search base.",
44
44
  )
45
45
  parser.add_argument(
@@ -370,7 +370,7 @@ def link_to_existing_user(backend, details, user=None, *args, **kwargs):
370
370
 
371
371
  # Determine username to look for
372
372
  if sso_settings.get("username_from_email", True):
373
- # Extract username from email prefix (e.g., "mdavis@sjcme.edu" -> "mdavis")
373
+ # Extract username from email prefix (e.g., "jdoe@example.com" -> "jdoe")
374
374
  username = email.split("@")[0].lower()
375
375
  else:
376
376
  username = details.get("username", "").lower()
@@ -15,11 +15,11 @@ import hmac
15
15
  import json
16
16
  import logging
17
17
  from calendar import monthrange
18
- from email.utils import make_msgid
19
18
  from collections import defaultdict
20
19
  from collections.abc import Iterable
21
20
  from datetime import datetime, timedelta
22
21
  from datetime import time as dt_time
22
+ from email.utils import make_msgid
23
23
 
24
24
  import requests
25
25
  from django.conf import settings
@@ -1496,12 +1496,11 @@ def send_notification_rules(
1496
1496
  # successfully delivered to. Only "sent" entries dedup — "failed" entries
1497
1497
  # should be retried.
1498
1498
  #
1499
- # Exception: rows the delivery-reconciliation sweep marked
1500
- # ``delivery_state='retried'`` are deliberately excluded here. Such a row
1501
- # has ``status='sent'`` (the Gmail API send call succeeded) but the
1502
- # Workspace log showed it was never actually relayed, so reconciliation
1503
- # re-dispatched this task precisely to resend to that recipient. Leaving it
1504
- # in ``already_sent`` would make the retry a silent no-op.
1499
+ # Exception: rows a delivery sweep marked ``delivery_state='retried'`` are
1500
+ # deliberately excluded here. Such a row has ``status='sent'`` (the send call
1501
+ # succeeded) but a delivery check showed it was never actually delivered, so
1502
+ # the sweep re-dispatched this task precisely to resend to that recipient.
1503
+ # Leaving it in ``already_sent`` would make the retry a silent no-op.
1505
1504
  _sent_rows = list(
1506
1505
  NotificationLog.objects.filter(
1507
1506
  submission_id=submission_id,
@@ -1724,17 +1723,3 @@ def send_notification_rules(
1724
1723
  submission_id=submission_id,
1725
1724
  cc=email_cc,
1726
1725
  )
1727
-
1728
-
1729
- @shared_task(name="django_forms_workflows.reconcile_email_delivery")
1730
- def reconcile_email_delivery() -> str:
1731
- """Reconcile recent notification sends against the Workspace Gmail log.
1732
-
1733
- Confirms real delivery, auto-retries silent drops/soft failures, and alerts
1734
- on hard bounces and retry exhaustion. Scheduled by Celery beat; see
1735
- ``reconciliation.run_reconciliation`` for the logic and the
1736
- ``EMAIL_RECONCILIATION`` setting for configuration.
1737
- """
1738
- from .reconciliation import run_reconciliation
1739
-
1740
- return run_reconciliation()
@@ -178,7 +178,12 @@ def _build_grouped_forms(forms):
178
178
 
179
179
  if uncategorised:
180
180
  top_level.append(
181
- {"category": None, "forms": uncategorised, "children": [], "total_count": len(uncategorised)}
181
+ {
182
+ "category": None,
183
+ "forms": uncategorised,
184
+ "children": [],
185
+ "total_count": len(uncategorised),
186
+ }
182
187
  )
183
188
 
184
189
  return top_level
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "django-forms-workflows"
3
- version = "0.75.1"
3
+ version = "0.76.0"
4
4
  description = "Enterprise-grade, database-driven form builder with approval workflows and external data integration"
5
5
  license = "LGPL-3.0-only"
6
6
  readme = "README.md"
@@ -51,9 +51,6 @@ mysqlclient = { version = ">=2.2", optional = true }
51
51
  google-auth = { version = ">=2.20", optional = true }
52
52
  google-api-python-client = { version = ">=2.100", optional = true }
53
53
 
54
- # Optional delivery reconciliation against the Workspace Gmail log (BigQuery)
55
- google-cloud-bigquery = { version = ">=3.11", optional = true }
56
-
57
54
  # Optional PDF generation
58
55
  weasyprint = { version = ">=60.0", optional = true }
59
56
 
@@ -89,7 +86,6 @@ mssql = ["mssql-django", "pyodbc"]
89
86
  postgresql = ["psycopg2-binary"]
90
87
  mysql = ["mysqlclient"]
91
88
  gmail = ["google-auth", "google-api-python-client"]
92
- reconciliation = ["google-cloud-bigquery"]
93
89
  pdf = ["weasyprint"]
94
90
  excel = ["openpyxl"]
95
91
  qr = ["segno"]
@@ -1,379 +0,0 @@
1
- """Delivery reconciliation against the Google Workspace Gmail log (BigQuery).
2
-
3
- Why this exists
4
- ---------------
5
- The Gmail API ``messages().send()`` call returns success synchronously even when
6
- the message is subsequently dropped and never relayed. ``NotificationLog.status``
7
- therefore records only "the send call did not raise", not "the recipient got it".
8
- The single authoritative source of *delivery* truth for Gmail-sent mail is the
9
- Workspace Gmail log, exported to BigQuery, which records the real relay outcome
10
- per message (``gmail.event_info.success``, SMTP reply codes, etc.).
11
-
12
- This module joins our ``NotificationLog`` rows to that log on the RFC 2822
13
- Message-ID we stamp at send time (``NotificationLog.rfc2822_message_id`` ↔
14
- ``gmail.message_info.rfc2822_message_id``), classifies each send's real fate,
15
- and then:
16
-
17
- * marks confirmed deliveries ``delivery_state='delivered'``;
18
- * auto-retries soft failures and silent drops (re-dispatching
19
- ``send_notification_rules`` — the same idempotent path the admin "Retry"
20
- action uses) up to a per-recipient attempt cap;
21
- * alerts on hard bounces (no retry) and on retry exhaustion.
22
-
23
- It is invoked on a schedule by the ``reconcile_email_delivery`` Celery task.
24
-
25
- Configuration (``settings.EMAIL_RECONCILIATION``)::
26
-
27
- EMAIL_RECONCILIATION = {
28
- "enabled": True,
29
- "bigquery_project": "your-gcp-project",
30
- "bigquery_table": "your-gcp-project.workspace_logs.activity",
31
- "service_account_json": "/path/to/key.json", # or:
32
- "service_account_base64": "<base64 json>",
33
- "grace_minutes": 45, # don't judge a send before mail can settle
34
- "lookback_hours": 72, # how far back to reconcile
35
- "max_attempts": 3, # total sends per (submission, event, recipient)
36
- "batch_limit": 1000, # max log rows examined per run
37
- "alert_recipients": ["alerts@example.com"],
38
- }
39
- """
40
-
41
- from __future__ import annotations
42
-
43
- import base64
44
- import json
45
- import logging
46
- from collections import defaultdict
47
- from datetime import timedelta
48
-
49
- from django.conf import settings
50
- from django.core.mail import EmailMultiAlternatives
51
- from django.utils import timezone
52
-
53
- from .models import NotificationLog
54
-
55
- logger = logging.getLogger(__name__)
56
-
57
- # Generic fallbacks. Every deployment-specific value (GCP project, BigQuery
58
- # table, service-account credentials, alert recipients) MUST be supplied by the
59
- # consuming project via settings.EMAIL_RECONCILIATION — nothing institution-
60
- # specific belongs in this reusable package.
61
- _DEFAULTS = {
62
- "enabled": False,
63
- "bigquery_project": "",
64
- "bigquery_table": "",
65
- "service_account_json": "",
66
- "service_account_base64": "",
67
- "grace_minutes": 45,
68
- "lookback_hours": 72,
69
- "max_attempts": 3,
70
- "batch_limit": 1000,
71
- "alert_recipients": [],
72
- }
73
-
74
- _BQ_READONLY_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly"
75
-
76
-
77
- def _config() -> dict:
78
- cfg = dict(_DEFAULTS)
79
- cfg.update(getattr(settings, "EMAIL_RECONCILIATION", {}) or {})
80
- return cfg
81
-
82
-
83
- def _get_bq_client(cfg: dict):
84
- """Build a BigQuery client authenticated with the configured service account.
85
-
86
- Imports are local so the package still imports where google-cloud-bigquery
87
- is not installed (e.g. the web pods, which never reconcile).
88
- """
89
- from google.cloud import bigquery
90
- from google.oauth2 import service_account
91
-
92
- project = cfg["bigquery_project"]
93
- if cfg.get("service_account_json"):
94
- creds = service_account.Credentials.from_service_account_file(
95
- cfg["service_account_json"], scopes=[_BQ_READONLY_SCOPE]
96
- )
97
- elif cfg.get("service_account_base64"):
98
- info = json.loads(base64.b64decode(cfg["service_account_base64"]).decode("utf-8"))
99
- creds = service_account.Credentials.from_service_account_info(
100
- info, scopes=[_BQ_READONLY_SCOPE]
101
- )
102
- else:
103
- creds = None # fall back to Application Default Credentials
104
- return bigquery.Client(project=project, credentials=creds)
105
-
106
-
107
- def _query_delivery_outcomes(cfg: dict, message_ids: list[str], since_usec: int):
108
- """Query the Workspace Gmail log for the given Message-IDs.
109
-
110
- Returns ``(outcomes, seen_msgids)`` where ``outcomes`` is keyed by
111
- ``(msgid, recipient_lower)`` → dict(any_success, max_smtp_code, reason) and
112
- ``seen_msgids`` is the set of Message-IDs that appeared in the log at all
113
- (so a recipient absent from a *seen* message is distinguishable from a
114
- message that never relayed at all).
115
- """
116
- from google.cloud import bigquery
117
-
118
- table = cfg["bigquery_table"]
119
- sql = f"""
120
- SELECT
121
- g.message_info.rfc2822_message_id AS msgid,
122
- LOWER(dest.address) AS recipient,
123
- LOGICAL_OR(g.event_info.success) AS any_success,
124
- MAX(g.message_info.connection_info.smtp_reply_code) AS max_smtp_code,
125
- -- smtp_response_reason is an INT64 enum in this schema, not text.
126
- STRING_AGG(
127
- DISTINCT CAST(
128
- NULLIF(g.message_info.connection_info.smtp_response_reason, 0) AS STRING
129
- )
130
- ) AS smtp_reasons
131
- FROM `{table}` t,
132
- UNNEST([t.gmail]) g,
133
- UNNEST(g.message_info.destination) dest
134
- WHERE t.record_type = 'gmail'
135
- AND t.event_name = 'delivery'
136
- AND t.time_usec >= @since_usec
137
- AND g.message_info.rfc2822_message_id IN UNNEST(@msgids)
138
- GROUP BY msgid, recipient
139
- """
140
- job_config = bigquery.QueryJobConfig(
141
- query_parameters=[
142
- bigquery.ScalarQueryParameter("since_usec", "INT64", since_usec),
143
- bigquery.ArrayQueryParameter("msgids", "STRING", message_ids),
144
- ]
145
- )
146
- outcomes: dict[tuple[str, str], dict] = {}
147
- seen_msgids: set[str] = set()
148
- for row in _get_bq_client(cfg).query(sql, job_config=job_config).result():
149
- seen_msgids.add(row["msgid"])
150
- outcomes[(row["msgid"], (row["recipient"] or "").lower())] = {
151
- "any_success": bool(row["any_success"]),
152
- "max_smtp_code": row["max_smtp_code"],
153
- "reason": row["smtp_reasons"] or "",
154
- }
155
- return outcomes, seen_msgids
156
-
157
-
158
- def _classify(msgid: str, recipient: str, outcomes: dict, seen_msgids: set):
159
- """Return ``(state, detail)`` for one send.
160
-
161
- state ∈ {"delivered", "bounced", "soft"}:
162
- * delivered — log shows a successful relay to this recipient
163
- * bounced — permanent failure (SMTP 5xx); do NOT auto-retry
164
- * soft — transient failure, or no relay record at all (silent drop)
165
- """
166
- rec = outcomes.get((msgid, recipient))
167
- if rec is None:
168
- if msgid in seen_msgids:
169
- return "soft", "message relayed but no record for this recipient"
170
- return "soft", "no delivery record in Workspace log (possible silent drop)"
171
-
172
- code = rec["max_smtp_code"]
173
- reason = rec["reason"]
174
- # A confirmed successful relay wins over a stale high SMTP code: the log
175
- # records every hop, so a recipient can show a transient 451 deferral on
176
- # one event and a 250 success on a later one (any_success=True). Only treat
177
- # it as a bounce when there is NO success and the code is permanent (5xx).
178
- if rec["any_success"]:
179
- return "delivered", f"relayed{(' (reason ' + reason + ')') if reason else ''}"
180
- if code is not None and code >= 500:
181
- return "bounced", f"smtp {code}{(' reason ' + reason) if reason else ''}"
182
- if code is not None and 400 <= code < 500:
183
- return "soft", f"smtp {code} transient{(' reason ' + reason) if reason else ''}"
184
- return "soft", f"not relayed{(' (reason ' + reason + ')') if reason else ''}"
185
-
186
-
187
- def run_reconciliation() -> str:
188
- """Reconcile recently-sent notifications against the Workspace Gmail log.
189
-
190
- Safe to run repeatedly: rows advance from ``unconfirmed`` to a terminal
191
- delivery_state, so each run only does work for the latest grace window plus
192
- anything still in flight. Returns a human-readable summary string.
193
- """
194
- cfg = _config()
195
- if not cfg["enabled"]:
196
- return "email reconciliation disabled (EMAIL_RECONCILIATION['enabled'] is False)"
197
- if not cfg["bigquery_project"] or not cfg["bigquery_table"]:
198
- logger.warning(
199
- "email reconciliation enabled but bigquery_project/table not "
200
- "configured; nothing to do"
201
- )
202
- return "email reconciliation not configured (missing bigquery_project/table)"
203
-
204
- now = timezone.now()
205
- grace_cutoff = now - timedelta(minutes=cfg["grace_minutes"])
206
- lookback_cutoff = now - timedelta(hours=cfg["lookback_hours"])
207
-
208
- candidates = list(
209
- NotificationLog.objects.filter(
210
- status="sent",
211
- delivery_state="unconfirmed",
212
- created_at__lt=grace_cutoff,
213
- created_at__gte=lookback_cutoff,
214
- )
215
- .exclude(rfc2822_message_id="")
216
- .order_by("created_at")[: cfg["batch_limit"]]
217
- )
218
- if not candidates:
219
- return "reconciliation: no unconfirmed sends in window"
220
-
221
- msgids = sorted({c.rfc2822_message_id for c in candidates})
222
- # Give the log query a little extra lookback margin (mail can be logged a
223
- # few minutes after our created_at) — 6h cushion before the oldest row.
224
- since_usec = int((lookback_cutoff - timedelta(hours=6)).timestamp() * 1_000_000)
225
-
226
- try:
227
- outcomes, seen_msgids = _query_delivery_outcomes(cfg, msgids, since_usec)
228
- except Exception:
229
- logger.exception("reconciliation: BigQuery query failed; aborting run")
230
- return "reconciliation: BigQuery query failed (see logs)"
231
-
232
- counters: dict[str, int] = defaultdict(int)
233
- retries: dict[tuple[int, str], set[str]] = {}
234
- alerts: list[dict] = []
235
- updated: list[NotificationLog] = []
236
-
237
- for row in candidates:
238
- recipient = (row.recipient_email or "").lower()
239
- state, detail = _classify(row.rfc2822_message_id, recipient, outcomes, seen_msgids)
240
- row.delivery_checked_at = now
241
- row.delivery_detail = detail[:500]
242
-
243
- if state == "delivered":
244
- row.delivery_state = "delivered"
245
- counters["delivered"] += 1
246
- elif state == "bounced":
247
- row.delivery_state = "bounced"
248
- counters["bounced"] += 1
249
- alerts.append(_alert_entry(row, "hard bounce", detail))
250
- else: # soft — retry if we can and the cap allows
251
- attempts = (
252
- NotificationLog.objects.filter(
253
- submission_id=row.submission_id,
254
- notification_type=row.notification_type,
255
- recipient_email=row.recipient_email,
256
- ).count()
257
- if row.submission_id
258
- else None
259
- )
260
- can_retry = (
261
- row.submission_id is not None
262
- and row.notification_type
263
- and attempts is not None
264
- and attempts < cfg["max_attempts"]
265
- )
266
- if can_retry:
267
- row.delivery_state = "retried"
268
- retries.setdefault(
269
- (row.submission_id, row.notification_type), set()
270
- ).add(row.recipient_email)
271
- counters["retried"] += 1
272
- else:
273
- row.delivery_state = "exhausted"
274
- counters["exhausted"] += 1
275
- why = (
276
- "no submission/event to retry"
277
- if not (row.submission_id and row.notification_type)
278
- else f"retry cap reached ({attempts}/{cfg['max_attempts']})"
279
- )
280
- alerts.append(_alert_entry(row, f"undelivered — {why}", detail))
281
- updated.append(row)
282
-
283
- if updated:
284
- NotificationLog.objects.bulk_update(
285
- updated, ["delivery_state", "delivery_checked_at", "delivery_detail"]
286
- )
287
-
288
- # Dispatch retries once per (submission, event) — send_notification_rules
289
- # re-resolves recipients and its idempotency guard now lets the rows we
290
- # marked 'retried' through while still skipping confirmed/in-flight ones.
291
- dispatched = 0
292
- if retries:
293
- from .tasks import send_notification_rules
294
-
295
- for (submission_id, event) in retries:
296
- try:
297
- send_notification_rules.delay(submission_id, event)
298
- except Exception:
299
- try:
300
- send_notification_rules(submission_id, event)
301
- except Exception:
302
- logger.exception(
303
- "reconciliation: retry dispatch failed for submission %s "
304
- "event %s",
305
- submission_id,
306
- event,
307
- )
308
- continue
309
- dispatched += 1
310
-
311
- if alerts:
312
- _send_alert(cfg, alerts, dict(counters))
313
-
314
- summary = (
315
- f"reconciliation: examined {len(candidates)} send(s) — "
316
- f"delivered={counters['delivered']}, retried={counters['retried']} "
317
- f"({dispatched} dispatch task(s)), bounced={counters['bounced']}, "
318
- f"exhausted={counters['exhausted']}"
319
- )
320
- logger.info(summary)
321
- return summary
322
-
323
-
324
- def _alert_entry(row: NotificationLog, kind: str, detail: str) -> dict:
325
- return {
326
- "kind": kind,
327
- "recipient": row.recipient_email,
328
- "subject": row.subject,
329
- "notification_type": row.notification_type,
330
- "submission_id": row.submission_id,
331
- "created_at": row.created_at,
332
- "detail": detail,
333
- }
334
-
335
-
336
- def _send_alert(cfg: dict, alerts: list[dict], counters: dict) -> None:
337
- """Email the configured staff list about undelivered/bounced notifications.
338
-
339
- Sent directly (no NotificationLog row) so the alert itself is never
340
- reconciled or retried into a loop. Failures are swallowed — the summary is
341
- also logged at WARNING for the k8s log trail.
342
- """
343
- recipients = [e for e in cfg.get("alert_recipients", []) if e]
344
- if not recipients:
345
- return
346
-
347
- lines = [
348
- "Email delivery reconciliation found notifications that were NOT delivered.",
349
- "",
350
- f"Hard bounces (no retry): {counters.get('bounced', 0)}",
351
- f"Undelivered, retries exhausted: {counters.get('exhausted', 0)}",
352
- f"Auto-retried this run: {counters.get('retried', 0)}",
353
- f"Confirmed delivered this run: {counters.get('delivered', 0)}",
354
- "",
355
- "Affected messages:",
356
- ]
357
- for a in alerts[:50]:
358
- when = a["created_at"].strftime("%Y-%m-%d %H:%M") if a["created_at"] else "?"
359
- lines.append(
360
- f" • [{a['kind']}] {a['recipient']} — \"{a['subject']}\" "
361
- f"(type={a['notification_type']}, submission={a['submission_id']}, "
362
- f"sent {when}) :: {a['detail']}"
363
- )
364
- if len(alerts) > 50:
365
- lines.append(f" … and {len(alerts) - 50} more.")
366
- body = "\n".join(lines)
367
-
368
- from_addr = getattr(settings, "DEFAULT_FROM_EMAIL", "no-reply@localhost")
369
- subject = (
370
- f"[forms] Email delivery alert: "
371
- f"{counters.get('bounced', 0) + counters.get('exhausted', 0)} undelivered"
372
- )
373
- try:
374
- EmailMultiAlternatives(
375
- subject=subject, body=body, from_email=from_addr, to=recipients
376
- ).send(fail_silently=True)
377
- except Exception:
378
- logger.exception("reconciliation: failed to send alert email")
379
- logger.warning("reconciliation alert:\n%s", body)