trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Celery tasks for the notifications app (ADR-0075 §F durable-execution).
|
|
2
|
+
|
|
3
|
+
Three Beat-scheduled tasks:
|
|
4
|
+
|
|
5
|
+
- ``drain_notification_emails`` — every 30 s, finds Notification rows with
|
|
6
|
+
``email_pending=True`` older than the 5-min orphan window, renders an email
|
|
7
|
+
for each, sends via SMTP, and updates delivery state. Best-effort — broker
|
|
8
|
+
outage logs but doesn't propagate. Re-checks the recipient's account and
|
|
9
|
+
project-membership status at drain time, not just at creation time (#3523,
|
|
10
|
+
#3675) — a row queued before an off-boarding or a project removal is
|
|
11
|
+
retired unsent rather than delivered on a stale grant.
|
|
12
|
+
|
|
13
|
+
- ``archive_old_notifications`` — nightly, sets ``is_archived=True`` on any
|
|
14
|
+
Notification older than 90 days that has ``is_read=True``. Keeps the
|
|
15
|
+
index on ``(recipient, is_read, -created_at)`` shallow for the unread
|
|
16
|
+
query path.
|
|
17
|
+
|
|
18
|
+
- ``detect_stale_tasks`` — nightly (ADR-0200), scans every project for
|
|
19
|
+
non-terminal tasks that have sat in their current status past the project's
|
|
20
|
+
``stale_task_threshold_days`` (default 7) and nudges the assignee via the
|
|
21
|
+
#639 event-notification rail. Idempotent: dedupes against existing unread
|
|
22
|
+
``task.stale`` notifications, so a re-run creates zero duplicates.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import html
|
|
29
|
+
import logging
|
|
30
|
+
import textwrap
|
|
31
|
+
from datetime import timedelta
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
from trueppm_api.core.idempotent import idempotent_task
|
|
35
|
+
|
|
36
|
+
from .delivery_limits import (
|
|
37
|
+
EMAIL_MAX_BATCH_SIZE,
|
|
38
|
+
beat_ticks_per_minute,
|
|
39
|
+
per_tick_cap,
|
|
40
|
+
reserve_send_budget,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from django.core.mail.backends.base import BaseEmailBackend
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Locked constants for email delivery — kept in this module so they're
|
|
50
|
+
# adjacent to the drain task that owns them.
|
|
51
|
+
EMAIL_MAX_RETRIES = 3
|
|
52
|
+
EMAIL_ORPHAN_WINDOW_MINUTES = 5 # ADR-0075 §F durable-execution checklist item 3
|
|
53
|
+
# Fallback cap per drain tick, when no operator limit applies. Re-exported from
|
|
54
|
+
# delivery_limits, which every mail path now shares (#2887 item 3).
|
|
55
|
+
EMAIL_BATCH_SIZE = EMAIL_MAX_BATCH_SIZE
|
|
56
|
+
# Beat task name — also the key the per-minute throttle divisor is derived from, so
|
|
57
|
+
# the cadence and the throttle can no longer disagree (#2887 item 4).
|
|
58
|
+
DRAIN_TASK_NAME = "notifications.drain_notification_emails"
|
|
59
|
+
ARCHIVE_AFTER_DAYS = 90 # ADR-0075 §F item 6 — outbox cleanup
|
|
60
|
+
|
|
61
|
+
# Snippet rendering (#574, security review !306 LOW-1). SNIPPET_MAX_CHARS bounds
|
|
62
|
+
# the raw comment text before it's embedded in the email body; SNIPPET_WRAP_WIDTH
|
|
63
|
+
# hard-wraps it so a long unbroken string (a pasted URL, log line, or base64 blob)
|
|
64
|
+
# can't render as a single unbounded line in the recipient's mail client.
|
|
65
|
+
SNIPPET_MAX_CHARS = 280
|
|
66
|
+
SNIPPET_WRAP_WIDTH = 72 # classic plain-text convention; keeps quoted replies readable
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@idempotent_task(
|
|
70
|
+
lock_key_template="drain_notification_emails",
|
|
71
|
+
lock_ttl=60,
|
|
72
|
+
on_contention="skip",
|
|
73
|
+
soft_time_limit=25,
|
|
74
|
+
time_limit=30,
|
|
75
|
+
acks_late=True,
|
|
76
|
+
reject_on_worker_lost=True,
|
|
77
|
+
name="notifications.drain_notification_emails",
|
|
78
|
+
)
|
|
79
|
+
def drain_notification_emails(self: object) -> None:
|
|
80
|
+
"""Send queued email notifications.
|
|
81
|
+
|
|
82
|
+
Runs every 30 seconds via Celery Beat. Finds Notification rows where
|
|
83
|
+
``email_pending=True`` and ``email_sent_at IS NULL`` and ``email_attempts
|
|
84
|
+
< EMAIL_MAX_RETRIES`` and ``created_at < (now - orphan_window)``.
|
|
85
|
+
|
|
86
|
+
The orphan-window filter prevents racing the comment-create transaction —
|
|
87
|
+
a Notification inserted inside a still-open atomic block isn't visible
|
|
88
|
+
to this drain until commit AND has aged past the window. The 5-min value
|
|
89
|
+
matches the webhook drain (ADR-0019).
|
|
90
|
+
|
|
91
|
+
The singleton lock + ``on_contention="skip"`` ensures at most one drain
|
|
92
|
+
runs at a time; the next Beat tick picks up anything missed.
|
|
93
|
+
"""
|
|
94
|
+
_do_drain_emails()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@idempotent_task(
|
|
98
|
+
lock_key_template="archive_old_notifications",
|
|
99
|
+
lock_ttl=120,
|
|
100
|
+
on_contention="skip",
|
|
101
|
+
soft_time_limit=55,
|
|
102
|
+
time_limit=90,
|
|
103
|
+
acks_late=True,
|
|
104
|
+
reject_on_worker_lost=True,
|
|
105
|
+
name="notifications.archive_old_notifications",
|
|
106
|
+
)
|
|
107
|
+
def archive_old_notifications(self: object) -> None:
|
|
108
|
+
"""Archive read notifications older than 90 days.
|
|
109
|
+
|
|
110
|
+
Runs nightly via Celery Beat. Sets ``is_archived=True`` on Notifications
|
|
111
|
+
where ``is_read=True`` and ``created_at < (now - ARCHIVE_AFTER_DAYS)``.
|
|
112
|
+
|
|
113
|
+
Archive is a soft state — rows stay in the table, are excluded from
|
|
114
|
+
default list queries, and remain reachable via the explicit ``archived``
|
|
115
|
+
filter. A future hard-purge for archived rows older than 365 days is
|
|
116
|
+
operational concern (not in 0.2).
|
|
117
|
+
"""
|
|
118
|
+
_do_archive()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@idempotent_task(
|
|
122
|
+
lock_key_template="detect_stale_tasks",
|
|
123
|
+
lock_ttl=300,
|
|
124
|
+
on_contention="skip",
|
|
125
|
+
soft_time_limit=110,
|
|
126
|
+
time_limit=120,
|
|
127
|
+
acks_late=True,
|
|
128
|
+
reject_on_worker_lost=True,
|
|
129
|
+
name="notifications.detect_stale_tasks",
|
|
130
|
+
)
|
|
131
|
+
def detect_stale_tasks(self: object) -> None:
|
|
132
|
+
"""Nudge assignees of stale non-terminal tasks (ADR-0200).
|
|
133
|
+
|
|
134
|
+
Runs nightly via Celery Beat. Delegates to
|
|
135
|
+
:func:`notifications.services.create_stale_task_notifications`, which owns the
|
|
136
|
+
per-project threshold query, the unread dedupe, and the preference-gated fan-out.
|
|
137
|
+
|
|
138
|
+
Idempotent by construction: the dedupe against existing unread ``task.stale``
|
|
139
|
+
notifications means a broker retry, a manual re-queue, or the next nightly tick
|
|
140
|
+
all create zero duplicates. The singleton lock (``on_contention="skip"``) also
|
|
141
|
+
prevents two overlapping runs. A failed run is not retried — the tasks are still
|
|
142
|
+
stale tomorrow, so silent re-attempt on the next tick is correct and avoids a
|
|
143
|
+
retry storm (ADR-0200 §Durable Execution item 8).
|
|
144
|
+
"""
|
|
145
|
+
from .services import create_stale_task_notifications
|
|
146
|
+
|
|
147
|
+
created = create_stale_task_notifications()
|
|
148
|
+
if created:
|
|
149
|
+
logger.info("detect_stale_tasks: created %d stale-task notification(s)", created)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@idempotent_task(
|
|
153
|
+
lock_key_template="send_scheduled_digests",
|
|
154
|
+
lock_ttl=300,
|
|
155
|
+
on_contention="skip",
|
|
156
|
+
soft_time_limit=280,
|
|
157
|
+
time_limit=300,
|
|
158
|
+
acks_late=True,
|
|
159
|
+
reject_on_worker_lost=True,
|
|
160
|
+
max_retries=0,
|
|
161
|
+
name="notifications.send_scheduled_digests",
|
|
162
|
+
)
|
|
163
|
+
def send_scheduled_digests(self: object) -> None:
|
|
164
|
+
"""Send weekly digests whose per-user slot matches this hour (ADR-0663).
|
|
165
|
+
|
|
166
|
+
Runs **hourly** via Beat even though the digests are weekly: the send time is
|
|
167
|
+
a per-user property (``UserNotificationSettings.digest_*``) in the user's own
|
|
168
|
+
timezone, so the sweep has to look every hour and match slots rather than fire
|
|
169
|
+
on one cluster-wide schedule.
|
|
170
|
+
|
|
171
|
+
``max_retries=0`` — retrying is pointless here. The next tick is 60 minutes
|
|
172
|
+
away and re-evaluates from current state, and the ledger row is already written,
|
|
173
|
+
so a retry would either no-op or mail a stale snapshot. A failed run means one
|
|
174
|
+
skipped weekly digest, which the reader can recover by logging in
|
|
175
|
+
(ADR-0663 §Durable Execution item 8).
|
|
176
|
+
"""
|
|
177
|
+
from .digests import send_due_digests
|
|
178
|
+
|
|
179
|
+
created = send_due_digests()
|
|
180
|
+
if created:
|
|
181
|
+
logger.info("send_scheduled_digests: created %d digest notification(s)", created)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@idempotent_task(
|
|
185
|
+
lock_key_template="purge_old_digest_runs",
|
|
186
|
+
lock_ttl=300,
|
|
187
|
+
on_contention="skip",
|
|
188
|
+
soft_time_limit=55,
|
|
189
|
+
time_limit=90,
|
|
190
|
+
acks_late=True,
|
|
191
|
+
reject_on_worker_lost=True,
|
|
192
|
+
name="notifications.purge_old_digest_runs",
|
|
193
|
+
)
|
|
194
|
+
def purge_old_digest_runs(self: object) -> None:
|
|
195
|
+
"""Delete digest ledger rows past the retention window (ADR-0663 item 6).
|
|
196
|
+
|
|
197
|
+
Retention is 90 days rather than the 7-day per-request outbox convention: the
|
|
198
|
+
ledger exists to remember a *weekly* send, so a 7-day window would forget a
|
|
199
|
+
fortnight-old run it must not repeat.
|
|
200
|
+
"""
|
|
201
|
+
from .digests import purge_old_digest_runs as _purge
|
|
202
|
+
|
|
203
|
+
deleted = _purge()
|
|
204
|
+
if deleted:
|
|
205
|
+
logger.info("purge_old_digest_runs: deleted %d ledger row(s)", deleted)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Drain — extracted for direct testability without Celery in the loop
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _drain_batch_size(email_settings: Any) -> int:
|
|
214
|
+
"""Rows to drain this tick, honoring the operator's delivery limits (#2860).
|
|
215
|
+
|
|
216
|
+
Thin wrapper over :func:`delivery_limits.per_tick_cap` that supplies this drain's
|
|
217
|
+
beat cadence. The interpretation of both controls, and the shared per-minute budget
|
|
218
|
+
that spans every mail path, live in ``delivery_limits`` (#2887 item 3).
|
|
219
|
+
"""
|
|
220
|
+
return per_tick_cap(
|
|
221
|
+
email_settings,
|
|
222
|
+
ticks_per_minute=beat_ticks_per_minute(DRAIN_TASK_NAME),
|
|
223
|
+
fallback=EMAIL_MAX_BATCH_SIZE,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _current_project_membership_exists() -> Any:
|
|
228
|
+
"""Correlated ``EXISTS(ProjectMembership …)`` for a notification's (recipient, project).
|
|
229
|
+
|
|
230
|
+
Shared by :func:`_retire_pending_for_revoked_membership` and the pending-row
|
|
231
|
+
query in :func:`_do_drain_emails` so both express the same boundary
|
|
232
|
+
``NotificationSerializer._recipient_can_see_project`` (#3510) enforces on
|
|
233
|
+
read — as one correlated subquery attached to whatever outer
|
|
234
|
+
``Notification`` queryset calls this, never a per-row Python-side lookup
|
|
235
|
+
(#3675 review note: this drain processes a whole batch per 30 s tick, so an
|
|
236
|
+
N+1 here scales with backlog size).
|
|
237
|
+
|
|
238
|
+
``is_deleted=False`` matches the serializer predicate exactly: a soft-deleted
|
|
239
|
+
(revoked) ``ProjectMembership`` row does not count as current membership.
|
|
240
|
+
"""
|
|
241
|
+
from django.db.models import Exists, OuterRef
|
|
242
|
+
|
|
243
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
244
|
+
|
|
245
|
+
return Exists(
|
|
246
|
+
ProjectMembership.objects.filter(
|
|
247
|
+
user_id=OuterRef("recipient_id"),
|
|
248
|
+
project_id=OuterRef("project_id"),
|
|
249
|
+
is_deleted=False,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _retire_pending_for_revoked_membership() -> int:
|
|
255
|
+
"""Retire queued emails whose recipient has since lost project membership (#3675).
|
|
256
|
+
|
|
257
|
+
Mirrors :func:`_retire_pending_for_deactivated_recipients` (#3523) for the
|
|
258
|
+
membership axis rather than the account-active axis. ``_render_email`` /
|
|
259
|
+
``_send_email_for_notification`` read ``Notification.subject``/``.body``
|
|
260
|
+
straight off the frozen row and mail them via SMTP without ever consulting
|
|
261
|
+
``NotificationSerializer._recipient_can_see_project`` (#3510) — so a member
|
|
262
|
+
removed from ``obj.project`` during the orphan-window-plus-retry gap between
|
|
263
|
+
a notification's creation and its eventual successful send still received a
|
|
264
|
+
plaintext email naming a project/task they can no longer see through the API.
|
|
265
|
+
Fixing the REST read path (#3510) does not reach this channel because it
|
|
266
|
+
never goes through the serializer at all.
|
|
267
|
+
|
|
268
|
+
A row with no project (``project_id IS NULL`` — the ADR-0663 account-scoped
|
|
269
|
+
digest shape) has no membership boundary to check and is left untouched,
|
|
270
|
+
matching ``_recipient_can_see_project``'s own "falls open with no project"
|
|
271
|
+
rule.
|
|
272
|
+
|
|
273
|
+
**Retired, not failed** — same terminal-state reasoning as #3523: only
|
|
274
|
+
``email_pending`` is cleared here. ``email_attempts`` and ``email_failed_at``
|
|
275
|
+
are deliberately left untouched, so ``observability.selectors.
|
|
276
|
+
notification_email_signals``'s ``failed_recent`` signal (``email_pending=
|
|
277
|
+
False`` AND ``email_attempts >= EMAIL_MAX_RETRIES`` AND a recent
|
|
278
|
+
``email_failed_at``) never reports a deliberate privacy suppression as an
|
|
279
|
+
SMTP/mail-relay failure, and the row does not sit in ``queued_aging`` forever
|
|
280
|
+
either (it reaches a terminal state instead of parking in the backlog).
|
|
281
|
+
|
|
282
|
+
Resolved as a single ``UPDATE … WHERE NOT EXISTS (…)`` rather than one
|
|
283
|
+
membership query per row — the drain runs every 30 s over the whole pending
|
|
284
|
+
backlog, so a per-row check would multiply with backlog size.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
The number of rows retired — 0 on every tick where no pending recipient
|
|
288
|
+
has lost project access, the steady state.
|
|
289
|
+
"""
|
|
290
|
+
from .models import Notification
|
|
291
|
+
|
|
292
|
+
retired: int = (
|
|
293
|
+
Notification.objects.filter(
|
|
294
|
+
email_pending=True,
|
|
295
|
+
email_sent_at__isnull=True,
|
|
296
|
+
project_id__isnull=False,
|
|
297
|
+
)
|
|
298
|
+
.exclude(_current_project_membership_exists())
|
|
299
|
+
.update(email_pending=False)
|
|
300
|
+
)
|
|
301
|
+
if retired:
|
|
302
|
+
logger.info(
|
|
303
|
+
"drain_notification_emails: retired %d queued email(s) for recipient(s) "
|
|
304
|
+
"no longer a member of the notification's project",
|
|
305
|
+
retired,
|
|
306
|
+
)
|
|
307
|
+
return retired
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _retire_pending_for_deactivated_recipients() -> int:
|
|
311
|
+
"""Retire queued emails whose recipient has since been deactivated (#3523).
|
|
312
|
+
|
|
313
|
+
The digest sweep now excludes deactivated users at the audience (see
|
|
314
|
+
``digests._opted_in_user_ids``), but rows queued *before* off-boarding are already
|
|
315
|
+
in the outbox — and every notification email in OSS leaves through this one drain,
|
|
316
|
+
so this is the single chokepoint at which the account axis covers the mention,
|
|
317
|
+
stale-task, event and digest rails at once.
|
|
318
|
+
|
|
319
|
+
**Retired, not merely skipped.** Excluding these rows from the candidate query
|
|
320
|
+
alone would leave them ``email_pending=True`` forever, and
|
|
321
|
+
``observability.selectors.notification_email_signals`` reads exactly that as its
|
|
322
|
+
``queued_aging`` signal — rows pending past an hour mean "Beat is dead, the worker
|
|
323
|
+
is gone, or the transport cannot be built". A permanent false alarm on the System
|
|
324
|
+
Health card is the over-claiming that selector's docstring exists to prevent, so
|
|
325
|
+
the row must reach a terminal state instead of parking in the backlog.
|
|
326
|
+
|
|
327
|
+
The terminal state written here is ``email_pending=False`` **only**.
|
|
328
|
+
``email_attempts`` and ``email_failed_at`` are deliberately left untouched: the
|
|
329
|
+
same selector derives ``failed_recent`` from ``email_pending=False`` **and**
|
|
330
|
+
``email_attempts >= EMAIL_MAX_RETRIES`` **and** a recent ``email_failed_at``, so
|
|
331
|
+
stamping either would report an off-boarding as a mail-relay failure. This is not
|
|
332
|
+
a delivery failure — there is no longer anyone to deliver to.
|
|
333
|
+
|
|
334
|
+
The durable in-app ``Notification`` row is left alone. It is the inbox record, and
|
|
335
|
+
a deactivated account cannot authenticate to read it (JWT, session and PAT all
|
|
336
|
+
close on ``is_active``); the email is the only channel that reaches outward.
|
|
337
|
+
|
|
338
|
+
Reactivation does not resurrect a retired row, which is the intended trade: an
|
|
339
|
+
off-boarding is not expected to reverse, and a week-old digest replayed on
|
|
340
|
+
reinstatement would state program health that has since moved on.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The number of rows retired — 0 on every tick of a workspace with no pending
|
|
344
|
+
mail for a deactivated user, which is the steady state.
|
|
345
|
+
"""
|
|
346
|
+
from .models import Notification
|
|
347
|
+
|
|
348
|
+
retired: int = Notification.objects.filter(
|
|
349
|
+
email_pending=True,
|
|
350
|
+
email_sent_at__isnull=True,
|
|
351
|
+
recipient__is_active=False,
|
|
352
|
+
).update(email_pending=False)
|
|
353
|
+
if retired:
|
|
354
|
+
logger.info(
|
|
355
|
+
"drain_notification_emails: retired %d queued email(s) for deactivated "
|
|
356
|
+
"recipient(s) without sending",
|
|
357
|
+
retired,
|
|
358
|
+
)
|
|
359
|
+
return retired
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _do_drain_emails() -> None:
|
|
363
|
+
from django.utils import timezone
|
|
364
|
+
|
|
365
|
+
from .models import Notification, WorkspaceEmailSettings
|
|
366
|
+
|
|
367
|
+
now = timezone.now()
|
|
368
|
+
orphan_cutoff = now - timedelta(minutes=EMAIL_ORPHAN_WINDOW_MINUTES)
|
|
369
|
+
|
|
370
|
+
_retire_pending_for_deactivated_recipients()
|
|
371
|
+
_retire_pending_for_revoked_membership()
|
|
372
|
+
|
|
373
|
+
# Read the operator's delivery limits BEFORE the query, because they bound it
|
|
374
|
+
# (#2860). max_recipients and throttle_per_min persisted, validated and rendered
|
|
375
|
+
# back in Workspace → Email for three releases while this task capped every
|
|
376
|
+
# drain at a hardcoded 50 regardless — a control that looked applied and was not.
|
|
377
|
+
email_settings = WorkspaceEmailSettings.load()
|
|
378
|
+
# Two-stage: the per-tick cap smooths within a minute, the shared budget bounds
|
|
379
|
+
# the minute across *every* mail path (invite drain, password reset, export-ready)
|
|
380
|
+
# so no single path can spend the whole allowance (#2887 item 3).
|
|
381
|
+
budget = reserve_send_budget(
|
|
382
|
+
int(email_settings.throttle_per_min or 0), _drain_batch_size(email_settings)
|
|
383
|
+
)
|
|
384
|
+
granted = budget.granted
|
|
385
|
+
if granted <= 0:
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
from django.db.models import Q
|
|
389
|
+
|
|
390
|
+
pending = list(
|
|
391
|
+
Notification.objects.filter(
|
|
392
|
+
email_pending=True,
|
|
393
|
+
email_sent_at__isnull=True,
|
|
394
|
+
email_attempts__lt=EMAIL_MAX_RETRIES,
|
|
395
|
+
created_at__lt=orphan_cutoff,
|
|
396
|
+
# Fail closed on the account axis (#3523). The retirement pass above has
|
|
397
|
+
# already cleared these rows, so this predicate normally matches nothing;
|
|
398
|
+
# it is here for the row deactivated in the gap between that UPDATE and
|
|
399
|
+
# this SELECT, where the only safe default is not to send.
|
|
400
|
+
recipient__is_active=True,
|
|
401
|
+
)
|
|
402
|
+
# Fail closed on the membership axis (#3675), same reasoning: the retirement
|
|
403
|
+
# pass above already cleared rows whose recipient lost project access, this
|
|
404
|
+
# is the backstop for a revocation landing in the gap between that UPDATE and
|
|
405
|
+
# this SELECT. A row with no project (account-scoped digests) has no
|
|
406
|
+
# membership boundary and always passes.
|
|
407
|
+
.filter(_current_project_membership_exists() | Q(project_id__isnull=True))
|
|
408
|
+
.select_related("recipient", "mention", "mention__task_comment", "mention__mentioner")
|
|
409
|
+
.order_by("created_at")[:granted]
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Hand back what this tick will not spend, or an idle drain starves the other
|
|
413
|
+
# paths for the rest of the minute.
|
|
414
|
+
budget.release(granted - len(pending))
|
|
415
|
+
|
|
416
|
+
if not pending:
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
# Resolve the workspace transport + From identity ONCE per batch — the config
|
|
420
|
+
# is constant across the batch, so building it per message would be N SMTP
|
|
421
|
+
# connections + N single-row reads per tick (#712 perf, ADR-0213).
|
|
422
|
+
from .email_backend import (
|
|
423
|
+
EmailTransportError,
|
|
424
|
+
resolve_email_connection,
|
|
425
|
+
resolve_from_email,
|
|
426
|
+
resolve_reply_to,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
connection = resolve_email_connection(email_settings)
|
|
431
|
+
except EmailTransportError:
|
|
432
|
+
# The workspace has a transport configured that cannot be built — an
|
|
433
|
+
# undecryptable credential or a host the egress guard rejects (#2886 item 2).
|
|
434
|
+
# Deliberately return WITHOUT touching the rows: this is a configuration fault
|
|
435
|
+
# an operator fix clears, and burning three retries per row would permanently
|
|
436
|
+
# discard notifications for it. The rows stay pending, which is what the
|
|
437
|
+
# System Health card's aging-backlog signal reports on, and
|
|
438
|
+
# ``transport_status`` names the cause directly.
|
|
439
|
+
logger.error(
|
|
440
|
+
"drain_notification_emails: the configured mail transport is unusable — "
|
|
441
|
+
"%d row(s) left pending, no retries burned. Fix the transport in "
|
|
442
|
+
"Workspace -> Email.",
|
|
443
|
+
len(pending),
|
|
444
|
+
)
|
|
445
|
+
budget.release(len(pending))
|
|
446
|
+
return
|
|
447
|
+
from_email = resolve_from_email(email_settings)
|
|
448
|
+
reply_to = resolve_reply_to(email_settings)
|
|
449
|
+
|
|
450
|
+
# Open the shared connection once so all N sends reuse one socket/TLS
|
|
451
|
+
# handshake. Best-effort: if the transport is down, each per-message send
|
|
452
|
+
# still attempts (and fails) on its own, preserving the attempt/retry count.
|
|
453
|
+
try:
|
|
454
|
+
connection.open()
|
|
455
|
+
except Exception:
|
|
456
|
+
logger.warning("drain_notification_emails: could not open mail transport", exc_info=True)
|
|
457
|
+
|
|
458
|
+
sent = 0
|
|
459
|
+
failed = 0
|
|
460
|
+
try:
|
|
461
|
+
for notif in pending:
|
|
462
|
+
try:
|
|
463
|
+
ok = _send_email_for_notification(
|
|
464
|
+
notif,
|
|
465
|
+
connection=connection,
|
|
466
|
+
from_email=from_email,
|
|
467
|
+
reply_to=reply_to,
|
|
468
|
+
)
|
|
469
|
+
except Exception:
|
|
470
|
+
logger.exception(
|
|
471
|
+
"drain_notification_emails: unexpected error for notif %s", notif.pk
|
|
472
|
+
)
|
|
473
|
+
ok = False
|
|
474
|
+
|
|
475
|
+
if ok:
|
|
476
|
+
Notification.objects.filter(pk=notif.pk).update(
|
|
477
|
+
email_pending=False,
|
|
478
|
+
email_sent_at=timezone.now(),
|
|
479
|
+
)
|
|
480
|
+
sent += 1
|
|
481
|
+
else:
|
|
482
|
+
# Increment attempts atomically; on attempt N=max, clear
|
|
483
|
+
# email_pending so the row is no longer eligible for retry.
|
|
484
|
+
from django.db.models import F
|
|
485
|
+
|
|
486
|
+
new_attempts = notif.email_attempts + 1
|
|
487
|
+
update_fields: dict[str, object] = {
|
|
488
|
+
"email_attempts": F("email_attempts") + 1,
|
|
489
|
+
"email_failed_at": timezone.now(),
|
|
490
|
+
}
|
|
491
|
+
if new_attempts >= EMAIL_MAX_RETRIES:
|
|
492
|
+
update_fields["email_pending"] = False
|
|
493
|
+
Notification.objects.filter(pk=notif.pk).update(**update_fields)
|
|
494
|
+
failed += 1
|
|
495
|
+
finally:
|
|
496
|
+
with contextlib.suppress(Exception):
|
|
497
|
+
connection.close()
|
|
498
|
+
|
|
499
|
+
logger.info(
|
|
500
|
+
"drain_notification_emails: sent=%d failed=%d candidates=%d",
|
|
501
|
+
sent,
|
|
502
|
+
failed,
|
|
503
|
+
len(pending),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _do_archive() -> None:
|
|
508
|
+
from django.utils import timezone
|
|
509
|
+
|
|
510
|
+
from .models import Notification
|
|
511
|
+
|
|
512
|
+
cutoff = timezone.now() - timedelta(days=ARCHIVE_AFTER_DAYS)
|
|
513
|
+
archived = Notification.objects.filter(
|
|
514
|
+
is_read=True,
|
|
515
|
+
is_archived=False,
|
|
516
|
+
created_at__lt=cutoff,
|
|
517
|
+
).update(is_archived=True)
|
|
518
|
+
if archived:
|
|
519
|
+
logger.info(
|
|
520
|
+
"archive_old_notifications: archived %d row(s) older than %dd",
|
|
521
|
+
archived,
|
|
522
|
+
ARCHIVE_AFTER_DAYS,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
# Email rendering — minimal Diátaxis-friendly subject + plain-text body
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _send_email_for_notification(
|
|
532
|
+
notif: object,
|
|
533
|
+
*,
|
|
534
|
+
connection: BaseEmailBackend | None = None,
|
|
535
|
+
from_email: str | None = None,
|
|
536
|
+
reply_to: list[str] | None = None,
|
|
537
|
+
) -> bool:
|
|
538
|
+
"""Render and send the email for a single Notification.
|
|
539
|
+
|
|
540
|
+
Returns True on successful SMTP delivery, False on any failure (SMTP error,
|
|
541
|
+
missing user email, missing source mention). The caller increments the
|
|
542
|
+
attempt counter — this function is pure-attempt, no state mutation.
|
|
543
|
+
|
|
544
|
+
The workspace transport (``connection``), ``from_email``, and ``reply_to``
|
|
545
|
+
are resolved **once per drain batch** and passed in (#712 perf: the config is
|
|
546
|
+
constant across a batch, so a per-message ``load()`` + SMTP connect would be
|
|
547
|
+
50 redundant DB reads and SMTP/TLS handshakes per tick). When omitted they are
|
|
548
|
+
resolved here so a direct/one-off caller still works.
|
|
549
|
+
"""
|
|
550
|
+
from django.core.mail import EmailMessage
|
|
551
|
+
|
|
552
|
+
from .email_backend import (
|
|
553
|
+
resolve_email_connection,
|
|
554
|
+
resolve_from_email,
|
|
555
|
+
resolve_reply_to,
|
|
556
|
+
)
|
|
557
|
+
from .models import Notification
|
|
558
|
+
|
|
559
|
+
notif_obj: Notification = notif # type: ignore[assignment]
|
|
560
|
+
recipient = notif_obj.recipient
|
|
561
|
+
recipient_email = getattr(recipient, "email", "") or ""
|
|
562
|
+
if not recipient_email:
|
|
563
|
+
logger.warning(
|
|
564
|
+
"drain_notification_emails: recipient %s has no email, skipping notif %s",
|
|
565
|
+
getattr(recipient, "pk", "?"),
|
|
566
|
+
notif_obj.pk,
|
|
567
|
+
)
|
|
568
|
+
return False
|
|
569
|
+
|
|
570
|
+
# A notification is mention-sourced OR event-sourced (#639, ADR-0085 §3);
|
|
571
|
+
# _render_email handles both shapes and returns ("", "") if neither yields
|
|
572
|
+
# renderable content (e.g. the source comment was deleted).
|
|
573
|
+
subject, body = _render_email(notif_obj)
|
|
574
|
+
if not subject or not body:
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
# Dynamic transport + From identity from the workspace SMTP config (#712,
|
|
578
|
+
# ADR-0213). Falls back to the global backend / DEFAULT_FROM_EMAIL when the
|
|
579
|
+
# workspace transport is unconfigured (cloud), so this is a no-op for installs
|
|
580
|
+
# that never touch the writable config.
|
|
581
|
+
if connection is None:
|
|
582
|
+
connection = resolve_email_connection()
|
|
583
|
+
if from_email is None:
|
|
584
|
+
from_email = resolve_from_email()
|
|
585
|
+
if reply_to is None:
|
|
586
|
+
reply_to = resolve_reply_to()
|
|
587
|
+
msg = EmailMessage(
|
|
588
|
+
subject=subject,
|
|
589
|
+
body=body,
|
|
590
|
+
from_email=from_email,
|
|
591
|
+
to=[recipient_email],
|
|
592
|
+
reply_to=reply_to or None,
|
|
593
|
+
headers=_unsubscribe_headers() or None,
|
|
594
|
+
connection=connection,
|
|
595
|
+
)
|
|
596
|
+
try:
|
|
597
|
+
msg.send(fail_silently=False)
|
|
598
|
+
except Exception:
|
|
599
|
+
logger.warning(
|
|
600
|
+
"drain_notification_emails: SMTP send failed for notif %s",
|
|
601
|
+
notif_obj.pk,
|
|
602
|
+
exc_info=True,
|
|
603
|
+
)
|
|
604
|
+
return False
|
|
605
|
+
return True
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _render_email(notif: object) -> tuple[str, str]:
|
|
609
|
+
"""Render a Notification into (subject, body) plain-text strings.
|
|
610
|
+
|
|
611
|
+
Kept deliberately minimal — the UI is the primary surface; email is a
|
|
612
|
+
secondary "ping the inbox" channel for users who opt in (default OFF).
|
|
613
|
+
A richer HTML template lands when the executive-digest issue ships
|
|
614
|
+
(trueppm-enterprise#112).
|
|
615
|
+
"""
|
|
616
|
+
from .models import Notification
|
|
617
|
+
|
|
618
|
+
notif_obj: Notification = notif # type: ignore[assignment]
|
|
619
|
+
mention = notif_obj.mention
|
|
620
|
+
if mention is None:
|
|
621
|
+
# Event-sourced notification (#639): the subject/body were rendered at
|
|
622
|
+
# dispatch time and frozen on the row. Empty subject => nothing to send.
|
|
623
|
+
return notif_obj.subject, notif_obj.body
|
|
624
|
+
|
|
625
|
+
mentioner = mention.mentioner
|
|
626
|
+
mentioner_name = (
|
|
627
|
+
mentioner.get_full_name() or mentioner.username if mentioner is not None else "Someone"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
comment = mention.task_comment
|
|
631
|
+
if comment is None or comment.is_deleted:
|
|
632
|
+
return "", ""
|
|
633
|
+
|
|
634
|
+
task = comment.task
|
|
635
|
+
task_name = task.name if task else "a task"
|
|
636
|
+
snippet = _sanitize_snippet(comment.body or "")
|
|
637
|
+
|
|
638
|
+
if mention.mentioned_group_key:
|
|
639
|
+
subject = f"{mentioner_name} mentioned @{mention.mentioned_group_key} on {task_name}"
|
|
640
|
+
else:
|
|
641
|
+
subject = f"{mentioner_name} mentioned you on {task_name}"
|
|
642
|
+
|
|
643
|
+
body_lines = [
|
|
644
|
+
f'{mentioner_name} mentioned you in a comment on "{task_name}":',
|
|
645
|
+
"",
|
|
646
|
+
snippet,
|
|
647
|
+
"",
|
|
648
|
+
"Open the task in TruePPM to reply or acknowledge.",
|
|
649
|
+
"",
|
|
650
|
+
"—",
|
|
651
|
+
"You can change which notifications send email at /me/settings/notifications/.",
|
|
652
|
+
]
|
|
653
|
+
body = "\n".join(body_lines)
|
|
654
|
+
return subject, body
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _sanitize_snippet(raw: str) -> str:
|
|
658
|
+
"""Bound and format a comment snippet for embedding in the plain-text email body.
|
|
659
|
+
|
|
660
|
+
Truncates to ``SNIPPET_MAX_CHARS``, HTML-escapes as defense-in-depth (the
|
|
661
|
+
body is sent as ``text/plain`` today, so this isn't an XSS vector, but
|
|
662
|
+
escaping now means a future HTML template — e.g. the executive-digest work
|
|
663
|
+
in trueppm-enterprise#112 — can reuse this helper without introducing a new
|
|
664
|
+
escaping gap), and hard-wraps at ``SNIPPET_WRAP_WIDTH`` so a long unbroken
|
|
665
|
+
string (a pasted URL, log line, or base64 blob) can't render as a single
|
|
666
|
+
unbounded line in the recipient's mail client (#574, security review !306
|
|
667
|
+
LOW-1). ``quote=False`` on the escape leaves straight/curly quotes and
|
|
668
|
+
apostrophes untouched — those are common in ordinary prose and carry no
|
|
669
|
+
markup risk in body text, only ``&``/``<``/``>`` do.
|
|
670
|
+
"""
|
|
671
|
+
text = (raw or "").strip()
|
|
672
|
+
if len(text) > SNIPPET_MAX_CHARS:
|
|
673
|
+
text = text[:SNIPPET_MAX_CHARS].rstrip() + "..."
|
|
674
|
+
escaped = html.escape(text, quote=False)
|
|
675
|
+
return textwrap.fill(
|
|
676
|
+
escaped,
|
|
677
|
+
width=SNIPPET_WRAP_WIDTH,
|
|
678
|
+
break_long_words=True,
|
|
679
|
+
break_on_hyphens=False,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _unsubscribe_headers() -> dict[str, str]:
|
|
684
|
+
"""Build the RFC 2369 ``List-Unsubscribe`` header.
|
|
685
|
+
|
|
686
|
+
Points at the user's notification-preferences page — TruePPM has no
|
|
687
|
+
per-notification unsubscribe token today, so this is "one click to the
|
|
688
|
+
login-gated preferences page" rather than a true no-auth unsubscribe. That is a
|
|
689
|
+
valid ``List-Unsubscribe`` on its own; RFC 2369 does not require the target to be
|
|
690
|
+
unauthenticated.
|
|
691
|
+
|
|
692
|
+
``List-Unsubscribe-Post: List-Unsubscribe=One-Click`` is deliberately **not**
|
|
693
|
+
emitted (#2887 item 6). That header is an RFC 8058 promise that an unauthenticated
|
|
694
|
+
``POST`` to the URL unsubscribes the recipient with no further interaction, and
|
|
695
|
+
there is no such handler anywhere in this app — the URL is a login-gated SPA route.
|
|
696
|
+
Gmail's and Yahoo's bulk-sender requirements are checked by *exercising* the POST,
|
|
697
|
+
so advertising it without a conforming endpoint is a deliverability liability,
|
|
698
|
+
inverting the reason it was added. Re-add it in the same change that ships a signed
|
|
699
|
+
unsubscribe token and its endpoint, never before.
|
|
700
|
+
|
|
701
|
+
Returns an empty dict — omitting the header entirely — when the deployer hasn't
|
|
702
|
+
configured ``FRONTEND_BASE_URL``, since a bare relative path is not a valid header
|
|
703
|
+
value.
|
|
704
|
+
"""
|
|
705
|
+
from django.conf import settings
|
|
706
|
+
|
|
707
|
+
base = getattr(settings, "FRONTEND_BASE_URL", "").rstrip("/")
|
|
708
|
+
if not base:
|
|
709
|
+
return {}
|
|
710
|
+
prefs_url = f"{base}/me/settings/notifications"
|
|
711
|
+
return {"List-Unsubscribe": f"<{prefs_url}>"}
|