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.
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/CHANGELOG.md +17 -11
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/PKG-INFO +1 -3
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/email_backends/gmail_api.py +3 -1
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/sync_ldap_users.py +1 -1
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sso_backends.py +1 -1
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/tasks.py +6 -21
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/views.py +6 -1
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/pyproject.toml +1 -5
- django_forms_workflows-0.75.1/django_forms_workflows/reconciliation.py +0 -379
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/LICENSE +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/README.md +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/admin.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/api_urls.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/api_views.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/apps.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/callback_registry.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/conditions.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/context_processors.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/base.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/database_source.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/ldap_source.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/data_sources/user_source.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/diff_views.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/email_backends/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/form_builder_urls.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/form_builder_views.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/forms.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/api_handler.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/base.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/database_handler.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/email_handler.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/executor.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/file_handler.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/handlers/ldap_handler.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/history.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/ldap_backend.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/create_default_templates.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/pull_forms.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/push_forms.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/seed_farm_demo.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/seed_pcn_form.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/seed_prefill_sources.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/sync_ldap_profiles.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/management/commands/test_db_connection.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0001_initial.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0003_postsubmissionaction.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0004_formtemplate.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0005_advanced_conditional_logic.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0007_add_userprofile_ldap_enhancements.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0008_add_file_workflow_models.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0009_add_formfield_readonly.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0010_add_prefillsource_template_fields.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0012_add_approval_step_fields.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0013_add_database_query_key.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0014_add_form_category.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0015_add_workflowstage_staged_approvals.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0016_add_pdf_generation.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0017_add_notification_batching.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0018_add_form_category_parent.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0019_add_ldap_group_profile.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0020_add_bulk_export_field.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0022_add_bulk_pdf_export.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0023_add_performance_indexes.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0024_add_allow_resubmit.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0025_workflowstage_approve_label.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0026_alter_formfield_width.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0027_formsubmission_form_data_gin.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0028_workflowstagegroupconfig.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0030_formfield_phone_type.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0031_sub_workflows.py +0 -0
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0034_add_notificationlog.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0035_migrate_prefill_source_strings.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0036_add_workflowstage_auto_created.py +0 -0
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0039_update_help_text.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0040_migrate_deprecated_features.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0041_remove_deprecated_fields.py +0 -0
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0044_remove_subworkflowinstance_label.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0045_stageapprovalgroup_workflow_name_label.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0047_add_hide_approval_history.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0049_formfield_multifile_type.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0050_workflowdefinition_collapse_parallel_stages.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0051_formdefinition_is_listed.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0053_stageformfieldnotification_static_emails.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0055_add_send_back.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0056_calculated_formula_spreadsheet.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0057_add_currency_field_type.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0063_formdefinition_api_enabled_apitoken.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0064_add_reviewer_groups.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0065_workflownotification.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0067_migrate_legacy_notify_flags.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0068_drop_legacy_notify_fields.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0069_add_signature_field_type.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0071_alter_pendingnotification_notification_type.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0072_add_change_history.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0074_add_notification_rule.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0075_migrate_to_notification_rules.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0076_update_notification_event_choices.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0077_drop_legacy_notification_models.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0078_allow_anonymous_submissions.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0079_webhookendpoint_webhookdeliverylog.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0081_add_success_page_fields.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0082_add_document_template_model.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0083_workflowdefinition_start_trigger.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0084_add_shared_option_list.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0085_add_payment_system.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0086_add_embed_enabled.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0087_notificationrule_use_triggering_stage.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0089_workflowstage_hide_comment_field.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0091_add_uuid_fields.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0092_add_user_notification_preference.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0093_normalize_conditional_operators.py +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0095_notificationrule_cc_fields.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0096_notificationlog_cc_bcc_emails.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/0097_notificationlog_delivery_reconciliation.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/migrations/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/models.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/base.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/registry.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/stripe_provider.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/payments/views.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/reporting_views.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/signals.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sso_urls.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sso_views.py +0 -0
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/static/django_forms_workflows/css/forms.css +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sync_api.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/sync_views.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/_category_node.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/_field_value.html +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/analytics_dashboard.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/api/docs.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/approval_inbox.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/approve.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/base.html +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/completed_approvals.html +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/embed_base.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/embed_success.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_categories.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_embed.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_list.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/form_submit.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/my_submissions.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/notification_preferences.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/payment_collect.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/payment_error.html +0 -0
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/rate_limited.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/reassign_task.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/resubmit_confirm.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/sso/login.html +0 -0
- {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
- {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
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_detail.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_pdf.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/submission_success.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/django_forms_workflows/withdraw_confirm.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/approval_notification.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/approval_reminder.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/approval_request.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/email_styles.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/escalation_notification.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/notification_digest.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/rejection_notification.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/emails/submission_notification.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templates/registration/login.html +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templatetags/__init__.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/templatetags/forms_workflows_tags.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/urls.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/utils.py +0 -0
- {django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/workflow_builder_views.py +0 -0
- {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.
|
|
10
|
+
## [0.76.0] - 2026-06-09
|
|
11
11
|
|
|
12
|
-
###
|
|
13
|
-
- **
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
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=
|
|
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., "
|
|
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()
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/tasks.py
RENAMED
|
@@ -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
|
|
1500
|
-
#
|
|
1501
|
-
#
|
|
1502
|
-
#
|
|
1503
|
-
#
|
|
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()
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/views.py
RENAMED
|
@@ -178,7 +178,12 @@ def _build_grouped_forms(forms):
|
|
|
178
178
|
|
|
179
179
|
if uncategorised:
|
|
180
180
|
top_level.append(
|
|
181
|
-
{
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/__init__.py
RENAMED
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/admin.py
RENAMED
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/api_urls.py
RENAMED
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/api_views.py
RENAMED
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/apps.py
RENAMED
|
File without changes
|
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/conditions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/diff_views.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/forms.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_forms_workflows-0.75.1 → django_forms_workflows-0.76.0}/django_forms_workflows/history.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|