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,1917 @@
|
|
|
1
|
+
"""Celery tasks for the projects app — sprint close drain + burndown beat.
|
|
2
|
+
|
|
3
|
+
The transactional outbox pattern from ``apps/scheduling`` is mirrored here
|
|
4
|
+
for sprint close: the API endpoint inserts a ``SprintCloseRequest`` row in
|
|
5
|
+
the same DB transaction as the state change and returns 202 Accepted; this
|
|
6
|
+
drain task picks up PENDING rows and applies the actual close transition
|
|
7
|
+
under ``select_for_update`` (so concurrent drains never double-close).
|
|
8
|
+
|
|
9
|
+
See ADR-0037 for the full spec.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from celery import shared_task
|
|
19
|
+
from django.db import transaction
|
|
20
|
+
from django.utils import timezone
|
|
21
|
+
|
|
22
|
+
from trueppm_api.core.idempotent import idempotent_task
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
EXPORT_MAX_RETRIES = 3 # ADR-0219 §Durable Execution item 8
|
|
27
|
+
EXPORT_ORPHAN_WINDOW_MINUTES = 5 # ADR-0219 §Durable Execution item 3
|
|
28
|
+
EXPORT_DRAIN_BATCH_SIZE = 10
|
|
29
|
+
DEFAULT_EXPORT_RETENTION_DAYS = 7 # ADR-0219 §Durable Execution item 6 (shared with ADR-0174)
|
|
30
|
+
|
|
31
|
+
# Rows one purge run may reap, shared by the export and import purges (#2615).
|
|
32
|
+
# Sized so a full batch of storage DELETE round-trips finishes well inside the
|
|
33
|
+
# 55 s soft limit those tasks carry; a larger backlog drains over several runs.
|
|
34
|
+
PURGE_BATCH_SIZE = 500
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Config-change notices — outbox worker, drain, purge (#3009, ADR-1174)
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Pending rows younger than this are left to their own on-commit dispatch, which
|
|
42
|
+
# may still be in flight; the drain only picks up what that dispatch stranded.
|
|
43
|
+
CONFIG_NOTICE_ORPHAN_WINDOW = timedelta(minutes=5)
|
|
44
|
+
# Past this a dispatched or running row is presumed lost. Well clear of the emit's
|
|
45
|
+
# 150 s hard time limit, so a live emit is never re-driven underneath itself.
|
|
46
|
+
CONFIG_NOTICE_RECOVERY_WINDOW = timedelta(minutes=10)
|
|
47
|
+
CONFIG_NOTICE_DRAIN_BATCH_SIZE = 100
|
|
48
|
+
CONFIG_NOTICE_RETENTION = timedelta(days=7)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@idempotent_task(
|
|
52
|
+
lock_key_template="config_notice:{0}",
|
|
53
|
+
lock_ttl=180,
|
|
54
|
+
on_contention="skip",
|
|
55
|
+
soft_time_limit=120,
|
|
56
|
+
time_limit=150,
|
|
57
|
+
acks_late=True,
|
|
58
|
+
reject_on_worker_lost=True,
|
|
59
|
+
name="projects.emit_config_notice",
|
|
60
|
+
)
|
|
61
|
+
def emit_config_notice(self: object, request_id: str) -> None:
|
|
62
|
+
"""Emit one queued config-change notice.
|
|
63
|
+
|
|
64
|
+
Idempotent on the outbox row: the business logic claims it with a conditional
|
|
65
|
+
UPDATE and no-ops on a row that is running elsewhere or already finished, so a
|
|
66
|
+
drain re-dispatch racing the on-commit dispatch sends the notice once.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
request_id: ConfigNoticeRequest UUID string.
|
|
70
|
+
"""
|
|
71
|
+
from trueppm_api.apps.projects.config_notice import run_config_notice_request
|
|
72
|
+
|
|
73
|
+
run_config_notice_request(request_id)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@idempotent_task(
|
|
77
|
+
lock_key_template="drain_config_notice_requests",
|
|
78
|
+
lock_ttl=60,
|
|
79
|
+
on_contention="skip",
|
|
80
|
+
soft_time_limit=25,
|
|
81
|
+
time_limit=30,
|
|
82
|
+
acks_late=True,
|
|
83
|
+
reject_on_worker_lost=True,
|
|
84
|
+
name="projects.drain_config_notice_requests",
|
|
85
|
+
)
|
|
86
|
+
def drain_config_notice_requests(self: object) -> None:
|
|
87
|
+
"""Recover lost config-notice rows and dispatch stranded pending ones.
|
|
88
|
+
|
|
89
|
+
Runs every 30 seconds via Celery Beat. The singleton lock plus
|
|
90
|
+
``on_contention="skip"`` means an overlapping tick is dropped rather than
|
|
91
|
+
queued — the next tick covers it.
|
|
92
|
+
"""
|
|
93
|
+
_do_config_notice_drain()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@idempotent_task(
|
|
97
|
+
lock_key_template="purge_old_config_notice_requests",
|
|
98
|
+
lock_ttl=120,
|
|
99
|
+
on_contention="skip",
|
|
100
|
+
soft_time_limit=55,
|
|
101
|
+
time_limit=90,
|
|
102
|
+
acks_late=True,
|
|
103
|
+
reject_on_worker_lost=True,
|
|
104
|
+
name="projects.purge_old_config_notice_requests",
|
|
105
|
+
)
|
|
106
|
+
def purge_old_config_notice_requests(self: object) -> None:
|
|
107
|
+
"""Delete done/dead ConfigNoticeRequest rows older than seven days. Nightly."""
|
|
108
|
+
_do_config_notice_purge()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _do_config_notice_drain() -> None:
|
|
112
|
+
"""Business logic for drain_config_notice_requests — extracted for testability."""
|
|
113
|
+
from trueppm_api.apps.projects.config_notice import MAX_CONFIG_NOTICE_ATTEMPTS
|
|
114
|
+
from trueppm_api.apps.projects.models import ConfigNoticeRequest, ConfigNoticeRequestStatus
|
|
115
|
+
|
|
116
|
+
now = timezone.now()
|
|
117
|
+
stale = ConfigNoticeRequest.objects.filter(
|
|
118
|
+
status__in=[ConfigNoticeRequestStatus.DISPATCHED, ConfigNoticeRequestStatus.RUNNING],
|
|
119
|
+
claimed_at__lt=now - CONFIG_NOTICE_RECOVERY_WINDOW,
|
|
120
|
+
)
|
|
121
|
+
abandoned = stale.filter(attempt_count__gte=MAX_CONFIG_NOTICE_ATTEMPTS).update(
|
|
122
|
+
status=ConfigNoticeRequestStatus.DEAD, completed_at=now
|
|
123
|
+
)
|
|
124
|
+
recovered = stale.filter(attempt_count__lt=MAX_CONFIG_NOTICE_ATTEMPTS).update(
|
|
125
|
+
status=ConfigNoticeRequestStatus.PENDING, celery_task_id=""
|
|
126
|
+
)
|
|
127
|
+
if abandoned:
|
|
128
|
+
logger.error(
|
|
129
|
+
"drain_config_notice_requests: abandoned %d notice(s) after %d attempts",
|
|
130
|
+
abandoned,
|
|
131
|
+
MAX_CONFIG_NOTICE_ATTEMPTS,
|
|
132
|
+
)
|
|
133
|
+
if recovered:
|
|
134
|
+
logger.warning("drain_config_notice_requests: recovered %d lost notice(s)", recovered)
|
|
135
|
+
|
|
136
|
+
pending = list(
|
|
137
|
+
ConfigNoticeRequest.objects.filter(
|
|
138
|
+
status=ConfigNoticeRequestStatus.PENDING,
|
|
139
|
+
requested_at__lt=now - CONFIG_NOTICE_ORPHAN_WINDOW,
|
|
140
|
+
)
|
|
141
|
+
.order_by("requested_at")
|
|
142
|
+
.values_list("pk", flat=True)[:CONFIG_NOTICE_DRAIN_BATCH_SIZE]
|
|
143
|
+
)
|
|
144
|
+
dispatched = 0
|
|
145
|
+
for pk in pending:
|
|
146
|
+
try:
|
|
147
|
+
result = emit_config_notice.delay(str(pk))
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.warning(
|
|
150
|
+
"drain_config_notice_requests: broker unavailable — %d notice(s) stay pending",
|
|
151
|
+
len(pending) - dispatched,
|
|
152
|
+
)
|
|
153
|
+
break
|
|
154
|
+
# Conditional: a concurrent on-commit dispatch may already have moved it.
|
|
155
|
+
ConfigNoticeRequest.objects.filter(pk=pk, status=ConfigNoticeRequestStatus.PENDING).update(
|
|
156
|
+
status=ConfigNoticeRequestStatus.DISPATCHED,
|
|
157
|
+
celery_task_id=str(result.id),
|
|
158
|
+
claimed_at=now,
|
|
159
|
+
)
|
|
160
|
+
dispatched += 1
|
|
161
|
+
|
|
162
|
+
if dispatched:
|
|
163
|
+
logger.info("drain_config_notice_requests: dispatched=%d", dispatched)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _do_config_notice_purge() -> None:
|
|
167
|
+
"""Business logic for purge_old_config_notice_requests — extracted for testability."""
|
|
168
|
+
from trueppm_api.apps.projects.models import ConfigNoticeRequest, ConfigNoticeRequestStatus
|
|
169
|
+
|
|
170
|
+
deleted, _ = ConfigNoticeRequest.objects.filter(
|
|
171
|
+
status__in=[ConfigNoticeRequestStatus.DONE, ConfigNoticeRequestStatus.DEAD],
|
|
172
|
+
requested_at__lt=timezone.now() - CONFIG_NOTICE_RETENTION,
|
|
173
|
+
).delete()
|
|
174
|
+
logger.info("purge_old_config_notice_requests: deleted %d row(s)", deleted)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# close_sprint — applies a single SprintCloseRequest
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# How many times a close may be attempted before the request is abandoned, and
|
|
183
|
+
# how long a failed one waits before the drain re-queues it (#2894). The budget
|
|
184
|
+
# is enforced by the drain rather than by the Celery decorator's ``max_retries``
|
|
185
|
+
# — see the exception handler in close_sprint for why a broker-level retry
|
|
186
|
+
# cannot work here.
|
|
187
|
+
_MAX_CLOSE_ATTEMPTS = 3
|
|
188
|
+
_CLOSE_RETRY_BACKOFF = timedelta(seconds=60)
|
|
189
|
+
# Per-sweep caps. Fresh closes and retries draw from separate dispatch budgets so
|
|
190
|
+
# neither can starve the other — see the drain for why one shared, ordered slice
|
|
191
|
+
# stopped being safe once failed rows could re-enter the pool.
|
|
192
|
+
_DISPATCH_BUDGET = 50
|
|
193
|
+
_RETRY_DISPATCH_BUDGET = 10
|
|
194
|
+
_RETRY_REQUEUE_BUDGET = 200
|
|
195
|
+
# Deliberately far below _RETRY_REQUEUE_BUDGET (#2992). Re-queueing is one bulk
|
|
196
|
+
# UPDATE whose cost barely moves with the row count; abandoning now costs a
|
|
197
|
+
# transaction, a BoardEvent insert and a channel-layer fan-out *per row*, and it
|
|
198
|
+
# runs ahead of the dispatch sweeps inside a 25 s soft limit. The path fires in
|
|
199
|
+
# bulk precisely during a correlated outage, which is the worst moment to spend
|
|
200
|
+
# that budget — so it takes a small bite and leaves the rest for the next sweep.
|
|
201
|
+
_ABANDON_BUDGET = 50
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@idempotent_task(
|
|
205
|
+
lock_key_template="sprint_close_lock:{0}",
|
|
206
|
+
lock_ttl=120,
|
|
207
|
+
on_contention="skip",
|
|
208
|
+
max_retries=3,
|
|
209
|
+
retry_backoff=10,
|
|
210
|
+
retry_backoff_max=60,
|
|
211
|
+
retry_jitter=True,
|
|
212
|
+
soft_time_limit=55,
|
|
213
|
+
time_limit=90,
|
|
214
|
+
acks_late=True,
|
|
215
|
+
reject_on_worker_lost=True,
|
|
216
|
+
)
|
|
217
|
+
def close_sprint(self: object, request_id: str) -> None:
|
|
218
|
+
"""Apply a queued sprint close transition.
|
|
219
|
+
|
|
220
|
+
Idempotency: if the SprintCloseRequest is already COMPLETED or FAILED, the
|
|
221
|
+
task is a no-op. If the sprint is already COMPLETED for any reason, the
|
|
222
|
+
request is short-circuited to COMPLETED. Re-entry under broker retry
|
|
223
|
+
therefore never produces duplicate close side-effects.
|
|
224
|
+
|
|
225
|
+
Recovery from a failure runs through the **drain**, not through the broker
|
|
226
|
+
(#2894). A failure rolls the transaction back and marks the row FAILED with
|
|
227
|
+
a ``next_attempt_at``; ``drain_sprint_close_requests`` resets it to PENDING
|
|
228
|
+
once that time passes and re-dispatches, up to ``_MAX_CLOSE_ATTEMPTS``. The
|
|
229
|
+
decorator's ``max_retries`` cannot serve this: its retry re-enters here and
|
|
230
|
+
hits the FAILED short-circuit above, so it would spend the budget without
|
|
231
|
+
running the close. When the budget is spent ``next_attempt_at`` stays null,
|
|
232
|
+
the row is abandoned, and the sprint remains ACTIVE — recoverable only by
|
|
233
|
+
issuing a fresh close request. That abandonment is announced on the board as
|
|
234
|
+
``sprint_close_failed`` (#2992) via ``_finalize_close_failure``, which is the
|
|
235
|
+
one place any close request is marked FAILED; an intermediate, still-
|
|
236
|
+
retryable failure deliberately announces nothing.
|
|
237
|
+
|
|
238
|
+
On success the function:
|
|
239
|
+
1. Snapshots ``completed_*`` from current task state
|
|
240
|
+
2. Transitions sprint state to COMPLETED + sets closed_at
|
|
241
|
+
3. Applies carry-over (FK move on incomplete tasks)
|
|
242
|
+
4. Marks the SprintCloseRequest COMPLETED
|
|
243
|
+
5. Enqueues a ScheduleRequest with ``reason=SPRINT_CLOSED`` for CPM recompute
|
|
244
|
+
6. Broadcasts a ``sprint_closed`` board event to connected clients
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
request_id: SprintCloseRequest UUID string.
|
|
248
|
+
"""
|
|
249
|
+
from trueppm_api.apps.projects.models import (
|
|
250
|
+
SprintCloseRequest,
|
|
251
|
+
SprintCloseRequestFailureReason,
|
|
252
|
+
SprintCloseRequestStatus,
|
|
253
|
+
SprintState,
|
|
254
|
+
)
|
|
255
|
+
from trueppm_api.apps.projects.services import (
|
|
256
|
+
apply_carry_over,
|
|
257
|
+
apply_pending_disposition,
|
|
258
|
+
notify_carryover_assignees,
|
|
259
|
+
snapshot_completed_metrics,
|
|
260
|
+
snapshot_sprint_task_outcomes,
|
|
261
|
+
)
|
|
262
|
+
from trueppm_api.apps.scheduling.models import (
|
|
263
|
+
ScheduleRequest,
|
|
264
|
+
ScheduleRequestReason,
|
|
265
|
+
)
|
|
266
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
req = SprintCloseRequest.objects.select_related("sprint", "sprint__project").get(
|
|
270
|
+
pk=request_id
|
|
271
|
+
)
|
|
272
|
+
except SprintCloseRequest.DoesNotExist:
|
|
273
|
+
logger.warning("close_sprint: request %s not found, skipping", request_id)
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
if req.status in (SprintCloseRequestStatus.COMPLETED, SprintCloseRequestStatus.FAILED):
|
|
277
|
+
logger.info("close_sprint: request %s already %s — short-circuit", request_id, req.status)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# This increment MUST stay outside the `transaction.atomic()` below, and that
|
|
281
|
+
# placement is load-bearing rather than incidental (#2894): the retry budget
|
|
282
|
+
# is the only thing bounding the drain's re-queue loop, and it is enforced by
|
|
283
|
+
# comparing attempt_count. Wrapping close_sprint in @transaction.atomic — or
|
|
284
|
+
# moving this inside the block — rolls the increment back together with the
|
|
285
|
+
# failed close, so attempt_count never advances, the budget is never spent,
|
|
286
|
+
# and a permanently failing close is re-queued forever.
|
|
287
|
+
SprintCloseRequest.objects.filter(pk=req.pk).update(
|
|
288
|
+
status=SprintCloseRequestStatus.IN_FLIGHT,
|
|
289
|
+
started_at=timezone.now(),
|
|
290
|
+
attempt_count=req.attempt_count + 1,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
with transaction.atomic():
|
|
295
|
+
# Lock the sprint row: prevents two concurrent close attempts from
|
|
296
|
+
# double-snapshotting completed_* or applying carry-over twice.
|
|
297
|
+
from trueppm_api.apps.projects.models import Sprint
|
|
298
|
+
|
|
299
|
+
sprint = Sprint.objects.select_for_update().get(pk=req.sprint_id)
|
|
300
|
+
|
|
301
|
+
if _finalize_non_closable_sprint(req, sprint):
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
snapshot_completed_metrics(sprint)
|
|
305
|
+
sprint.state = SprintState.COMPLETED
|
|
306
|
+
sprint.closed_at = timezone.now()
|
|
307
|
+
sprint.save(
|
|
308
|
+
update_fields=[
|
|
309
|
+
"completed_points",
|
|
310
|
+
"completed_task_count",
|
|
311
|
+
"goal_outcome",
|
|
312
|
+
"state",
|
|
313
|
+
"closed_at",
|
|
314
|
+
]
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# ADR-0176 §2 (#982): snapshot the closing task membership BEFORE
|
|
318
|
+
# apply_carry_over mutates Task.sprint — otherwise "what didn't ship"
|
|
319
|
+
# is destroyed (carried tasks move to the next sprint, dropped tasks
|
|
320
|
+
# to the backlog). NOT wrapped in try/except: the audit is part of the
|
|
321
|
+
# close's definition of done, so a failure rolls the whole close back
|
|
322
|
+
# and the drain re-queues the request.
|
|
323
|
+
#
|
|
324
|
+
# That second clause was false when written (#2894) — the handler
|
|
325
|
+
# marked the row FAILED and nothing ever picked a FAILED row up. It
|
|
326
|
+
# is true now, but bounded: _MAX_CLOSE_ATTEMPTS tries, then the
|
|
327
|
+
# request is abandoned and the sprint stays ACTIVE. An audit failure
|
|
328
|
+
# that reproduces will still strand the close; it just no longer does
|
|
329
|
+
# so on the first try, silently.
|
|
330
|
+
snapshot_sprint_task_outcomes(sprint, carry_over_to=req.carry_over_to)
|
|
331
|
+
|
|
332
|
+
_clear_sprint_ranks(sprint)
|
|
333
|
+
|
|
334
|
+
carried_task_ids = apply_carry_over(sprint, req.carry_over_to)
|
|
335
|
+
|
|
336
|
+
# ADR-0102 §7: dispose of tasks still pending acceptance at close.
|
|
337
|
+
# Runs AFTER carry-over (so a carried task's new sprint_id is set) and
|
|
338
|
+
# never blocks the close. 'carry' re-flags pending tasks in the
|
|
339
|
+
# incoming sprint; 'reject' removes them from the sprint.
|
|
340
|
+
apply_pending_disposition(sprint, req.pending_disposition, by=req.requested_by)
|
|
341
|
+
|
|
342
|
+
# ADR-0232 (#1470): tell each carried task's assignee their committed
|
|
343
|
+
# work crossed the close→plan seam. Sourced from the faithful moved-id
|
|
344
|
+
# set; defers create_event_notifications to on_commit inside the
|
|
345
|
+
# service. Non-blocking like the reforecast digest below: a
|
|
346
|
+
# notification bug must never strand or revert a sprint close.
|
|
347
|
+
try:
|
|
348
|
+
notify_carryover_assignees(
|
|
349
|
+
sprint,
|
|
350
|
+
req.carry_over_to,
|
|
351
|
+
carried_task_ids,
|
|
352
|
+
actor_id=req.requested_by_id,
|
|
353
|
+
)
|
|
354
|
+
except Exception:
|
|
355
|
+
logger.exception(
|
|
356
|
+
"close_sprint: carryover notification failed for sprint %s — continuing close",
|
|
357
|
+
sprint.pk,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
_recompute_and_reforecast_milestone(sprint, req)
|
|
361
|
+
|
|
362
|
+
SprintCloseRequest.objects.filter(pk=req.pk).update(
|
|
363
|
+
status=SprintCloseRequestStatus.COMPLETED,
|
|
364
|
+
completed_at=timezone.now(),
|
|
365
|
+
error_message="",
|
|
366
|
+
failure_reason="",
|
|
367
|
+
# Structural, not incidental: every terminal writer clears the
|
|
368
|
+
# retry clock, so "null means no further attempt" holds by
|
|
369
|
+
# construction rather than because no live path happens to leave
|
|
370
|
+
# a stale value behind.
|
|
371
|
+
next_attempt_at=None,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Enqueue downstream CPM recompute with the SPRINT_CLOSED reason
|
|
375
|
+
# so the audit trail records why the recalculation fired.
|
|
376
|
+
project_id = sprint.project_id
|
|
377
|
+
ScheduleRequest.objects.create(
|
|
378
|
+
project_id=project_id,
|
|
379
|
+
reason=ScheduleRequestReason.SPRINT_CLOSED,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
_compute_velocity_suggestions_safe(sprint)
|
|
383
|
+
|
|
384
|
+
sprint_id_str = str(sprint.pk)
|
|
385
|
+
project_id_str = str(project_id)
|
|
386
|
+
transaction.on_commit(
|
|
387
|
+
lambda: broadcast_board_event(
|
|
388
|
+
project_id_str,
|
|
389
|
+
"sprint_closed",
|
|
390
|
+
{"id": sprint_id_str},
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
# ADR-0147: emit the sprint.closed webhook. The completion snapshot in
|
|
394
|
+
# the payload is team velocity and is privacy-gated by the builder
|
|
395
|
+
# (ADR-0104) — built now, inside the close transaction, so the gate reads
|
|
396
|
+
# the committed policy and the on_commit callback does no DB work.
|
|
397
|
+
from trueppm_api.apps.projects.views import (
|
|
398
|
+
_dispatch_webhooks,
|
|
399
|
+
_sprint_closed_webhook_payload,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
closed_payload = _sprint_closed_webhook_payload(sprint, source="sprint_close")
|
|
403
|
+
transaction.on_commit(
|
|
404
|
+
lambda: _dispatch_webhooks(project_id_str, "sprint.closed", closed_payload)
|
|
405
|
+
)
|
|
406
|
+
# The carried-over tasks changed sprint (and possibly status). Without
|
|
407
|
+
# a broadcast, connected clients keep rendering them under the closed
|
|
408
|
+
# sprint until a manual refetch. Emit one bulk event for the batch.
|
|
409
|
+
# Bind the ids via a default arg (matches the backlog_services pattern)
|
|
410
|
+
# so closure late-binding can't swap them if this grows more branches.
|
|
411
|
+
if carried_task_ids:
|
|
412
|
+
|
|
413
|
+
def _broadcast_carry_over(
|
|
414
|
+
pid: str = project_id_str, ids: list[str] = carried_task_ids
|
|
415
|
+
) -> None:
|
|
416
|
+
broadcast_board_event(pid, "tasks_bulk_mutated", {"task_ids": ids})
|
|
417
|
+
|
|
418
|
+
transaction.on_commit(_broadcast_carry_over)
|
|
419
|
+
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
logger.exception("close_sprint: failed for request %s", request_id)
|
|
422
|
+
# The whole close rolled back, so the sprint is still ACTIVE and the row
|
|
423
|
+
# is safe to run again from the top. Schedule a retry until the budget is
|
|
424
|
+
# spent; ``next_attempt_at=None`` on the last one is what makes it
|
|
425
|
+
# terminal (#2894).
|
|
426
|
+
#
|
|
427
|
+
# Deliberately NOT re-raised to drive the decorator's max_retries. A
|
|
428
|
+
# retry there re-enters close_sprint, which short-circuits on a FAILED
|
|
429
|
+
# row and returns immediately — the retry would burn the budget without
|
|
430
|
+
# ever reaching the work. The drain is the recovery path because it is
|
|
431
|
+
# the thing that can reset status back to PENDING first.
|
|
432
|
+
attempts = req.attempt_count + 1 # this run, already applied above
|
|
433
|
+
exhausted = attempts >= _MAX_CLOSE_ATTEMPTS
|
|
434
|
+
_finalize_close_failure(
|
|
435
|
+
req_pk=req.pk,
|
|
436
|
+
sprint_id=req.sprint_id,
|
|
437
|
+
project_id=req.sprint.project_id,
|
|
438
|
+
error_message=str(exc)[:1000],
|
|
439
|
+
failure_reason=SprintCloseRequestFailureReason.ERROR,
|
|
440
|
+
attempt_count=attempts,
|
|
441
|
+
next_attempt_at=None if exhausted else timezone.now() + _CLOSE_RETRY_BACKOFF,
|
|
442
|
+
)
|
|
443
|
+
if exhausted:
|
|
444
|
+
logger.error(
|
|
445
|
+
"close_sprint: request %s exhausted %d attempts — sprint %s stays ACTIVE",
|
|
446
|
+
request_id,
|
|
447
|
+
attempts,
|
|
448
|
+
req.sprint_id,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _finalize_close_failure(
|
|
453
|
+
*,
|
|
454
|
+
req_pk: Any,
|
|
455
|
+
sprint_id: Any,
|
|
456
|
+
project_id: Any,
|
|
457
|
+
error_message: str,
|
|
458
|
+
failure_reason: str,
|
|
459
|
+
attempt_count: int,
|
|
460
|
+
next_attempt_at: Any = None,
|
|
461
|
+
expected_status: str | None = None,
|
|
462
|
+
) -> None:
|
|
463
|
+
"""Mark a close request FAILED and, when that is terminal, tell the board (#2992).
|
|
464
|
+
|
|
465
|
+
The single writer of ``status=FAILED``. It exists because the *terminal*
|
|
466
|
+
failure — the one a user has to be told about, because the sprint stays
|
|
467
|
+
ACTIVE forever — is reached from four independent places, and a broadcast
|
|
468
|
+
wired into only the obvious one would miss three:
|
|
469
|
+
|
|
470
|
+
1. ``close_sprint``'s exception handler once the attempt budget is spent,
|
|
471
|
+
2. ``_finalize_non_closable_sprint`` when the sprint was cancelled,
|
|
472
|
+
3. the same function when the sprint is in some other non-closable state,
|
|
473
|
+
4. ``_do_drain``'s orphan sweep, for a close that never raised at all
|
|
474
|
+
because its worker died mid-attempt on every try.
|
|
475
|
+
|
|
476
|
+
``next_attempt_at`` is the terminality test, not ``status``: a FAILED row
|
|
477
|
+
with a live retry clock is one the drain will pick up again in a minute, and
|
|
478
|
+
announcing *that* as a failure would alarm a user about something that
|
|
479
|
+
self-heals. This is the same rule ``SprintCloseRequestStateSerializer.
|
|
480
|
+
get_terminal`` names on the read side, kept in one place on the write side so
|
|
481
|
+
the two cannot drift.
|
|
482
|
+
|
|
483
|
+
The broadcast is deferred with ``transaction.on_commit`` inside an explicit
|
|
484
|
+
``atomic`` block because the four call sites do **not** share an ambient
|
|
485
|
+
transaction: two run inside ``close_sprint``'s ``with transaction.atomic()``
|
|
486
|
+
and two (the exception handler, which sits outside the block that just rolled
|
|
487
|
+
back, and the drain) run in worker autocommit. Registering the callback
|
|
488
|
+
against a transaction this function opens itself makes the ordering — row
|
|
489
|
+
durable, then announce — identical at all four, instead of correct at two by
|
|
490
|
+
construction and at the other two by accident.
|
|
491
|
+
|
|
492
|
+
The payload deliberately carries no ``error_message``. That field is
|
|
493
|
+
role-gated on ``GET /sprints/{id}/close-request/`` — raw exception text is
|
|
494
|
+
Admin-only and is withheld from agent tokens entirely, because a database or
|
|
495
|
+
broker failure puts internal hostnames and SQL fragments in it. A board
|
|
496
|
+
broadcast fans out to the whole project group with no per-recipient
|
|
497
|
+
filtering available, so putting the text here would silently reverse that
|
|
498
|
+
gate. Clients learn the failure happened from the event and read the text
|
|
499
|
+
they are entitled to from the endpoint. Everything in the payload below is
|
|
500
|
+
already unconditional on that endpoint for every reader who can receive the
|
|
501
|
+
event.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
req_pk: SprintCloseRequest primary key.
|
|
505
|
+
sprint_id: The sprint that failed to close.
|
|
506
|
+
project_id: Board group to fan out on — a ``Project`` pk, which is what
|
|
507
|
+
makes this event deliverable (a program pk would not be).
|
|
508
|
+
error_message: Stored failure text; never broadcast.
|
|
509
|
+
failure_reason: A ``SprintCloseRequestFailureReason`` member.
|
|
510
|
+
attempt_count: Attempts made including the one that just failed.
|
|
511
|
+
next_attempt_at: When the drain may retry, or None for terminal.
|
|
512
|
+
expected_status: Only write if the row is still in this status. Used by
|
|
513
|
+
the drain, whose select-then-write split would otherwise clobber a
|
|
514
|
+
row that moved on in between.
|
|
515
|
+
|
|
516
|
+
The write is a check-and-set and the announcement is conditional on it. Two
|
|
517
|
+
races make a blind ``filter(pk=...).update()`` wrong here, and both end in
|
|
518
|
+
the same user-visible lie — "your sprint didn't close" about one that did:
|
|
519
|
+
|
|
520
|
+
* Django runs non-robust ``on_commit`` hooks from ``Atomic.__exit__``,
|
|
521
|
+
*after* the commit. ``close_sprint``'s success path registers three, and
|
|
522
|
+
one of them dispatches webhooks and can raise — which lands in the very
|
|
523
|
+
``except`` block that calls this function, with the COMPLETED row already
|
|
524
|
+
durable. Excluding COMPLETED stops a committed close being rewritten as a
|
|
525
|
+
failure by its own post-commit fallout.
|
|
526
|
+
* The drain's orphan sweep selects candidates and then writes them one at a
|
|
527
|
+
time, so the guard that used to live in a single ``UPDATE … WHERE`` no
|
|
528
|
+
longer covers the write. ``expected_status`` puts it back.
|
|
529
|
+
|
|
530
|
+
Returning early when the update matched nothing keeps the broadcast honest:
|
|
531
|
+
an event is sent only for a row this call actually made terminal.
|
|
532
|
+
"""
|
|
533
|
+
from trueppm_api.apps.projects.models import SprintCloseRequest, SprintCloseRequestStatus
|
|
534
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
535
|
+
|
|
536
|
+
with transaction.atomic():
|
|
537
|
+
rows = SprintCloseRequest.objects.filter(pk=req_pk)
|
|
538
|
+
if expected_status is not None:
|
|
539
|
+
rows = rows.filter(status=expected_status)
|
|
540
|
+
updated = rows.exclude(status=SprintCloseRequestStatus.COMPLETED).update(
|
|
541
|
+
status=SprintCloseRequestStatus.FAILED,
|
|
542
|
+
completed_at=timezone.now(),
|
|
543
|
+
error_message=error_message,
|
|
544
|
+
failure_reason=failure_reason,
|
|
545
|
+
next_attempt_at=next_attempt_at,
|
|
546
|
+
)
|
|
547
|
+
if next_attempt_at is not None or not updated:
|
|
548
|
+
return
|
|
549
|
+
|
|
550
|
+
# Bound to locals rather than read off the closure: the callback runs
|
|
551
|
+
# after this frame is gone, and the drain calls this in a loop.
|
|
552
|
+
sprint_id_str = str(sprint_id)
|
|
553
|
+
request_id_str = str(req_pk)
|
|
554
|
+
project_id_str = str(project_id)
|
|
555
|
+
reason = str(failure_reason)
|
|
556
|
+
attempts = int(attempt_count)
|
|
557
|
+
transaction.on_commit(
|
|
558
|
+
lambda: broadcast_board_event(
|
|
559
|
+
project_id_str,
|
|
560
|
+
"sprint_close_failed",
|
|
561
|
+
{
|
|
562
|
+
"id": sprint_id_str,
|
|
563
|
+
"request_id": request_id_str,
|
|
564
|
+
"failure_reason": reason,
|
|
565
|
+
"attempt_count": attempts,
|
|
566
|
+
# Constant by construction — this event is only ever emitted
|
|
567
|
+
# for a request that will not be retried. Stated on the wire
|
|
568
|
+
# so a consumer that never calls the read route still knows
|
|
569
|
+
# it is not looking at a transient blip.
|
|
570
|
+
"terminal": True,
|
|
571
|
+
},
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _finalize_non_closable_sprint(req: Any, sprint: Any) -> bool:
|
|
577
|
+
"""Finalize the close request if the locked sprint can't be closed; return handled.
|
|
578
|
+
|
|
579
|
+
Returns True (and updates the ``SprintCloseRequest``) when the sprint is already
|
|
580
|
+
COMPLETED (mark the request COMPLETED — an earlier dispatch closed it), CANCELLED,
|
|
581
|
+
or otherwise not ACTIVE (mark the request FAILED). Returns False when the sprint is
|
|
582
|
+
ACTIVE and the close should proceed.
|
|
583
|
+
"""
|
|
584
|
+
from trueppm_api.apps.projects.models import (
|
|
585
|
+
SprintCloseRequest,
|
|
586
|
+
SprintCloseRequestFailureReason,
|
|
587
|
+
SprintCloseRequestStatus,
|
|
588
|
+
SprintState,
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
if sprint.state == SprintState.COMPLETED:
|
|
592
|
+
# Already closed by an earlier dispatch; mark request done.
|
|
593
|
+
SprintCloseRequest.objects.filter(pk=req.pk).update(
|
|
594
|
+
status=SprintCloseRequestStatus.COMPLETED,
|
|
595
|
+
completed_at=timezone.now(),
|
|
596
|
+
next_attempt_at=None,
|
|
597
|
+
)
|
|
598
|
+
return True
|
|
599
|
+
|
|
600
|
+
if sprint.state == SprintState.CANCELLED:
|
|
601
|
+
_finalize_close_failure(
|
|
602
|
+
req_pk=req.pk,
|
|
603
|
+
sprint_id=sprint.pk,
|
|
604
|
+
project_id=sprint.project_id,
|
|
605
|
+
error_message="Sprint was cancelled before close could complete.",
|
|
606
|
+
failure_reason=SprintCloseRequestFailureReason.CANCELLED,
|
|
607
|
+
# ``req`` was read before the increment this attempt already applied.
|
|
608
|
+
attempt_count=req.attempt_count + 1,
|
|
609
|
+
# Terminal by nature: no retry can un-cancel a sprint. Leaving the
|
|
610
|
+
# clock null is what keeps the drain's retry sweep off this row, and
|
|
611
|
+
# is what makes the helper announce it.
|
|
612
|
+
next_attempt_at=None,
|
|
613
|
+
)
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
if sprint.state != SprintState.ACTIVE:
|
|
617
|
+
_finalize_close_failure(
|
|
618
|
+
req_pk=req.pk,
|
|
619
|
+
sprint_id=sprint.pk,
|
|
620
|
+
project_id=sprint.project_id,
|
|
621
|
+
error_message=f"Sprint state {sprint.state} is not closable.",
|
|
622
|
+
failure_reason=SprintCloseRequestFailureReason.NOT_CLOSABLE,
|
|
623
|
+
attempt_count=req.attempt_count + 1,
|
|
624
|
+
next_attempt_at=None,
|
|
625
|
+
)
|
|
626
|
+
return True
|
|
627
|
+
|
|
628
|
+
return False
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _clear_sprint_ranks(sprint: Any) -> None:
|
|
632
|
+
"""Clear the within-sprint execution order on close (#365, ADR-0105 §5).
|
|
633
|
+
|
|
634
|
+
A task returns to the product backlog ordered by ``priority_rank``; its
|
|
635
|
+
closed-sprint ``sprint_rank`` stays queryable on the HistoricalTask rows (the
|
|
636
|
+
pre-clear value on the create-time row, the null on the row the save() below
|
|
637
|
+
writes). Runs BEFORE apply_carry_over so a carried task re-enters its next sprint
|
|
638
|
+
un-ranked (re-seeded from priority_rank on that sprint's activate), never
|
|
639
|
+
inheriting a stale rank. save() (not bulk_update) so server_version bumps +
|
|
640
|
+
history is written.
|
|
641
|
+
"""
|
|
642
|
+
from trueppm_api.apps.projects.models import Task
|
|
643
|
+
|
|
644
|
+
for task in Task.objects.filter(sprint_id=sprint.pk, is_deleted=False):
|
|
645
|
+
if task.sprint_rank is not None:
|
|
646
|
+
task.sprint_rank = None
|
|
647
|
+
task.save(update_fields=["sprint_rank", "server_version"])
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _recompute_and_reforecast_milestone(sprint: Any, req: Any) -> None:
|
|
651
|
+
"""Recompute the bound milestone rollup and reforecast its finish (ADR-0074/0106).
|
|
652
|
+
|
|
653
|
+
ADR-0074: recompute the milestone rollup with the final ``completed_*`` snapshot.
|
|
654
|
+
Runs inside the drain transaction, after carry-over, so the milestone reflects the
|
|
655
|
+
closed sprint's final contribution before the sprint_closed broadcast goes out.
|
|
656
|
+
|
|
657
|
+
ADR-0106 §3 (#860) — the bridge WOW: reforecast the bound milestone's finish as a
|
|
658
|
+
range from the just-closed sprint's velocity. The snapshot write is synchronous
|
|
659
|
+
(durable with the close); the milestone_forecast_updated broadcast + Enterprise-seam
|
|
660
|
+
signal defer to on_commit inside the service. Wrapped non-blocking: a reforecast bug
|
|
661
|
+
must never strand or revert a sprint close (ADR-0106 §Durable 8).
|
|
662
|
+
"""
|
|
663
|
+
if sprint.target_milestone_id is None:
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
from trueppm_api.apps.projects.services import recompute_milestone_rollup
|
|
667
|
+
|
|
668
|
+
recompute_milestone_rollup(sprint.target_milestone_id)
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
from trueppm_api.apps.projects.services import (
|
|
672
|
+
notify_milestone_forecast_shift,
|
|
673
|
+
reforecast_bound_milestone,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
forecast = reforecast_bound_milestone(sprint.target_milestone_id)
|
|
677
|
+
# #861 — push the PM cohort the milestone-confidence shift so the bridge
|
|
678
|
+
# reforecast isn't silent when the team closes the sprint outside the PM's
|
|
679
|
+
# session. Non-blocking with the reforecast itself: a digest failure must
|
|
680
|
+
# never strand close.
|
|
681
|
+
if forecast is not None:
|
|
682
|
+
notify_milestone_forecast_shift(forecast, sprint, actor_id=req.requested_by_id)
|
|
683
|
+
except Exception:
|
|
684
|
+
logger.exception(
|
|
685
|
+
"close_sprint: reforecast failed for milestone %s — continuing close",
|
|
686
|
+
sprint.target_milestone_id,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _compute_velocity_suggestions_safe(sprint: Any) -> None:
|
|
691
|
+
"""Compute velocity-calibration suggestions (ADR-0065), non-blocking on failure.
|
|
692
|
+
|
|
693
|
+
Any error logs and is swallowed so a calibration bug cannot strand a sprint close.
|
|
694
|
+
"""
|
|
695
|
+
try:
|
|
696
|
+
from trueppm_api.apps.scheduling.services import compute_velocity_suggestions
|
|
697
|
+
|
|
698
|
+
compute_velocity_suggestions(sprint.pk)
|
|
699
|
+
except Exception:
|
|
700
|
+
logger.exception(
|
|
701
|
+
"close_sprint: velocity calibration failed for sprint %s — continuing close",
|
|
702
|
+
sprint.pk,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
# ---------------------------------------------------------------------------
|
|
707
|
+
# Drain — every 30 seconds via Beat
|
|
708
|
+
# ---------------------------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@idempotent_task(
|
|
712
|
+
lock_key_template="drain_sprint_close_requests",
|
|
713
|
+
lock_ttl=60,
|
|
714
|
+
on_contention="skip",
|
|
715
|
+
soft_time_limit=25,
|
|
716
|
+
time_limit=30,
|
|
717
|
+
acks_late=True,
|
|
718
|
+
reject_on_worker_lost=True,
|
|
719
|
+
name="projects.drain_sprint_close_requests",
|
|
720
|
+
)
|
|
721
|
+
def drain_sprint_close_requests(self: object) -> None:
|
|
722
|
+
"""Dispatch any pending or stranded SprintCloseRequest rows.
|
|
723
|
+
|
|
724
|
+
Beat: every 30 s. Like the scheduling drain, this also recovers IN_FLIGHT
|
|
725
|
+
rows that have aged past the 5-minute orphan window (worker died mid-close).
|
|
726
|
+
"""
|
|
727
|
+
_do_drain()
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@idempotent_task(
|
|
731
|
+
lock_key_template="update_sprint_burndown_snapshots",
|
|
732
|
+
lock_ttl=300,
|
|
733
|
+
on_contention="skip",
|
|
734
|
+
soft_time_limit=120,
|
|
735
|
+
time_limit=180,
|
|
736
|
+
acks_late=True,
|
|
737
|
+
reject_on_worker_lost=True,
|
|
738
|
+
name="projects.update_sprint_burndown_snapshots",
|
|
739
|
+
)
|
|
740
|
+
def update_sprint_burndown_snapshots(self: object) -> None:
|
|
741
|
+
"""Write yesterday's burndown row for every ACTIVE sprint.
|
|
742
|
+
|
|
743
|
+
Beat: 01:00 UTC daily. Real-time UPSERTs from the task_status_changed
|
|
744
|
+
signal cover today's row; this Beat task fills in days where no status
|
|
745
|
+
changed (and therefore no signal fired).
|
|
746
|
+
"""
|
|
747
|
+
from trueppm_api.apps.projects.models import Sprint, SprintState
|
|
748
|
+
from trueppm_api.apps.projects.services import upsert_burndown_for_sprint
|
|
749
|
+
|
|
750
|
+
yesterday = timezone.localdate() - timedelta(days=1)
|
|
751
|
+
for sprint in Sprint.objects.filter(state=SprintState.ACTIVE, is_deleted=False):
|
|
752
|
+
try:
|
|
753
|
+
upsert_burndown_for_sprint(sprint, snapshot_date=yesterday)
|
|
754
|
+
except Exception:
|
|
755
|
+
logger.exception("update_sprint_burndown_snapshots: failed for sprint %s", sprint.pk)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@idempotent_task(
|
|
759
|
+
lock_key_template="generate_recurring_occurrences",
|
|
760
|
+
lock_ttl=300,
|
|
761
|
+
on_contention="skip",
|
|
762
|
+
soft_time_limit=120,
|
|
763
|
+
time_limit=180,
|
|
764
|
+
acks_late=True,
|
|
765
|
+
reject_on_worker_lost=True,
|
|
766
|
+
name="projects.generate_recurring_occurrences",
|
|
767
|
+
)
|
|
768
|
+
def generate_recurring_occurrences(self: object) -> None:
|
|
769
|
+
"""Lazily materialize upcoming occurrences for every active recurrence rule.
|
|
770
|
+
|
|
771
|
+
Beat: hourly. Generates only occurrences due within
|
|
772
|
+
``settings.TRUEPPM_RECURRENCE_HORIZON_DAYS`` (a bounded look-ahead — never the
|
|
773
|
+
full series) that do not already exist, honoring each rule's end condition.
|
|
774
|
+
Idempotent via the (rule, date) unique constraint and the skip-on-contention
|
|
775
|
+
lock; per-rule try/except so one malformed rule cannot starve the sweep (ADR-0090).
|
|
776
|
+
"""
|
|
777
|
+
from django.conf import settings as dj_settings
|
|
778
|
+
|
|
779
|
+
from trueppm_api.apps.projects.models import TaskRecurrenceRule
|
|
780
|
+
from trueppm_api.apps.projects.services import _generate_due_occurrences
|
|
781
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
782
|
+
|
|
783
|
+
horizon = getattr(dj_settings, "TRUEPPM_RECURRENCE_HORIZON_DAYS", 14)
|
|
784
|
+
total = 0
|
|
785
|
+
# Collect new occurrence ids per project so each project's open boards get a
|
|
786
|
+
# single bulk event rather than one per task.
|
|
787
|
+
created_by_project: dict[str, list[str]] = {}
|
|
788
|
+
rules = TaskRecurrenceRule.objects.filter(is_deleted=False).select_related(
|
|
789
|
+
"task", "task__assignee"
|
|
790
|
+
)
|
|
791
|
+
for rule in rules:
|
|
792
|
+
try:
|
|
793
|
+
created = _generate_due_occurrences(rule, horizon_days=horizon)
|
|
794
|
+
except Exception:
|
|
795
|
+
logger.exception("generate_recurring_occurrences: failed for rule %s", rule.pk)
|
|
796
|
+
continue
|
|
797
|
+
for task in created:
|
|
798
|
+
created_by_project.setdefault(str(task.project_id), []).append(str(task.id))
|
|
799
|
+
total += len(created)
|
|
800
|
+
if total:
|
|
801
|
+
logger.info("generate_recurring_occurrences: created %d occurrence(s)", total)
|
|
802
|
+
|
|
803
|
+
# Materialized occurrences are board-scoped tasks that show up on the project
|
|
804
|
+
# board; without a broadcast a connected client only sees them on its next manual
|
|
805
|
+
# refresh or delta pull (#1008). Emit one bulk event per project so open boards
|
|
806
|
+
# live-update. Deferred via on_commit to honor the broadcast contract (broadcast
|
|
807
|
+
# only after the rows are durably committed); this Beat task runs in autocommit,
|
|
808
|
+
# where the occurrences are already committed and the callback fires immediately.
|
|
809
|
+
for pid, task_ids in created_by_project.items():
|
|
810
|
+
|
|
811
|
+
def _broadcast(pid: str = pid, ids: list[str] = task_ids) -> None:
|
|
812
|
+
broadcast_board_event(pid, "tasks_bulk_mutated", {"task_ids": ids})
|
|
813
|
+
|
|
814
|
+
transaction.on_commit(_broadcast)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@idempotent_task(
|
|
818
|
+
lock_key_template="purge_sprint_close_requests",
|
|
819
|
+
lock_ttl=120,
|
|
820
|
+
on_contention="skip",
|
|
821
|
+
soft_time_limit=55,
|
|
822
|
+
time_limit=90,
|
|
823
|
+
acks_late=True,
|
|
824
|
+
reject_on_worker_lost=True,
|
|
825
|
+
name="projects.purge_sprint_close_requests",
|
|
826
|
+
)
|
|
827
|
+
def purge_sprint_close_requests(self: object) -> None:
|
|
828
|
+
"""Delete COMPLETED / *terminally* FAILED SprintCloseRequest rows older than 7 days.
|
|
829
|
+
|
|
830
|
+
The retention window is measured from ``requested_at``, so a request that sat
|
|
831
|
+
PENDING for longer than the window — a drain or broker outage, which the purge
|
|
832
|
+
survives because it excludes PENDING — is already older than the cutoff the
|
|
833
|
+
moment it finally runs. Without the ``next_attempt_at`` guard below, a
|
|
834
|
+
transient failure on that first attempt would schedule a 60-second retry and
|
|
835
|
+
the nightly purge could delete the row before the retry ever fired: the sprint
|
|
836
|
+
stays ACTIVE *and* the record of why is gone, which is strictly worse than the
|
|
837
|
+
stranding #2894 exists to fix. A row with a live retry clock is not finished,
|
|
838
|
+
whatever its status says, so it is not eligible for retention purging.
|
|
839
|
+
"""
|
|
840
|
+
from trueppm_api.apps.projects.models import (
|
|
841
|
+
SprintCloseRequest,
|
|
842
|
+
SprintCloseRequestStatus,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
846
|
+
deleted, _ = (
|
|
847
|
+
SprintCloseRequest.objects.filter(
|
|
848
|
+
status__in=[
|
|
849
|
+
SprintCloseRequestStatus.COMPLETED,
|
|
850
|
+
SprintCloseRequestStatus.FAILED,
|
|
851
|
+
],
|
|
852
|
+
requested_at__lt=cutoff,
|
|
853
|
+
)
|
|
854
|
+
.exclude(
|
|
855
|
+
status=SprintCloseRequestStatus.FAILED,
|
|
856
|
+
next_attempt_at__isnull=False,
|
|
857
|
+
)
|
|
858
|
+
.delete()
|
|
859
|
+
)
|
|
860
|
+
logger.info("purge_sprint_close_requests: deleted %d row(s)", deleted)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# ---------------------------------------------------------------------------
|
|
864
|
+
# Soft-deleted project retention purge (#1114, ADR-0173)
|
|
865
|
+
# ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
# Projects hard-deleted per transaction. Each project's DB-level CASCADE loads
|
|
868
|
+
# its whole child subtree (tasks, edges, sprints, risks, baselines, …) into the
|
|
869
|
+
# collector, so — unlike the flat single-table purges (webhook/taskrun/sync) — the
|
|
870
|
+
# delete is bounded into batches to cap collector memory and lock-hold time. A
|
|
871
|
+
# soft-deleted project is already invisible to every reader (#1112), so spreading
|
|
872
|
+
# the hard-delete across several transactions has no user-visible effect.
|
|
873
|
+
_PROJECT_PURGE_BATCH_SIZE = 50
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _do_project_purge(*, dry_run: bool = False, override_value: int | None = None) -> int:
|
|
877
|
+
"""Business logic for purge_soft_deleted_projects — extracted for testability.
|
|
878
|
+
|
|
879
|
+
Hard-deletes (row + DB CASCADE children) projects whose soft-delete age exceeds
|
|
880
|
+
the window resolved by ``resolve_retention`` (operator override → the
|
|
881
|
+
``TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS`` default, ADR-0173); ``None``
|
|
882
|
+
disables the purge (unbounded retention). Returns the number of ``Project`` rows
|
|
883
|
+
deleted, or — when ``dry_run`` — the number eligible. ``override_value`` forces a
|
|
884
|
+
hypothetical window (used by the System Health impact estimate).
|
|
885
|
+
|
|
886
|
+
Eligibility is ``is_deleted=True AND deleted_at <= now - window``. A NULL
|
|
887
|
+
``deleted_at`` is deliberately **excluded**: a project soft-deleted before the
|
|
888
|
+
``deleted_at`` column existed has an unknown age and must never be silently
|
|
889
|
+
hard-deleted — the safe default is to retain it (an operator can still remove it
|
|
890
|
+
via ``?force=true``).
|
|
891
|
+
|
|
892
|
+
Several FKs to Project are ``on_delete=PROTECT`` and would block a bare
|
|
893
|
+
``Project.delete()``, so the delete is delegated to ``hard_delete_projects``,
|
|
894
|
+
which resolves those children reflectively from ``_meta`` and purges them first —
|
|
895
|
+
the same helper the ``?force=true`` hard-delete path uses. This used to be a
|
|
896
|
+
hand-written ``ProjectMembership`` pre-delete asserting it was the *only* PROTECT
|
|
897
|
+
FK; that enumeration went stale when mention groups landed and a single project
|
|
898
|
+
that had ever carried one aborted the whole batch (#2372).
|
|
899
|
+
|
|
900
|
+
HistoricalProject rows are intentionally not touched here; they age out via the
|
|
901
|
+
separate history retention purge.
|
|
902
|
+
"""
|
|
903
|
+
from trueppm_api.apps.access.services import hard_delete_projects
|
|
904
|
+
from trueppm_api.apps.observability.retention import resolve_retention
|
|
905
|
+
from trueppm_api.apps.projects.models import Project
|
|
906
|
+
|
|
907
|
+
retention_days = (
|
|
908
|
+
override_value
|
|
909
|
+
if override_value is not None
|
|
910
|
+
else resolve_retention("TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS")
|
|
911
|
+
)
|
|
912
|
+
if retention_days is None:
|
|
913
|
+
return 0
|
|
914
|
+
|
|
915
|
+
cutoff = timezone.now() - timedelta(days=retention_days)
|
|
916
|
+
eligible = Project.objects.filter(
|
|
917
|
+
is_deleted=True,
|
|
918
|
+
deleted_at__isnull=False,
|
|
919
|
+
deleted_at__lte=cutoff,
|
|
920
|
+
)
|
|
921
|
+
if dry_run:
|
|
922
|
+
return eligible.count()
|
|
923
|
+
|
|
924
|
+
project_ids = list(eligible.values_list("pk", flat=True))
|
|
925
|
+
deleted_projects = 0
|
|
926
|
+
for start in range(0, len(project_ids), _PROJECT_PURGE_BATCH_SIZE):
|
|
927
|
+
batch = project_ids[start : start + _PROJECT_PURGE_BATCH_SIZE]
|
|
928
|
+
# hard_delete_projects is itself @transaction.atomic, so each batch remains a
|
|
929
|
+
# single transaction — the batching still caps collector memory and lock-hold
|
|
930
|
+
# time without one failure rolling back previously purged batches.
|
|
931
|
+
deleted_projects += hard_delete_projects(batch)
|
|
932
|
+
if deleted_projects:
|
|
933
|
+
logger.info(
|
|
934
|
+
"purge_soft_deleted_projects: hard-deleted %d soft-deleted project(s)",
|
|
935
|
+
deleted_projects,
|
|
936
|
+
)
|
|
937
|
+
return deleted_projects
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
@idempotent_task(
|
|
941
|
+
lock_key_template="purge_soft_deleted_projects",
|
|
942
|
+
lock_ttl=600,
|
|
943
|
+
on_contention="skip",
|
|
944
|
+
soft_time_limit=540,
|
|
945
|
+
time_limit=600,
|
|
946
|
+
acks_late=True,
|
|
947
|
+
reject_on_worker_lost=True,
|
|
948
|
+
name="projects.purge_soft_deleted_projects",
|
|
949
|
+
)
|
|
950
|
+
def purge_soft_deleted_projects(self: object) -> None:
|
|
951
|
+
"""Hard-delete soft-deleted projects past the retention window.
|
|
952
|
+
|
|
953
|
+
Dispatchable directly, but not on its own Beat schedule — the consolidated
|
|
954
|
+
retention coordinator (``retention.run_purge``) owns scheduled purging and picks
|
|
955
|
+
this up via the ``purge_registry`` binding (ADR-0173 §C), running it alongside
|
|
956
|
+
the other retention purges in one unified ``PurgeRun``.
|
|
957
|
+
"""
|
|
958
|
+
_do_project_purge()
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ---------------------------------------------------------------------------
|
|
962
|
+
# Drain implementation — extracted for testability
|
|
963
|
+
# ---------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
_ORPHAN_WINDOW = timedelta(minutes=5)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _do_drain() -> None:
|
|
970
|
+
from trueppm_api.apps.projects.models import (
|
|
971
|
+
SprintCloseRequest,
|
|
972
|
+
SprintCloseRequestFailureReason,
|
|
973
|
+
SprintCloseRequestStatus,
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
now = timezone.now()
|
|
977
|
+
orphan_cutoff = now - _ORPHAN_WINDOW
|
|
978
|
+
|
|
979
|
+
# Recover IN_FLIGHT rows that have stalled past the orphan window — the
|
|
980
|
+
# task_id may not match anything live, so we reset and let the next
|
|
981
|
+
# dispatch attempt acquire the per-request lock fresh.
|
|
982
|
+
#
|
|
983
|
+
# Bounded by the same budget as the failure path (#2894). ``close_sprint``
|
|
984
|
+
# increments ``attempt_count`` on every entry, so a close that orphans rather
|
|
985
|
+
# than raising — a worker OOM-killed on each attempt — was recovered here
|
|
986
|
+
# without limit while the counter climbed forever. A constant named as *the*
|
|
987
|
+
# close attempt budget that only one of the two recovery paths honors is a
|
|
988
|
+
# guard narrower than the class it names, which is how this bug survived in
|
|
989
|
+
# the failure path to begin with.
|
|
990
|
+
recovered = SprintCloseRequest.objects.filter(
|
|
991
|
+
status=SprintCloseRequestStatus.IN_FLIGHT,
|
|
992
|
+
started_at__lt=orphan_cutoff,
|
|
993
|
+
attempt_count__lt=_MAX_CLOSE_ATTEMPTS,
|
|
994
|
+
).update(status=SprintCloseRequestStatus.PENDING)
|
|
995
|
+
|
|
996
|
+
# An orphan whose budget is spent must be *abandoned*, not left IN_FLIGHT.
|
|
997
|
+
# Capping the recovery above without this would trade an unbounded loop for a
|
|
998
|
+
# row wedged in a non-terminal state that no sweep touches and no reader can
|
|
999
|
+
# interpret — the same silent stranding in a different status.
|
|
1000
|
+
#
|
|
1001
|
+
# Materialised row-by-row rather than left as one bulk .update() (#2992):
|
|
1002
|
+
# this is a terminal failure like any other, so it has to announce itself on
|
|
1003
|
+
# the sprint's board — and a bulk update returns a count, which cannot say
|
|
1004
|
+
# *which* projects to fan out to. Sliced for the same reason every other
|
|
1005
|
+
# statement in this sweep is: one UPDATE that costs the same whether it hits
|
|
1006
|
+
# one row or ten thousand becomes N BoardEvent inserts and N group_sends
|
|
1007
|
+
# once it broadcasts, inside a task with a soft time limit. A correlated
|
|
1008
|
+
# outage (workers OOM-killed across many projects) is exactly when this path
|
|
1009
|
+
# fires in bulk, so leaving it unbounded would turn a bad minute into a
|
|
1010
|
+
# worse one. The remainder is picked up on the next 30 s sweep.
|
|
1011
|
+
stalled = list(
|
|
1012
|
+
SprintCloseRequest.objects.filter(
|
|
1013
|
+
status=SprintCloseRequestStatus.IN_FLIGHT,
|
|
1014
|
+
started_at__lt=orphan_cutoff,
|
|
1015
|
+
attempt_count__gte=_MAX_CLOSE_ATTEMPTS,
|
|
1016
|
+
).values("pk", "sprint_id", "sprint__project_id", "attempt_count")[:_ABANDON_BUDGET]
|
|
1017
|
+
)
|
|
1018
|
+
abandoned = 0
|
|
1019
|
+
for row in stalled:
|
|
1020
|
+
# Each row belongs to a different project, so this is one event per board
|
|
1021
|
+
# rather than a loop fanning the same event out N times. Isolated per row:
|
|
1022
|
+
# a channel-layer or BoardEvent fault on one project must not abort the
|
|
1023
|
+
# sweep before it dispatches the fresh, healthy closes at the bottom of
|
|
1024
|
+
# this function — the bulk UPDATE this replaced could not fail that way.
|
|
1025
|
+
try:
|
|
1026
|
+
_finalize_close_failure(
|
|
1027
|
+
req_pk=row["pk"],
|
|
1028
|
+
sprint_id=row["sprint_id"],
|
|
1029
|
+
project_id=row["sprint__project_id"],
|
|
1030
|
+
error_message=(
|
|
1031
|
+
"The close stopped responding on every attempt and was abandoned. "
|
|
1032
|
+
"The sprint is still open."
|
|
1033
|
+
),
|
|
1034
|
+
failure_reason=SprintCloseRequestFailureReason.STALLED,
|
|
1035
|
+
attempt_count=row["attempt_count"],
|
|
1036
|
+
# Restores the predicate the single bulk UPDATE used to carry in
|
|
1037
|
+
# its WHERE clause. Without it the select-then-write split can
|
|
1038
|
+
# clobber a row that finished in between and announce a failure
|
|
1039
|
+
# for a sprint that closed.
|
|
1040
|
+
expected_status=SprintCloseRequestStatus.IN_FLIGHT,
|
|
1041
|
+
)
|
|
1042
|
+
except Exception:
|
|
1043
|
+
logger.exception(
|
|
1044
|
+
"drain_sprint_close_requests: abandoning stalled request %s failed", row["pk"]
|
|
1045
|
+
)
|
|
1046
|
+
continue
|
|
1047
|
+
abandoned += 1
|
|
1048
|
+
if abandoned:
|
|
1049
|
+
logger.error(
|
|
1050
|
+
"drain_sprint_close_requests: abandoned %d close request(s) stalled past "
|
|
1051
|
+
"the attempt budget",
|
|
1052
|
+
abandoned,
|
|
1053
|
+
)
|
|
1054
|
+
if recovered:
|
|
1055
|
+
logger.warning(
|
|
1056
|
+
"drain_sprint_close_requests: recovered %d orphaned IN_FLIGHT row(s)",
|
|
1057
|
+
recovered,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
# Re-queue FAILED rows whose retry clock has come round (#2894). Before this
|
|
1061
|
+
# the drain recovered IN_FLIGHT only, so a single unsnapshottable task left
|
|
1062
|
+
# the sprint ACTIVE indefinitely behind a terminally-FAILED request, with no
|
|
1063
|
+
# path back except manual DB intervention.
|
|
1064
|
+
#
|
|
1065
|
+
# `next_attempt_at__isnull=False` is the whole guard: a failure nothing can
|
|
1066
|
+
# fix (sprint cancelled, sprint not closable) and an exhausted budget both
|
|
1067
|
+
# leave it null, so neither is picked up here. `completed_at` is cleared so
|
|
1068
|
+
# the row does not read as finished while it waits to run again.
|
|
1069
|
+
# Capped like the dispatch slices below: a correlated failure could otherwise
|
|
1070
|
+
# flip an unbounded number of rows, and hold their locks, in one statement
|
|
1071
|
+
# inside a task that has a soft time limit.
|
|
1072
|
+
retry_ids = SprintCloseRequest.objects.filter(
|
|
1073
|
+
status=SprintCloseRequestStatus.FAILED,
|
|
1074
|
+
next_attempt_at__isnull=False,
|
|
1075
|
+
next_attempt_at__lte=now,
|
|
1076
|
+
attempt_count__lt=_MAX_CLOSE_ATTEMPTS,
|
|
1077
|
+
).values("pk")[:_RETRY_REQUEUE_BUDGET]
|
|
1078
|
+
requeued = SprintCloseRequest.objects.filter(pk__in=retry_ids).update(
|
|
1079
|
+
status=SprintCloseRequestStatus.PENDING,
|
|
1080
|
+
next_attempt_at=None,
|
|
1081
|
+
completed_at=None,
|
|
1082
|
+
# Cleared alongside completed_at: a row waiting to run again has not
|
|
1083
|
+
# started, and a stale started_at would both misread on the API and feed
|
|
1084
|
+
# the orphan sweep above a start time from a previous attempt.
|
|
1085
|
+
started_at=None,
|
|
1086
|
+
)
|
|
1087
|
+
if requeued:
|
|
1088
|
+
logger.warning(
|
|
1089
|
+
"drain_sprint_close_requests: re-queued %d failed close request(s) for retry",
|
|
1090
|
+
requeued,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
pending = list(
|
|
1094
|
+
SprintCloseRequest.objects.filter(
|
|
1095
|
+
status=SprintCloseRequestStatus.PENDING,
|
|
1096
|
+
requested_at__lt=now - timedelta(seconds=2),
|
|
1097
|
+
attempt_count=0,
|
|
1098
|
+
).order_by("requested_at")[:_DISPATCH_BUDGET]
|
|
1099
|
+
)
|
|
1100
|
+
# Retries draw from their own budget rather than competing for the slice
|
|
1101
|
+
# above (#2894). A re-queued row keeps its original ``requested_at`` — that
|
|
1102
|
+
# field answers "when did the user ask", and the read route shows it — so it
|
|
1103
|
+
# sorts ahead of every newer close. Before the retry sweep existed nothing
|
|
1104
|
+
# old could re-enter the PENDING pool, so a single ordered slice was safe;
|
|
1105
|
+
# now a correlated failure would let the retry cohort fill the whole budget
|
|
1106
|
+
# and strand fresh user closes behind it. Two budgets, neither starving.
|
|
1107
|
+
pending += list(
|
|
1108
|
+
SprintCloseRequest.objects.filter(
|
|
1109
|
+
status=SprintCloseRequestStatus.PENDING,
|
|
1110
|
+
requested_at__lt=now - timedelta(seconds=2),
|
|
1111
|
+
attempt_count__gt=0,
|
|
1112
|
+
).order_by("requested_at")[:_RETRY_DISPATCH_BUDGET]
|
|
1113
|
+
)
|
|
1114
|
+
dispatched = 0
|
|
1115
|
+
for req in pending:
|
|
1116
|
+
try:
|
|
1117
|
+
close_sprint.delay(str(req.id))
|
|
1118
|
+
except Exception:
|
|
1119
|
+
logger.warning(
|
|
1120
|
+
"drain_sprint_close_requests: broker unavailable — request %s stays pending",
|
|
1121
|
+
req.id,
|
|
1122
|
+
)
|
|
1123
|
+
continue
|
|
1124
|
+
dispatched += 1
|
|
1125
|
+
if dispatched or recovered or requeued:
|
|
1126
|
+
logger.info(
|
|
1127
|
+
"drain_sprint_close_requests: dispatched=%d recovered=%d requeued=%d",
|
|
1128
|
+
dispatched,
|
|
1129
|
+
recovered,
|
|
1130
|
+
requeued,
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
# ---------------------------------------------------------------------------
|
|
1135
|
+
# cascade_project_soft_delete — offloaded child tombstone cascade (#1112)
|
|
1136
|
+
# ---------------------------------------------------------------------------
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
@idempotent_task(
|
|
1140
|
+
lock_key_template="project_cascade_soft_delete:{0}",
|
|
1141
|
+
lock_ttl=600,
|
|
1142
|
+
on_contention="skip",
|
|
1143
|
+
max_retries=3,
|
|
1144
|
+
retry_backoff=10,
|
|
1145
|
+
retry_backoff_max=120,
|
|
1146
|
+
retry_jitter=True,
|
|
1147
|
+
soft_time_limit=540,
|
|
1148
|
+
time_limit=600,
|
|
1149
|
+
acks_late=True,
|
|
1150
|
+
reject_on_worker_lost=True,
|
|
1151
|
+
name="projects.cascade_project_soft_delete",
|
|
1152
|
+
)
|
|
1153
|
+
def cascade_project_soft_delete(self: object, project_id: str) -> None:
|
|
1154
|
+
"""Tombstone a soft-deleted project's children off the request path (#1112).
|
|
1155
|
+
|
|
1156
|
+
``ProjectViewSet.perform_destroy`` tombstones the project row synchronously
|
|
1157
|
+
(instant, so the project reads as gone) and enqueues this task to drain the
|
|
1158
|
+
potentially huge child cascade — tasks, dependency edges, sprints, risks, and
|
|
1159
|
+
baselines — that #1111 previously ran inline inside the request transaction.
|
|
1160
|
+
|
|
1161
|
+
Idempotency: the cascade only touches rows still ``is_deleted=False``, so a
|
|
1162
|
+
broker-retry, a duplicate dispatch, or the ``on_contention="skip"`` lock all
|
|
1163
|
+
resolve to a safe no-op. A vanished project (hard-deleted in the meantime, so
|
|
1164
|
+
its children are gone via DB CASCADE) is simply skipped.
|
|
1165
|
+
|
|
1166
|
+
No broadcast here: the ``project_deleted`` board event already fired from
|
|
1167
|
+
``perform_destroy`` on commit, and child tombstones reach mobile clients via
|
|
1168
|
+
the sync delta pull (the ``sync_seq`` cursor this cascade stamps, ADR-0686),
|
|
1169
|
+
not over WebSocket — so there is nothing to re-broadcast and no double-fire
|
|
1170
|
+
risk.
|
|
1171
|
+
"""
|
|
1172
|
+
from trueppm_api.apps.projects.models import (
|
|
1173
|
+
Project,
|
|
1174
|
+
cascade_project_children_soft_delete,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
if not Project.objects.filter(pk=project_id).exists():
|
|
1178
|
+
logger.info(
|
|
1179
|
+
"cascade_project_soft_delete: project %s gone (hard-deleted?) — skipping",
|
|
1180
|
+
project_id,
|
|
1181
|
+
)
|
|
1182
|
+
return
|
|
1183
|
+
|
|
1184
|
+
with transaction.atomic():
|
|
1185
|
+
cascade_project_children_soft_delete(project_id)
|
|
1186
|
+
logger.info("cascade_project_soft_delete: cascaded children for project %s", project_id)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
# ---------------------------------------------------------------------------
|
|
1190
|
+
# Async project export bundle (ADR-0219, #1266) — mirrors workspace export (ADR-0174)
|
|
1191
|
+
# ---------------------------------------------------------------------------
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
@shared_task( # type: ignore[untyped-decorator]
|
|
1195
|
+
bind=True,
|
|
1196
|
+
max_retries=EXPORT_MAX_RETRIES,
|
|
1197
|
+
soft_time_limit=600,
|
|
1198
|
+
time_limit=660,
|
|
1199
|
+
acks_late=True,
|
|
1200
|
+
reject_on_worker_lost=True,
|
|
1201
|
+
name="projects.run_project_export",
|
|
1202
|
+
)
|
|
1203
|
+
def run_project_export(self: object, job_id: str) -> None:
|
|
1204
|
+
"""Build the project export bundle for ``job_id`` (ADR-0219).
|
|
1205
|
+
|
|
1206
|
+
Idempotent: claims the job under ``select_for_update`` and no-ops unless it is
|
|
1207
|
+
``pending``/``running``, so a duplicate delivery (broker retry, drain re-dispatch)
|
|
1208
|
+
cannot produce two archives. Transient failures retry up to ``EXPORT_MAX_RETRIES``
|
|
1209
|
+
(the job stays ``running`` so a retry is allowed); on exhaustion the job is marked
|
|
1210
|
+
``failed`` and the Admin can request a fresh export.
|
|
1211
|
+
"""
|
|
1212
|
+
from trueppm_api.apps.projects.export_bundle import build_and_store_project_archive
|
|
1213
|
+
from trueppm_api.apps.projects.models import ExportJobStatus, ProjectExportJob
|
|
1214
|
+
|
|
1215
|
+
with transaction.atomic():
|
|
1216
|
+
job = ProjectExportJob.objects.select_for_update().filter(pk=job_id).first()
|
|
1217
|
+
if job is None:
|
|
1218
|
+
logger.warning("run_project_export: job %s not found", job_id)
|
|
1219
|
+
return
|
|
1220
|
+
if job.status not in (ExportJobStatus.PENDING, ExportJobStatus.RUNNING):
|
|
1221
|
+
logger.info("run_project_export: job %s already %s, skipping", job_id, job.status)
|
|
1222
|
+
return
|
|
1223
|
+
job.status = ExportJobStatus.RUNNING
|
|
1224
|
+
job.started_at = timezone.now()
|
|
1225
|
+
job.celery_task_id = getattr(getattr(self, "request", None), "id", "") or ""
|
|
1226
|
+
job.save(update_fields=["status", "started_at", "celery_task_id"])
|
|
1227
|
+
|
|
1228
|
+
try:
|
|
1229
|
+
storage_path, size = build_and_store_project_archive(job_id)
|
|
1230
|
+
except Exception as exc:
|
|
1231
|
+
retries = getattr(getattr(self, "request", None), "retries", 0)
|
|
1232
|
+
if retries < EXPORT_MAX_RETRIES:
|
|
1233
|
+
logger.warning("run_project_export: job %s failed, retrying", job_id, exc_info=True)
|
|
1234
|
+
raise self.retry(exc=exc, countdown=10 * (2**retries)) from exc # type: ignore[attr-defined]
|
|
1235
|
+
logger.exception("run_project_export: job %s failed permanently", job_id)
|
|
1236
|
+
ProjectExportJob.objects.filter(pk=job_id).update(
|
|
1237
|
+
status=ExportJobStatus.FAILED,
|
|
1238
|
+
error_detail=str(exc)[:2000],
|
|
1239
|
+
completed_at=timezone.now(),
|
|
1240
|
+
)
|
|
1241
|
+
return
|
|
1242
|
+
|
|
1243
|
+
retention = _export_retention_days()
|
|
1244
|
+
expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
|
|
1245
|
+
ProjectExportJob.objects.filter(pk=job_id).update(
|
|
1246
|
+
status=ExportJobStatus.SUCCESS,
|
|
1247
|
+
file_path=storage_path,
|
|
1248
|
+
file_size=size,
|
|
1249
|
+
expires_at=expires_at,
|
|
1250
|
+
completed_at=timezone.now(),
|
|
1251
|
+
error_detail="",
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
@idempotent_task(
|
|
1256
|
+
lock_key_template="drain_project_exports",
|
|
1257
|
+
lock_ttl=60,
|
|
1258
|
+
on_contention="skip",
|
|
1259
|
+
soft_time_limit=25,
|
|
1260
|
+
time_limit=30,
|
|
1261
|
+
acks_late=True,
|
|
1262
|
+
reject_on_worker_lost=True,
|
|
1263
|
+
name="projects.drain_project_exports",
|
|
1264
|
+
)
|
|
1265
|
+
def drain_project_exports(self: object) -> None:
|
|
1266
|
+
"""Re-dispatch project export jobs stuck in ``pending`` (broker down at on_commit)."""
|
|
1267
|
+
_do_drain_project_exports()
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
@idempotent_task(
|
|
1271
|
+
lock_key_template="purge_expired_project_exports",
|
|
1272
|
+
lock_ttl=120,
|
|
1273
|
+
on_contention="skip",
|
|
1274
|
+
soft_time_limit=55,
|
|
1275
|
+
time_limit=90,
|
|
1276
|
+
acks_late=True,
|
|
1277
|
+
reject_on_worker_lost=True,
|
|
1278
|
+
name="projects.purge_expired_project_exports",
|
|
1279
|
+
)
|
|
1280
|
+
def purge_expired_project_exports(self: object) -> None:
|
|
1281
|
+
"""Delete project export jobs past their link expiry, and their stored archives."""
|
|
1282
|
+
_do_purge_expired_project_exports()
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _export_retention_days() -> int | None:
|
|
1286
|
+
from django.conf import settings
|
|
1287
|
+
|
|
1288
|
+
return getattr(settings, "TRUEPPM_EXPORT_RETENTION_DAYS", DEFAULT_EXPORT_RETENTION_DAYS)
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _do_drain_project_exports() -> None:
|
|
1292
|
+
from trueppm_api.apps.projects.models import ExportJobStatus, ProjectExportJob
|
|
1293
|
+
|
|
1294
|
+
orphan_cutoff = timezone.now() - timedelta(minutes=EXPORT_ORPHAN_WINDOW_MINUTES)
|
|
1295
|
+
stuck = list(
|
|
1296
|
+
ProjectExportJob.objects.filter(
|
|
1297
|
+
status=ExportJobStatus.PENDING,
|
|
1298
|
+
celery_task_id="",
|
|
1299
|
+
created_at__lt=orphan_cutoff,
|
|
1300
|
+
).order_by("created_at")[:EXPORT_DRAIN_BATCH_SIZE]
|
|
1301
|
+
)
|
|
1302
|
+
if not stuck:
|
|
1303
|
+
return
|
|
1304
|
+
for job in stuck:
|
|
1305
|
+
try:
|
|
1306
|
+
run_project_export.delay(str(job.id))
|
|
1307
|
+
except Exception: # pragma: no cover - broker still down, next tick retries
|
|
1308
|
+
logger.warning("drain_project_exports: broker still unavailable for %s", job.id)
|
|
1309
|
+
break
|
|
1310
|
+
logger.info("drain_project_exports: re-dispatched %d job(s)", len(stuck))
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _do_purge_expired_project_exports() -> None:
|
|
1314
|
+
from django.core.files.storage import default_storage
|
|
1315
|
+
|
|
1316
|
+
from trueppm_api.apps.projects.models import ProjectExportJob
|
|
1317
|
+
|
|
1318
|
+
retention = _export_retention_days()
|
|
1319
|
+
if retention is None: # retention disabled — keep archives indefinitely
|
|
1320
|
+
return
|
|
1321
|
+
expired = ProjectExportJob.objects.filter(expires_at__lt=timezone.now())
|
|
1322
|
+
count = 0
|
|
1323
|
+
for job in expired.iterator():
|
|
1324
|
+
if job.file_path:
|
|
1325
|
+
try:
|
|
1326
|
+
default_storage.delete(job.file_path)
|
|
1327
|
+
except OSError: # pragma: no cover - storage drift, still drop the row
|
|
1328
|
+
logger.warning(
|
|
1329
|
+
"purge_expired_project_exports: could not delete file for %s", job.id
|
|
1330
|
+
)
|
|
1331
|
+
job.delete()
|
|
1332
|
+
count += 1
|
|
1333
|
+
if count:
|
|
1334
|
+
logger.info("purge_expired_project_exports: deleted %d expired export(s)", count)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
# ---------------------------------------------------------------------------
|
|
1338
|
+
# Async program export bundle (ADR-0219, #1958) — the program-grain sibling of
|
|
1339
|
+
# the project export tasks above; identical durable-execution shape.
|
|
1340
|
+
# ---------------------------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
@shared_task( # type: ignore[untyped-decorator]
|
|
1344
|
+
bind=True,
|
|
1345
|
+
max_retries=EXPORT_MAX_RETRIES,
|
|
1346
|
+
soft_time_limit=600,
|
|
1347
|
+
time_limit=660,
|
|
1348
|
+
acks_late=True,
|
|
1349
|
+
reject_on_worker_lost=True,
|
|
1350
|
+
name="projects.run_program_export",
|
|
1351
|
+
)
|
|
1352
|
+
def run_program_export(self: object, job_id: str) -> None:
|
|
1353
|
+
"""Build the program export bundle for ``job_id`` (ADR-0219, #1958).
|
|
1354
|
+
|
|
1355
|
+
Idempotent: claims the job under ``select_for_update`` and no-ops unless it is
|
|
1356
|
+
``pending``/``running``, so a duplicate delivery (broker retry, drain re-dispatch)
|
|
1357
|
+
cannot produce two archives. Transient failures retry up to ``EXPORT_MAX_RETRIES``;
|
|
1358
|
+
on exhaustion the job is marked ``failed`` and the Admin can request a fresh export.
|
|
1359
|
+
"""
|
|
1360
|
+
from trueppm_api.apps.projects.export_bundle import build_and_store_program_archive
|
|
1361
|
+
from trueppm_api.apps.projects.models import ExportJobStatus, ProgramExportJob
|
|
1362
|
+
|
|
1363
|
+
with transaction.atomic():
|
|
1364
|
+
job = ProgramExportJob.objects.select_for_update().filter(pk=job_id).first()
|
|
1365
|
+
if job is None:
|
|
1366
|
+
logger.warning("run_program_export: job %s not found", job_id)
|
|
1367
|
+
return
|
|
1368
|
+
if job.status not in (ExportJobStatus.PENDING, ExportJobStatus.RUNNING):
|
|
1369
|
+
logger.info("run_program_export: job %s already %s, skipping", job_id, job.status)
|
|
1370
|
+
return
|
|
1371
|
+
job.status = ExportJobStatus.RUNNING
|
|
1372
|
+
job.started_at = timezone.now()
|
|
1373
|
+
job.celery_task_id = getattr(getattr(self, "request", None), "id", "") or ""
|
|
1374
|
+
job.save(update_fields=["status", "started_at", "celery_task_id"])
|
|
1375
|
+
|
|
1376
|
+
try:
|
|
1377
|
+
storage_path, size = build_and_store_program_archive(job_id)
|
|
1378
|
+
except Exception as exc:
|
|
1379
|
+
retries = getattr(getattr(self, "request", None), "retries", 0)
|
|
1380
|
+
if retries < EXPORT_MAX_RETRIES:
|
|
1381
|
+
logger.warning("run_program_export: job %s failed, retrying", job_id, exc_info=True)
|
|
1382
|
+
raise self.retry(exc=exc, countdown=10 * (2**retries)) from exc # type: ignore[attr-defined]
|
|
1383
|
+
logger.exception("run_program_export: job %s failed permanently", job_id)
|
|
1384
|
+
ProgramExportJob.objects.filter(pk=job_id).update(
|
|
1385
|
+
status=ExportJobStatus.FAILED,
|
|
1386
|
+
error_detail=str(exc)[:2000],
|
|
1387
|
+
completed_at=timezone.now(),
|
|
1388
|
+
)
|
|
1389
|
+
return
|
|
1390
|
+
|
|
1391
|
+
retention = _export_retention_days()
|
|
1392
|
+
expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
|
|
1393
|
+
ProgramExportJob.objects.filter(pk=job_id).update(
|
|
1394
|
+
status=ExportJobStatus.SUCCESS,
|
|
1395
|
+
file_path=storage_path,
|
|
1396
|
+
file_size=size,
|
|
1397
|
+
expires_at=expires_at,
|
|
1398
|
+
completed_at=timezone.now(),
|
|
1399
|
+
error_detail="",
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
@idempotent_task(
|
|
1404
|
+
lock_key_template="drain_program_exports",
|
|
1405
|
+
lock_ttl=60,
|
|
1406
|
+
on_contention="skip",
|
|
1407
|
+
soft_time_limit=25,
|
|
1408
|
+
time_limit=30,
|
|
1409
|
+
acks_late=True,
|
|
1410
|
+
reject_on_worker_lost=True,
|
|
1411
|
+
name="projects.drain_program_exports",
|
|
1412
|
+
)
|
|
1413
|
+
def drain_program_exports(self: object) -> None:
|
|
1414
|
+
"""Re-dispatch program export jobs stuck in ``pending`` (broker down at on_commit)."""
|
|
1415
|
+
_do_drain_program_exports()
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
@idempotent_task(
|
|
1419
|
+
lock_key_template="purge_expired_program_exports",
|
|
1420
|
+
lock_ttl=120,
|
|
1421
|
+
on_contention="skip",
|
|
1422
|
+
soft_time_limit=55,
|
|
1423
|
+
time_limit=90,
|
|
1424
|
+
acks_late=True,
|
|
1425
|
+
reject_on_worker_lost=True,
|
|
1426
|
+
name="projects.purge_expired_program_exports",
|
|
1427
|
+
)
|
|
1428
|
+
def purge_expired_program_exports(self: object) -> None:
|
|
1429
|
+
"""Delete program export jobs past their link expiry, and their stored archives."""
|
|
1430
|
+
_do_purge_expired_program_exports()
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _do_drain_program_exports() -> None:
|
|
1434
|
+
from trueppm_api.apps.projects.models import ExportJobStatus, ProgramExportJob
|
|
1435
|
+
|
|
1436
|
+
orphan_cutoff = timezone.now() - timedelta(minutes=EXPORT_ORPHAN_WINDOW_MINUTES)
|
|
1437
|
+
stuck = list(
|
|
1438
|
+
ProgramExportJob.objects.filter(
|
|
1439
|
+
status=ExportJobStatus.PENDING,
|
|
1440
|
+
celery_task_id="",
|
|
1441
|
+
created_at__lt=orphan_cutoff,
|
|
1442
|
+
).order_by("created_at")[:EXPORT_DRAIN_BATCH_SIZE]
|
|
1443
|
+
)
|
|
1444
|
+
if not stuck:
|
|
1445
|
+
return
|
|
1446
|
+
for job in stuck:
|
|
1447
|
+
try:
|
|
1448
|
+
run_program_export.delay(str(job.id))
|
|
1449
|
+
except Exception: # pragma: no cover - broker still down, next tick retries
|
|
1450
|
+
logger.warning("drain_program_exports: broker still unavailable for %s", job.id)
|
|
1451
|
+
break
|
|
1452
|
+
logger.info("drain_program_exports: re-dispatched %d job(s)", len(stuck))
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _do_purge_expired_program_exports() -> None:
|
|
1456
|
+
from django.core.files.storage import default_storage
|
|
1457
|
+
|
|
1458
|
+
from trueppm_api.apps.projects.models import ProgramExportJob
|
|
1459
|
+
|
|
1460
|
+
retention = _export_retention_days()
|
|
1461
|
+
if retention is None: # retention disabled — keep archives indefinitely
|
|
1462
|
+
return
|
|
1463
|
+
# Bounded per run (#2615). Each iteration is a storage DELETE plus a row
|
|
1464
|
+
# DELETE, and the task carries a 55 s soft limit — an unbounded walk over a
|
|
1465
|
+
# backlog larger than one window is SIGKILLed partway rather than draining,
|
|
1466
|
+
# so the same oversized backlog is retried from the start on every tick and
|
|
1467
|
+
# never shrinks. A slice drains it incrementally instead: whatever is left
|
|
1468
|
+
# is picked up by the next scheduled run.
|
|
1469
|
+
expired = ProgramExportJob.objects.filter(expires_at__lt=timezone.now()).order_by("expires_at")
|
|
1470
|
+
count = 0
|
|
1471
|
+
for job in expired[:PURGE_BATCH_SIZE]:
|
|
1472
|
+
if job.file_path:
|
|
1473
|
+
try:
|
|
1474
|
+
default_storage.delete(job.file_path)
|
|
1475
|
+
except OSError: # pragma: no cover - storage drift, still drop the row
|
|
1476
|
+
logger.warning(
|
|
1477
|
+
"purge_expired_program_exports: could not delete file for %s", job.id
|
|
1478
|
+
)
|
|
1479
|
+
job.delete()
|
|
1480
|
+
count += 1
|
|
1481
|
+
if count:
|
|
1482
|
+
logger.info("purge_expired_program_exports: deleted %d expired export(s)", count)
|
|
1483
|
+
if count == PURGE_BATCH_SIZE and (remaining := expired.count()):
|
|
1484
|
+
logger.info(
|
|
1485
|
+
"purge_expired_program_exports: batch full, %d expired export(s) remain for the "
|
|
1486
|
+
"next run",
|
|
1487
|
+
remaining,
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
# ---------------------------------------------------------------------------
|
|
1492
|
+
# Async program seed import (ADR-0726, #2574) — the mirror of the export tasks
|
|
1493
|
+
# above. Same durable-execution shape; the payload travels through storage.
|
|
1494
|
+
# ---------------------------------------------------------------------------
|
|
1495
|
+
|
|
1496
|
+
IMPORT_MAX_RETRIES = 3 # ADR-0726 §Durable Execution item 8
|
|
1497
|
+
IMPORT_ORPHAN_WINDOW_MINUTES = 10 # ADR-0726 §Durable Execution item 3
|
|
1498
|
+
IMPORT_DRAIN_BATCH_SIZE = 10
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _import_retention_days() -> int | None:
|
|
1502
|
+
"""Days a terminal import job (and its stored payload) is kept; ``None`` disables."""
|
|
1503
|
+
from django.conf import settings
|
|
1504
|
+
|
|
1505
|
+
value = getattr(settings, "TRUEPPM_IMPORT_RETENTION_DAYS", 7)
|
|
1506
|
+
return None if value in (None, 0) else int(value)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
@shared_task( # type: ignore[untyped-decorator]
|
|
1510
|
+
bind=True,
|
|
1511
|
+
max_retries=IMPORT_MAX_RETRIES,
|
|
1512
|
+
soft_time_limit=1800,
|
|
1513
|
+
time_limit=1860,
|
|
1514
|
+
acks_late=True,
|
|
1515
|
+
reject_on_worker_lost=True,
|
|
1516
|
+
name="projects.run_program_import",
|
|
1517
|
+
)
|
|
1518
|
+
def run_program_import(self: object, job_id: str) -> None:
|
|
1519
|
+
"""Build the subtree for a queued seed import (ADR-0726, #2574).
|
|
1520
|
+
|
|
1521
|
+
The request already did the parts that must be synchronous: it validated the
|
|
1522
|
+
document, resolved and performed any confirmed replacement, and created the
|
|
1523
|
+
program shell. This task only fills that shell, which is why it is *purely
|
|
1524
|
+
additive* — and why the claim below is load-bearing rather than decorative.
|
|
1525
|
+
A duplicate delivery (drain re-dispatch, ``acks_late`` redelivery after a
|
|
1526
|
+
worker loss) would otherwise run the whole build a second time into the same
|
|
1527
|
+
program and double every task, dependency, and sprint; ``Task`` carries no
|
|
1528
|
+
``(project, wbs_path)`` unique constraint to catch it.
|
|
1529
|
+
|
|
1530
|
+
Only transient faults retry. A validation or referential failure is
|
|
1531
|
+
deterministic — and the request already rejected those synchronously — so it
|
|
1532
|
+
goes straight to ``failed`` with the diagnostics on the row.
|
|
1533
|
+
|
|
1534
|
+
A failed job deliberately leaves the empty program shell in place rather than
|
|
1535
|
+
cleaning it up: the replaced subtree is in Trash, and the Owner needs both the
|
|
1536
|
+
shell and the failed job row to reason about what happened.
|
|
1537
|
+
"""
|
|
1538
|
+
import json
|
|
1539
|
+
|
|
1540
|
+
from django.core.files.storage import default_storage
|
|
1541
|
+
|
|
1542
|
+
from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
|
|
1543
|
+
from trueppm_api.apps.projects.seed import SeedValidationError
|
|
1544
|
+
from trueppm_api.apps.projects.seed import import_seed as run_import
|
|
1545
|
+
|
|
1546
|
+
with transaction.atomic():
|
|
1547
|
+
job = ProgramImportJob.objects.select_for_update().filter(pk=job_id).first()
|
|
1548
|
+
if job is None:
|
|
1549
|
+
logger.warning("run_program_import: job %s not found", job_id)
|
|
1550
|
+
return
|
|
1551
|
+
if job.status not in (ImportJobStatus.PENDING, ImportJobStatus.RUNNING):
|
|
1552
|
+
logger.info("run_program_import: job %s already %s, skipping", job_id, job.status)
|
|
1553
|
+
return
|
|
1554
|
+
job.status = ImportJobStatus.RUNNING
|
|
1555
|
+
job.started_at = timezone.now()
|
|
1556
|
+
job.celery_task_id = getattr(getattr(self, "request", None), "id", "") or ""
|
|
1557
|
+
job.save(update_fields=["status", "started_at", "celery_task_id"])
|
|
1558
|
+
|
|
1559
|
+
program = job.program
|
|
1560
|
+
|
|
1561
|
+
# ``requested_by`` is SET_NULL, so an account deleted between the 202 and
|
|
1562
|
+
# this run leaves the importer with no owner to build memberships against.
|
|
1563
|
+
# It fails safely today — the importer raises rather than granting anything
|
|
1564
|
+
# to ``None`` — but it does so as an unhandled exception that burns all three
|
|
1565
|
+
# retries first and lands a traceback string in ``error_detail``. The
|
|
1566
|
+
# condition is deterministic, so refuse it up front with a reason the
|
|
1567
|
+
# polling client can render (#2615).
|
|
1568
|
+
if job.requested_by_id is None:
|
|
1569
|
+
logger.warning("run_program_import: job %s has no requester, refusing", job_id)
|
|
1570
|
+
_fail_import_job(
|
|
1571
|
+
job_id,
|
|
1572
|
+
"The account that requested this import no longer exists. "
|
|
1573
|
+
"Re-import as an existing user.",
|
|
1574
|
+
)
|
|
1575
|
+
return
|
|
1576
|
+
|
|
1577
|
+
try:
|
|
1578
|
+
with default_storage.open(job.file_path, "rb") as handle:
|
|
1579
|
+
payload = json.loads(handle.read().decode("utf-8"))
|
|
1580
|
+
except Exception:
|
|
1581
|
+
# The payload is gone or unreadable: deterministic, so never retry. The
|
|
1582
|
+
# reason is logged, not persisted — a storage exception renders the
|
|
1583
|
+
# absolute key path, and ``error_detail`` is served verbatim to any
|
|
1584
|
+
# program Admin while ``file_path`` is deliberately withheld from the
|
|
1585
|
+
# same serializer.
|
|
1586
|
+
logger.exception("run_program_import: payload unreadable for job %s", job_id)
|
|
1587
|
+
_fail_import_job(job_id, "The uploaded seed could no longer be read. Re-import the file.")
|
|
1588
|
+
return
|
|
1589
|
+
|
|
1590
|
+
try:
|
|
1591
|
+
# ``target_program`` adopts the shell the request created, which also
|
|
1592
|
+
# tells the importer to skip its own replace pass entirely — the
|
|
1593
|
+
# replacement, if any, already happened inside the request the operator
|
|
1594
|
+
# authorized (ADR-0726 §4).
|
|
1595
|
+
run_import(
|
|
1596
|
+
payload,
|
|
1597
|
+
owner=job.requested_by,
|
|
1598
|
+
create_users=False,
|
|
1599
|
+
target_program=program,
|
|
1600
|
+
)
|
|
1601
|
+
except SeedValidationError as exc:
|
|
1602
|
+
_fail_import_job(job_id, "; ".join(exc.errors)[:2000])
|
|
1603
|
+
return
|
|
1604
|
+
except Exception as exc:
|
|
1605
|
+
retries = getattr(getattr(self, "request", None), "retries", 0)
|
|
1606
|
+
if retries < IMPORT_MAX_RETRIES:
|
|
1607
|
+
logger.warning("run_program_import: job %s failed, retrying", job_id, exc_info=True)
|
|
1608
|
+
raise self.retry(exc=exc, countdown=10 * (2**retries)) from exc # type: ignore[attr-defined]
|
|
1609
|
+
logger.exception("run_program_import: job %s failed permanently", job_id)
|
|
1610
|
+
_fail_import_job(job_id, str(exc)[:2000])
|
|
1611
|
+
return
|
|
1612
|
+
|
|
1613
|
+
retention = _import_retention_days()
|
|
1614
|
+
expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
|
|
1615
|
+
ProgramImportJob.objects.filter(pk=job_id).update(
|
|
1616
|
+
status=ImportJobStatus.SUCCESS,
|
|
1617
|
+
result_summary=_import_summary(program),
|
|
1618
|
+
expires_at=expires_at,
|
|
1619
|
+
completed_at=timezone.now(),
|
|
1620
|
+
error_detail="",
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _import_summary(program: Any) -> dict[str, int]:
|
|
1625
|
+
"""Entity counts the polling client renders once the job reaches ``success``."""
|
|
1626
|
+
from trueppm_api.apps.projects.models import Dependency, Project, Sprint, Task
|
|
1627
|
+
|
|
1628
|
+
project_ids = list(
|
|
1629
|
+
Project.objects.filter(program=program, is_deleted=False).values_list("pk", flat=True)
|
|
1630
|
+
)
|
|
1631
|
+
return {
|
|
1632
|
+
"projects": len(project_ids),
|
|
1633
|
+
"tasks": Task.objects.filter(project_id__in=project_ids, is_deleted=False).count(),
|
|
1634
|
+
"sprints": Sprint.objects.filter(project_id__in=project_ids, is_deleted=False).count(),
|
|
1635
|
+
"dependencies": Dependency.objects.filter(
|
|
1636
|
+
predecessor__project_id__in=project_ids, is_deleted=False
|
|
1637
|
+
).count(),
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _fail_import_job(job_id: str, detail: str) -> None:
|
|
1642
|
+
"""Mark a job ``failed`` with a reason the polling client can render.
|
|
1643
|
+
|
|
1644
|
+
Persisted rather than only logged: an async import failure has to be visible
|
|
1645
|
+
on the surface that launched it, which is the whole point of returning a job
|
|
1646
|
+
handle instead of a 504.
|
|
1647
|
+
"""
|
|
1648
|
+
from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
|
|
1649
|
+
|
|
1650
|
+
retention = _import_retention_days()
|
|
1651
|
+
expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
|
|
1652
|
+
ProgramImportJob.objects.filter(pk=job_id).update(
|
|
1653
|
+
status=ImportJobStatus.FAILED,
|
|
1654
|
+
error_detail=detail or "The seed could not be imported.",
|
|
1655
|
+
expires_at=expires_at,
|
|
1656
|
+
completed_at=timezone.now(),
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
@idempotent_task(
|
|
1661
|
+
lock_key_template="drain_program_imports",
|
|
1662
|
+
lock_ttl=60,
|
|
1663
|
+
on_contention="skip",
|
|
1664
|
+
soft_time_limit=25,
|
|
1665
|
+
time_limit=30,
|
|
1666
|
+
acks_late=True,
|
|
1667
|
+
reject_on_worker_lost=True,
|
|
1668
|
+
name="projects.drain_program_imports",
|
|
1669
|
+
)
|
|
1670
|
+
def drain_program_imports(self: object) -> None:
|
|
1671
|
+
"""Re-dispatch seed import jobs stuck in ``pending`` (broker down at on_commit)."""
|
|
1672
|
+
_do_drain_program_imports()
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
@idempotent_task(
|
|
1676
|
+
lock_key_template="purge_expired_program_imports",
|
|
1677
|
+
lock_ttl=120,
|
|
1678
|
+
on_contention="skip",
|
|
1679
|
+
soft_time_limit=55,
|
|
1680
|
+
time_limit=90,
|
|
1681
|
+
acks_late=True,
|
|
1682
|
+
reject_on_worker_lost=True,
|
|
1683
|
+
name="projects.purge_expired_program_imports",
|
|
1684
|
+
)
|
|
1685
|
+
def purge_expired_program_imports(self: object) -> None:
|
|
1686
|
+
"""Delete terminal seed import jobs past retention, and their stored payloads."""
|
|
1687
|
+
_do_purge_expired_program_imports()
|
|
1688
|
+
|
|
1689
|
+
|
|
1690
|
+
def _do_drain_program_imports() -> None:
|
|
1691
|
+
from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
|
|
1692
|
+
|
|
1693
|
+
# Only rows whose on_commit dispatch demonstrably never landed: still
|
|
1694
|
+
# pending, never assigned a task id, and older than the window. A row inside
|
|
1695
|
+
# an open on_commit callback is invisible until commit, so the age floor is
|
|
1696
|
+
# what stops the drain racing an in-flight upload it would double-dispatch.
|
|
1697
|
+
orphan_cutoff = timezone.now() - timedelta(minutes=IMPORT_ORPHAN_WINDOW_MINUTES)
|
|
1698
|
+
stuck = list(
|
|
1699
|
+
ProgramImportJob.objects.filter(
|
|
1700
|
+
status=ImportJobStatus.PENDING,
|
|
1701
|
+
celery_task_id="",
|
|
1702
|
+
created_at__lt=orphan_cutoff,
|
|
1703
|
+
).order_by("created_at")[:IMPORT_DRAIN_BATCH_SIZE]
|
|
1704
|
+
)
|
|
1705
|
+
if not stuck:
|
|
1706
|
+
return
|
|
1707
|
+
for job in stuck:
|
|
1708
|
+
try:
|
|
1709
|
+
run_program_import.delay(str(job.id))
|
|
1710
|
+
except Exception: # pragma: no cover - broker still down, next tick retries
|
|
1711
|
+
logger.warning("drain_program_imports: broker still unavailable for %s", job.id)
|
|
1712
|
+
break
|
|
1713
|
+
logger.info("drain_program_imports: re-dispatched %d job(s)", len(stuck))
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _do_purge_expired_program_imports() -> None:
|
|
1717
|
+
from django.core.files.storage import default_storage
|
|
1718
|
+
|
|
1719
|
+
from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
|
|
1720
|
+
|
|
1721
|
+
retention = _import_retention_days()
|
|
1722
|
+
if retention is None: # retention disabled — keep rows indefinitely
|
|
1723
|
+
return
|
|
1724
|
+
# Bounded per run for the same reason as the export purge above (#2615).
|
|
1725
|
+
expired = ProgramImportJob.objects.filter(expires_at__lt=timezone.now()).order_by("expires_at")
|
|
1726
|
+
count = 0
|
|
1727
|
+
reaped = 0
|
|
1728
|
+
for job in expired[:PURGE_BATCH_SIZE]:
|
|
1729
|
+
if job.file_path:
|
|
1730
|
+
try:
|
|
1731
|
+
default_storage.delete(job.file_path)
|
|
1732
|
+
except OSError: # pragma: no cover - storage drift, still drop the row
|
|
1733
|
+
logger.warning(
|
|
1734
|
+
"purge_expired_program_imports: could not delete payload for %s", job.id
|
|
1735
|
+
)
|
|
1736
|
+
# A failed import deliberately keeps its program shell so the Owner can
|
|
1737
|
+
# reason about what happened (ADR-0726 §Durable Execution 8). That
|
|
1738
|
+
# reasoning needs the *job row*, and the job row is what this loop is
|
|
1739
|
+
# deleting — so past retention the shell is no longer evidence of
|
|
1740
|
+
# anything, it is only an empty program in the Owner's list that nothing
|
|
1741
|
+
# reaps. At the seed_import throttle's 6/min that is ~8,600 of them per
|
|
1742
|
+
# account per day. Retire it here, at the same moment its explanation
|
|
1743
|
+
# goes away, and only if it is still empty: anything the Owner has since
|
|
1744
|
+
# built under it makes it theirs, not the importer's leftover.
|
|
1745
|
+
# One transaction per job, not one for the batch: a storage error on the
|
|
1746
|
+
# next row must not roll back the rows already reaped, and the whole
|
|
1747
|
+
# point of the batch is that it can be interrupted and resumed. The
|
|
1748
|
+
# atomic block matters because ``soft_delete`` is two statements — the
|
|
1749
|
+
# row UPDATE and the ``deleted_version`` stamp the sync tombstone is read
|
|
1750
|
+
# from — and this runs in a beat task with no ambient transaction, so
|
|
1751
|
+
# without it a crash between them leaves a tombstone sync cannot see.
|
|
1752
|
+
with transaction.atomic():
|
|
1753
|
+
if job.status == ImportJobStatus.FAILED and _reap_empty_import_shell(job):
|
|
1754
|
+
reaped += 1
|
|
1755
|
+
job.delete()
|
|
1756
|
+
count += 1
|
|
1757
|
+
if count:
|
|
1758
|
+
logger.info("purge_expired_program_imports: deleted %d expired import(s)", count)
|
|
1759
|
+
if reaped:
|
|
1760
|
+
logger.info(
|
|
1761
|
+
"purge_expired_program_imports: retired %d empty failed-import shell(s)", reaped
|
|
1762
|
+
)
|
|
1763
|
+
if count == PURGE_BATCH_SIZE and (remaining := expired.count()):
|
|
1764
|
+
logger.info(
|
|
1765
|
+
"purge_expired_program_imports: batch full, %d expired import(s) remain for the "
|
|
1766
|
+
"next run",
|
|
1767
|
+
remaining,
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def _reap_empty_import_shell(job: Any) -> bool:
|
|
1772
|
+
"""Tombstone the empty program shell a failed import left behind (#2615).
|
|
1773
|
+
|
|
1774
|
+
Soft-deleted rather than hard-deleted: the program row's ``server_version``
|
|
1775
|
+
bump is what reaches offline sync consumers, and a hard delete would strand
|
|
1776
|
+
a client that had already seen the shell — the same reasoning that makes
|
|
1777
|
+
``soft_delete_program_subtree`` tombstone the program instead of removing it.
|
|
1778
|
+
There is no program Trash (#2587), so this is not recoverable through the UI;
|
|
1779
|
+
that is why every guard below is a *reason not to touch it*.
|
|
1780
|
+
|
|
1781
|
+
Returns True when a shell was retired.
|
|
1782
|
+
"""
|
|
1783
|
+
from trueppm_api.apps.projects.models import (
|
|
1784
|
+
ImportJobStatus,
|
|
1785
|
+
Program,
|
|
1786
|
+
ProgramImportJob,
|
|
1787
|
+
Project,
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
program = Program.objects.filter(pk=job.program_id, is_deleted=False).first()
|
|
1791
|
+
if program is None: # already gone, or already tombstoned by a replace
|
|
1792
|
+
return False
|
|
1793
|
+
if Project.objects.filter(program_id=program.pk, is_deleted=False).exists():
|
|
1794
|
+
return False
|
|
1795
|
+
# A later import that succeeded into the same shell makes it real, even
|
|
1796
|
+
# though *this* job failed.
|
|
1797
|
+
if (
|
|
1798
|
+
ProgramImportJob.objects.filter(program_id=program.pk)
|
|
1799
|
+
.exclude(pk=job.pk)
|
|
1800
|
+
.exclude(status=ImportJobStatus.FAILED)
|
|
1801
|
+
.exists()
|
|
1802
|
+
):
|
|
1803
|
+
return False
|
|
1804
|
+
# The ``server_version`` bump ``soft_delete`` writes is the delivery
|
|
1805
|
+
# mechanism: an offline client learns the shell is gone from its next sync
|
|
1806
|
+
# delta, which is the same path that carries every other program tombstone.
|
|
1807
|
+
#
|
|
1808
|
+
# Deliberately **no** ``program_deleted`` broadcast, unlike
|
|
1809
|
+
# ``soft_delete_program_subtree``. That one runs in a request a user is
|
|
1810
|
+
# watching; this runs in a nightly sweep, weeks later, over a shell that by
|
|
1811
|
+
# definition nobody has open. And ``broadcast_board_event`` is synchronous
|
|
1812
|
+
# (``async_to_sync`` over the channel layer) with no enclosing atomic block
|
|
1813
|
+
# here, so ``on_commit`` would fire it inline — up to ``PURGE_BATCH_SIZE``
|
|
1814
|
+
# blocking round-trips inside the same 55 s budget the batching above exists
|
|
1815
|
+
# to protect, for an event that no consumer can currently receive anyway
|
|
1816
|
+
# (program groups have no ``group_add``, #836).
|
|
1817
|
+
program.soft_delete()
|
|
1818
|
+
return True
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
# ---------------------------------------------------------------------------
|
|
1822
|
+
# Schedule outline batch operation undo ledgers (ADR-0810, #2756)
|
|
1823
|
+
# ---------------------------------------------------------------------------
|
|
1824
|
+
# PasteManyOperation and CascadeClassificationOperation, not CsvImportRequest —
|
|
1825
|
+
# that table already has its own retention story (task_batch_services.py's
|
|
1826
|
+
# import-fix section). Shaped like purge_expired_project_exports above, not the
|
|
1827
|
+
# consolidated ADR-0173 retention coordinator (apps/observability): that
|
|
1828
|
+
# coordinator's RETENTION_SPECS is a deliberately curated, count-asserted set of
|
|
1829
|
+
# six tables surfaced in an operator-facing editor, and folding a seventh in
|
|
1830
|
+
# would be scope beyond what #2756 asked for. A plain settings-driven purge,
|
|
1831
|
+
# same shape as the export/import job purges already in this file, is
|
|
1832
|
+
# consistent with the (lighter) precedent those set.
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _batch_operation_retention_days() -> int | None:
|
|
1836
|
+
from django.conf import settings
|
|
1837
|
+
|
|
1838
|
+
return getattr(settings, "TRUEPPM_BATCH_OPERATION_RETENTION_DAYS", 30)
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
@idempotent_task(
|
|
1842
|
+
lock_key_template="purge_expired_batch_operations",
|
|
1843
|
+
lock_ttl=120,
|
|
1844
|
+
on_contention="skip",
|
|
1845
|
+
soft_time_limit=55,
|
|
1846
|
+
time_limit=90,
|
|
1847
|
+
acks_late=True,
|
|
1848
|
+
reject_on_worker_lost=True,
|
|
1849
|
+
name="projects.purge_expired_batch_operations",
|
|
1850
|
+
)
|
|
1851
|
+
def purge_expired_batch_operations(self: object) -> None:
|
|
1852
|
+
"""Delete paste-many/cascade undo ledger rows past retention, undone or not."""
|
|
1853
|
+
_do_purge_expired_batch_operations()
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def _structural_operation_retention_days() -> int | None:
|
|
1857
|
+
"""Structural rows get their own, shorter window (ADR-0880 Sec.9).
|
|
1858
|
+
|
|
1859
|
+
A structural act fires on every indent and every drag, so an afternoon of outlining
|
|
1860
|
+
is hundreds of rows where a paste-many is a handful. Sharing the 30-day batch
|
|
1861
|
+
window would accumulate an order of magnitude more rows for a horizon nobody uses:
|
|
1862
|
+
the undo affordance is session-scoped (the session trail caps at 10 entries), so
|
|
1863
|
+
7 days comfortably outlives any session while keeping the table small.
|
|
1864
|
+
"""
|
|
1865
|
+
from django.conf import settings
|
|
1866
|
+
|
|
1867
|
+
return getattr(settings, "TRUEPPM_STRUCTURAL_OPERATION_RETENTION_DAYS", 7)
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _do_purge_expired_batch_operations() -> None:
|
|
1871
|
+
from trueppm_api.apps.projects.models import (
|
|
1872
|
+
CascadeClassificationOperation,
|
|
1873
|
+
PasteManyOperation,
|
|
1874
|
+
StructuralOperation,
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
retention = _batch_operation_retention_days()
|
|
1878
|
+
paste_deleted = cascade_deleted = structural_deleted = 0
|
|
1879
|
+
if retention is not None: # None disables retention — keep rows indefinitely
|
|
1880
|
+
cutoff = timezone.now() - timedelta(days=retention)
|
|
1881
|
+
paste_deleted, _ = PasteManyOperation.objects.filter(created_at__lt=cutoff).delete()
|
|
1882
|
+
cascade_deleted, _ = CascadeClassificationOperation.objects.filter(
|
|
1883
|
+
created_at__lt=cutoff
|
|
1884
|
+
).delete()
|
|
1885
|
+
|
|
1886
|
+
structural_retention = _structural_operation_retention_days()
|
|
1887
|
+
if structural_retention is not None:
|
|
1888
|
+
structural_cutoff = timezone.now() - timedelta(days=structural_retention)
|
|
1889
|
+
structural_deleted, _ = StructuralOperation.objects.filter(
|
|
1890
|
+
created_at__lt=structural_cutoff
|
|
1891
|
+
).delete()
|
|
1892
|
+
|
|
1893
|
+
total = paste_deleted + cascade_deleted + structural_deleted
|
|
1894
|
+
if total:
|
|
1895
|
+
logger.info(
|
|
1896
|
+
"purge_expired_batch_operations: deleted %d paste-many + %d cascade "
|
|
1897
|
+
"+ %d structural row(s)",
|
|
1898
|
+
paste_deleted,
|
|
1899
|
+
cascade_deleted,
|
|
1900
|
+
structural_deleted,
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
# ---------------------------------------------------------------------------
|
|
1905
|
+
# Template application (ADR-0789, #2729)
|
|
1906
|
+
# ---------------------------------------------------------------------------
|
|
1907
|
+
# Celery's autodiscover_tasks() only imports ``<app>/tasks.py``, so a task defined
|
|
1908
|
+
# in a sibling module never registers and its Beat entry silently resolves to
|
|
1909
|
+
# nothing — the drain would appear scheduled and never run. Re-exported here so
|
|
1910
|
+
# the two template tasks are discovered, while their implementation stays in
|
|
1911
|
+
# ``template_tasks.py`` next to the rest of the template code.
|
|
1912
|
+
from trueppm_api.apps.projects.template_tasks import ( # noqa: E402
|
|
1913
|
+
apply_template,
|
|
1914
|
+
drain_template_apply_queue,
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
__all__ = ["apply_template", "drain_template_apply_queue"]
|