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,1984 @@
|
|
|
1
|
+
"""Celery tasks for the scheduling app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
import operator
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
from datetime import date, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import redis as redis_lib
|
|
14
|
+
from celery.exceptions import SoftTimeLimitExceeded
|
|
15
|
+
from django.db import OperationalError
|
|
16
|
+
|
|
17
|
+
from trueppm_api.apps.projects.services import clear_uncommitted_cpm_output
|
|
18
|
+
from trueppm_api.core.idempotent import idempotent_task
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Exceptions that are transient and should trigger Celery's built-in retry.
|
|
23
|
+
_RETRIABLE = (ConnectionError, redis_lib.ConnectionError, OperationalError)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@idempotent_task(
|
|
27
|
+
lock_key_template="schedule_lock:{0}",
|
|
28
|
+
lock_ttl=300,
|
|
29
|
+
on_contention="queue",
|
|
30
|
+
queue_countdown=10,
|
|
31
|
+
max_queue_attempts=5,
|
|
32
|
+
autoretry_for=_RETRIABLE,
|
|
33
|
+
retry_backoff=30,
|
|
34
|
+
retry_backoff_max=300,
|
|
35
|
+
retry_jitter=True,
|
|
36
|
+
max_retries=3,
|
|
37
|
+
soft_time_limit=480,
|
|
38
|
+
time_limit=600,
|
|
39
|
+
acks_late=True,
|
|
40
|
+
reject_on_worker_lost=True,
|
|
41
|
+
)
|
|
42
|
+
def recalculate_schedule(
|
|
43
|
+
self: object,
|
|
44
|
+
project_id: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Run CPM on a project and persist the results.
|
|
47
|
+
|
|
48
|
+
Idempotency is enforced by the ``@idempotent_task`` decorator which
|
|
49
|
+
acquires a Redis SET NX lock keyed by project_id and auto-extends it
|
|
50
|
+
for long-running schedules. On lock contention the task is re-queued
|
|
51
|
+
with a 10-second countdown (up to 5 attempts).
|
|
52
|
+
|
|
53
|
+
Progress is tracked via TaskRunTracker, which persists status to the
|
|
54
|
+
TaskRun model and broadcasts task_run_* WebSocket events. The frontend
|
|
55
|
+
subscribes to these events instead of the old cpm_queued/complete/error
|
|
56
|
+
events (replaced by ADR-0020).
|
|
57
|
+
|
|
58
|
+
A full CPM recompute runs on every dispatch: the engine-level incremental
|
|
59
|
+
recompute designed in ADR-0027 is tracked separately under #235 (0.5). The
|
|
60
|
+
earlier API-layer write-back narrowing was removed in #1528 as unreachable
|
|
61
|
+
dead code — no dispatch site ever supplied the changed-task set.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
project_id: UUID string of the project to reschedule.
|
|
65
|
+
"""
|
|
66
|
+
from trueppm_api.apps.taskruns.tracker import TaskRunTracker
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with TaskRunTracker(
|
|
70
|
+
self,
|
|
71
|
+
project_id=project_id,
|
|
72
|
+
task_name="scheduling.recalculate",
|
|
73
|
+
) as tracker:
|
|
74
|
+
_run_schedule(project_id, tracker)
|
|
75
|
+
# Stamp the successful CPM completion so the web Schedule view can show
|
|
76
|
+
# the "recalculating" badge until the first post-import pass lands
|
|
77
|
+
# (#1053). A bulk update avoids a save() + history row + server_version
|
|
78
|
+
# bump for a non-domain timestamp.
|
|
79
|
+
from django.utils import timezone
|
|
80
|
+
|
|
81
|
+
from trueppm_api.apps.projects.models import Project
|
|
82
|
+
|
|
83
|
+
Project.objects.filter(pk=project_id).update(recalculated_at=timezone.now())
|
|
84
|
+
except SoftTimeLimitExceeded:
|
|
85
|
+
logger.error(
|
|
86
|
+
"recalculate_schedule: soft time limit exceeded for project %s",
|
|
87
|
+
project_id,
|
|
88
|
+
)
|
|
89
|
+
from django.db import transaction
|
|
90
|
+
|
|
91
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
92
|
+
|
|
93
|
+
# Record the failure first, then broadcast cpm_error only after that row
|
|
94
|
+
# commits (#896): clients should not be told a recompute failed unless the
|
|
95
|
+
# dead-letter record actually persisted. Default-arg binding pins the id.
|
|
96
|
+
with transaction.atomic():
|
|
97
|
+
_dead_letter_current(
|
|
98
|
+
self, project_id, SoftTimeLimitExceeded("CPM computation timed out")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _broadcast_error(pid: str = project_id) -> None:
|
|
102
|
+
broadcast_board_event(
|
|
103
|
+
project_id=pid, event_type="cpm_error", payload={"error": "timeout"}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
transaction.on_commit(_broadcast_error)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@idempotent_task(
|
|
110
|
+
lock_key_template="drain_schedule_queue",
|
|
111
|
+
lock_ttl=60,
|
|
112
|
+
on_contention="skip",
|
|
113
|
+
soft_time_limit=25,
|
|
114
|
+
time_limit=30,
|
|
115
|
+
acks_late=True,
|
|
116
|
+
reject_on_worker_lost=True,
|
|
117
|
+
name="scheduling.drain_schedule_queue",
|
|
118
|
+
)
|
|
119
|
+
def drain_schedule_queue(self: object) -> None:
|
|
120
|
+
"""Dispatch any pending ScheduleRequest outbox rows.
|
|
121
|
+
|
|
122
|
+
Runs every 30 seconds via Celery Beat. For each project with a pending
|
|
123
|
+
row, calls recalculate_schedule.delay() and marks the row as dispatched.
|
|
124
|
+
|
|
125
|
+
Also recovers orphaned dispatched rows older than 10 minutes — the
|
|
126
|
+
recalculate_schedule soft_time_limit is 480 s (8 min), so 10 min ensures
|
|
127
|
+
any prior task invocation has either completed or timed out before we
|
|
128
|
+
re-dispatch.
|
|
129
|
+
|
|
130
|
+
The @idempotent_task singleton lock ensures at most one drain runs at a
|
|
131
|
+
time; on_contention="skip" drops any concurrent trigger rather than
|
|
132
|
+
queuing, because the next Beat tick will cover it.
|
|
133
|
+
"""
|
|
134
|
+
_do_drain()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@idempotent_task(
|
|
138
|
+
lock_key_template="purge_old_schedule_requests",
|
|
139
|
+
lock_ttl=120,
|
|
140
|
+
on_contention="skip",
|
|
141
|
+
soft_time_limit=55,
|
|
142
|
+
time_limit=90,
|
|
143
|
+
acks_late=True,
|
|
144
|
+
reject_on_worker_lost=True,
|
|
145
|
+
name="scheduling.purge_old_schedule_requests",
|
|
146
|
+
)
|
|
147
|
+
def purge_old_schedule_requests(self: object) -> None:
|
|
148
|
+
"""Delete done/dead ScheduleRequest rows older than 7 days.
|
|
149
|
+
|
|
150
|
+
Runs nightly at 02:15 UTC via Celery Beat. Keeps the outbox table small
|
|
151
|
+
so index scans on (status, requested_at) stay fast.
|
|
152
|
+
"""
|
|
153
|
+
_do_purge()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _do_drain() -> None:
|
|
157
|
+
"""Business logic for drain_schedule_queue — extracted for testability."""
|
|
158
|
+
from django.utils import timezone
|
|
159
|
+
|
|
160
|
+
from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
|
|
161
|
+
|
|
162
|
+
now = timezone.now()
|
|
163
|
+
orphan_cutoff = now - timedelta(minutes=10)
|
|
164
|
+
|
|
165
|
+
# Recover orphaned rows: dispatched but not completed within 10 minutes.
|
|
166
|
+
#
|
|
167
|
+
# A blanket flip of every stale dispatched row back to pending is unsafe: if
|
|
168
|
+
# the project already has a pending row (a fresh edit arrived after the row was
|
|
169
|
+
# dispatched but before its task finished/died), resurrecting the dead row
|
|
170
|
+
# creates a *second* pending row and violates the
|
|
171
|
+
# schedule_request_one_pending_per_project partial-unique constraint — which
|
|
172
|
+
# aborts the whole UPDATE, so no orphan is recovered and the drain wedges on
|
|
173
|
+
# every tick (#1693). Because CPM is a full recompute, that existing pending
|
|
174
|
+
# row already supersedes the dead dispatched one, so retire the dead row as
|
|
175
|
+
# done rather than resurrecting it. Only stale dispatched rows with no
|
|
176
|
+
# coexisting pending row are flipped back to pending for re-dispatch.
|
|
177
|
+
projects_with_pending = set(
|
|
178
|
+
ScheduleRequest.objects.filter(status=ScheduleRequestStatus.PENDING).values_list(
|
|
179
|
+
"project_id", flat=True
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
stale_dispatched = ScheduleRequest.objects.filter(
|
|
183
|
+
status=ScheduleRequestStatus.DISPATCHED,
|
|
184
|
+
dispatched_at__lt=orphan_cutoff,
|
|
185
|
+
)
|
|
186
|
+
superseded = stale_dispatched.filter(project_id__in=projects_with_pending).update(
|
|
187
|
+
status=ScheduleRequestStatus.DONE
|
|
188
|
+
)
|
|
189
|
+
recovered = stale_dispatched.exclude(project_id__in=projects_with_pending).update(
|
|
190
|
+
status=ScheduleRequestStatus.PENDING, celery_task_id=""
|
|
191
|
+
)
|
|
192
|
+
if recovered:
|
|
193
|
+
logger.warning("drain_schedule_queue: recovered %d orphaned dispatched row(s)", recovered)
|
|
194
|
+
if superseded:
|
|
195
|
+
logger.warning(
|
|
196
|
+
"drain_schedule_queue: retired %d stale dispatched row(s) superseded by a pending row",
|
|
197
|
+
superseded,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Dispatch all currently pending rows (one Celery task per project).
|
|
201
|
+
pending = list(ScheduleRequest.objects.filter(status=ScheduleRequestStatus.PENDING))
|
|
202
|
+
dispatched = 0
|
|
203
|
+
for req in pending:
|
|
204
|
+
try:
|
|
205
|
+
result = recalculate_schedule.delay(str(req.project_id))
|
|
206
|
+
except Exception:
|
|
207
|
+
logger.warning(
|
|
208
|
+
"drain_schedule_queue: broker unavailable — project %s stays pending",
|
|
209
|
+
req.project_id,
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
# Conditional update: only flip to dispatched if it's still pending
|
|
213
|
+
# (guards against a concurrent on_commit _enqueue_recalculate call).
|
|
214
|
+
ScheduleRequest.objects.filter(id=req.id, status=ScheduleRequestStatus.PENDING).update(
|
|
215
|
+
status=ScheduleRequestStatus.DISPATCHED,
|
|
216
|
+
celery_task_id=result.id,
|
|
217
|
+
dispatched_at=now,
|
|
218
|
+
)
|
|
219
|
+
dispatched += 1
|
|
220
|
+
|
|
221
|
+
if dispatched or recovered:
|
|
222
|
+
logger.info("drain_schedule_queue: dispatched=%d recovered=%d", dispatched, recovered)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _do_purge() -> None:
|
|
226
|
+
"""Business logic for purge_old_schedule_requests — extracted for testability."""
|
|
227
|
+
from django.utils import timezone
|
|
228
|
+
|
|
229
|
+
from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
|
|
230
|
+
|
|
231
|
+
cutoff = timezone.now() - timedelta(days=7)
|
|
232
|
+
deleted, _ = ScheduleRequest.objects.filter(
|
|
233
|
+
status__in=[ScheduleRequestStatus.DONE, ScheduleRequestStatus.DEAD],
|
|
234
|
+
requested_at__lt=cutoff,
|
|
235
|
+
).delete()
|
|
236
|
+
logger.info("purge_old_schedule_requests: deleted %d row(s)", deleted)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@idempotent_task(
|
|
240
|
+
lock_key_template="purge_resolved_slip_conflicts",
|
|
241
|
+
lock_ttl=120,
|
|
242
|
+
on_contention="skip",
|
|
243
|
+
soft_time_limit=55,
|
|
244
|
+
time_limit=90,
|
|
245
|
+
acks_late=True,
|
|
246
|
+
reject_on_worker_lost=True,
|
|
247
|
+
name="scheduling.purge_resolved_slip_conflicts",
|
|
248
|
+
)
|
|
249
|
+
def purge_resolved_slip_conflicts(self: object) -> None:
|
|
250
|
+
"""Delete cross-project slip conflicts 90 days past resolution (ADR-0120 D4).
|
|
251
|
+
|
|
252
|
+
Runs nightly at 02:25 UTC. Removes ``CrossProjectSlipConflict`` rows whose
|
|
253
|
+
acknowledgment or auto-resolution is older than 90 days; unresolved and
|
|
254
|
+
unacknowledged rows are kept indefinitely (they are still live conflicts).
|
|
255
|
+
Idempotent: a re-run deletes nothing new.
|
|
256
|
+
"""
|
|
257
|
+
_do_purge_resolved_slip_conflicts()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _do_purge_resolved_slip_conflicts() -> None:
|
|
261
|
+
"""Business logic for purge_resolved_slip_conflicts — extracted for testability."""
|
|
262
|
+
from django.db.models import Q
|
|
263
|
+
from django.utils import timezone
|
|
264
|
+
|
|
265
|
+
from trueppm_api.apps.projects.models import CrossProjectSlipConflict
|
|
266
|
+
|
|
267
|
+
cutoff = timezone.now() - timedelta(days=90)
|
|
268
|
+
deleted, _ = CrossProjectSlipConflict.objects.filter(
|
|
269
|
+
Q(acknowledged_at__lt=cutoff) | Q(resolved_at__lt=cutoff)
|
|
270
|
+
).delete()
|
|
271
|
+
if deleted:
|
|
272
|
+
logger.info("purge_resolved_slip_conflicts: deleted %d row(s)", deleted)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@idempotent_task(
|
|
276
|
+
lock_key_template="purge_old_monte_carlo_runs",
|
|
277
|
+
lock_ttl=120,
|
|
278
|
+
on_contention="skip",
|
|
279
|
+
soft_time_limit=55,
|
|
280
|
+
time_limit=90,
|
|
281
|
+
acks_late=True,
|
|
282
|
+
reject_on_worker_lost=True,
|
|
283
|
+
name="scheduling.purge_old_monte_carlo_runs",
|
|
284
|
+
)
|
|
285
|
+
def purge_old_monte_carlo_runs(self: object) -> None:
|
|
286
|
+
"""Trim project Monte Carlo run history to the newest cap per project.
|
|
287
|
+
|
|
288
|
+
Runs nightly at 02:20 UTC via Celery Beat. Keeps the most recent N
|
|
289
|
+
``MonteCarloRun`` rows per project, where N is the per-workspace-effective
|
|
290
|
+
retention cap (ADR-0144, #1232) — no longer the global constant — so
|
|
291
|
+
forecast-drift history stays bounded. No-ops for a project whose effective cap
|
|
292
|
+
is ``None`` (Enterprise unlimited). Idempotent: rank-based delete is safe to run
|
|
293
|
+
repeatedly.
|
|
294
|
+
"""
|
|
295
|
+
_do_monte_carlo_run_purge()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _do_monte_carlo_run_purge() -> None:
|
|
299
|
+
"""Business logic for purge_old_monte_carlo_runs — extracted for testability.
|
|
300
|
+
|
|
301
|
+
Each project's retention cap is resolved per-workspace-effective (ADR-0144,
|
|
302
|
+
#1232) via ``resolve_effective_mc_history`` rather than the global
|
|
303
|
+
``MC_HISTORY_CAP`` constant — a workspace/program/project may raise or lower it
|
|
304
|
+
(clamped to MC_HISTORY_HARD_CAP in the resolver). For each project exceeding its
|
|
305
|
+
own cap, delete every run older than its cap-th most recent. The candidate set
|
|
306
|
+
is the projects whose total run count exceeds the global hard cap floor, so the
|
|
307
|
+
common case (projects under the cap) does no delete work; the per-project cap is
|
|
308
|
+
then applied exactly. A project whose effective cap is None (Enterprise
|
|
309
|
+
unlimited) is skipped.
|
|
310
|
+
"""
|
|
311
|
+
from django.db.models import Count
|
|
312
|
+
|
|
313
|
+
from trueppm_api.apps.scheduling.forecast_history_settings import resolve_effective_mc_history
|
|
314
|
+
from trueppm_api.apps.scheduling.models import MonteCarloRun
|
|
315
|
+
|
|
316
|
+
# Candidate projects: any with more than one run. We can't pre-filter by a single
|
|
317
|
+
# global cap because each project's effective cap may differ; resolving per
|
|
318
|
+
# project below is bounded by the (small) set of projects that actually have
|
|
319
|
+
# history.
|
|
320
|
+
project_ids = (
|
|
321
|
+
MonteCarloRun.objects.values("project_id")
|
|
322
|
+
.annotate(n=Count("id"))
|
|
323
|
+
.filter(n__gt=1)
|
|
324
|
+
.values_list("project_id", flat=True)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
from trueppm_api.apps.projects.models import Project
|
|
328
|
+
from trueppm_api.apps.workspace.models import Workspace
|
|
329
|
+
|
|
330
|
+
# Load the workspace singleton once and thread it through every per-project
|
|
331
|
+
# resolve. Workspace.load() is a get_or_create round-trip (not memoized), so
|
|
332
|
+
# passing it in avoids a redundant workspace query per candidate project.
|
|
333
|
+
workspace = Workspace.load()
|
|
334
|
+
|
|
335
|
+
total_deleted = 0
|
|
336
|
+
for project_id in list(project_ids):
|
|
337
|
+
project = Project.objects.filter(pk=project_id).select_related("program").first()
|
|
338
|
+
if project is None:
|
|
339
|
+
continue
|
|
340
|
+
cap: int | None = resolve_effective_mc_history(
|
|
341
|
+
project, "mc_history_retention_cap", workspace=workspace
|
|
342
|
+
)
|
|
343
|
+
if cap is None:
|
|
344
|
+
continue # Enterprise unlimited: nothing to purge for this project.
|
|
345
|
+
# Find the taken_at of the cap-th most recent run; everything strictly
|
|
346
|
+
# older is surplus. Ordered by -taken_at so [cap - 1] is the boundary row.
|
|
347
|
+
boundary = list(
|
|
348
|
+
MonteCarloRun.objects.filter(project_id=project_id)
|
|
349
|
+
.order_by("-taken_at")
|
|
350
|
+
.values_list("taken_at", flat=True)[cap - 1 : cap]
|
|
351
|
+
)
|
|
352
|
+
if not boundary:
|
|
353
|
+
continue
|
|
354
|
+
deleted, _ = MonteCarloRun.objects.filter(
|
|
355
|
+
project_id=project_id, taken_at__lt=boundary[0]
|
|
356
|
+
).delete()
|
|
357
|
+
total_deleted += deleted
|
|
358
|
+
|
|
359
|
+
if total_deleted:
|
|
360
|
+
logger.info("purge_old_monte_carlo_runs: deleted %d row(s)", total_deleted)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@idempotent_task(
|
|
364
|
+
lock_key_template="capture_daily_forecast_floor",
|
|
365
|
+
lock_ttl=120,
|
|
366
|
+
on_contention="skip",
|
|
367
|
+
soft_time_limit=110,
|
|
368
|
+
time_limit=150,
|
|
369
|
+
acks_late=True,
|
|
370
|
+
reject_on_worker_lost=True,
|
|
371
|
+
name="scheduling.capture_daily_forecast_floor",
|
|
372
|
+
)
|
|
373
|
+
def capture_daily_forecast_floor(self: object) -> None:
|
|
374
|
+
"""Guarantee ≥1 forecast snapshot per active project per day (ADR-0154, #388).
|
|
375
|
+
|
|
376
|
+
Runs nightly at 00:30 UTC via Celery Beat. Captures a ``scheduled`` snapshot
|
|
377
|
+
for every non-deleted, non-archived project that has no snapshot in the last
|
|
378
|
+
24 h — covering quiet days with no recompute, and acting as the durability
|
|
379
|
+
backstop that backfills any ``recompute`` capture missed by a broker blip or a
|
|
380
|
+
worker death between commit and on_commit. Idempotent: a project already
|
|
381
|
+
covered in the window is skipped.
|
|
382
|
+
"""
|
|
383
|
+
_do_daily_forecast_floor()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _do_daily_forecast_floor() -> None:
|
|
387
|
+
"""Business logic for capture_daily_forecast_floor — extracted for testability."""
|
|
388
|
+
from django.utils import timezone
|
|
389
|
+
|
|
390
|
+
from trueppm_api.apps.projects.lifecycle import visible_projects
|
|
391
|
+
from trueppm_api.apps.projects.models import Project
|
|
392
|
+
from trueppm_api.apps.scheduling.models import ForecastSnapshotTrigger, ProjectForecastSnapshot
|
|
393
|
+
from trueppm_api.apps.scheduling.services import safe_capture_forecast_snapshot
|
|
394
|
+
|
|
395
|
+
cutoff = timezone.now() - timedelta(hours=24)
|
|
396
|
+
# Projects already covered in the window — skip them with a single query rather
|
|
397
|
+
# than letting the capture-path dedup absorb each one (dedup only skips when the
|
|
398
|
+
# forecast is *unchanged*; the floor's intent is one row/day regardless).
|
|
399
|
+
covered = set(
|
|
400
|
+
ProjectForecastSnapshot.objects.filter(captured_at__gte=cutoff)
|
|
401
|
+
.values_list("project_id", flat=True)
|
|
402
|
+
.distinct()
|
|
403
|
+
)
|
|
404
|
+
# Drafts are excluded (#2962/#3128). Two reasons, and the second is the one on
|
|
405
|
+
# the exclusion list: a forecast history for a plan nobody has committed to is
|
|
406
|
+
# noise by construction (the dates move because the plan is still being
|
|
407
|
+
# written), and every captured snapshot whose end date moved fires
|
|
408
|
+
# ``notify_project_end_date_shift`` at each ADMIN — a daily "your end date
|
|
409
|
+
# slipped" for a schedule the author is in the middle of typing. The commit
|
|
410
|
+
# moment captures baseline v1, which is where a draft's history should start.
|
|
411
|
+
project_ids = visible_projects(
|
|
412
|
+
Project.objects.filter(is_deleted=False, is_archived=False)
|
|
413
|
+
).values_list("id", flat=True)
|
|
414
|
+
captured = 0
|
|
415
|
+
for project_id in project_ids:
|
|
416
|
+
if project_id in covered:
|
|
417
|
+
continue
|
|
418
|
+
safe_capture_forecast_snapshot(project_id, ForecastSnapshotTrigger.SCHEDULED)
|
|
419
|
+
captured += 1
|
|
420
|
+
if captured:
|
|
421
|
+
logger.info("capture_daily_forecast_floor: captured %d snapshot(s)", captured)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
@idempotent_task(
|
|
425
|
+
lock_key_template="prune_forecast_snapshots",
|
|
426
|
+
lock_ttl=120,
|
|
427
|
+
on_contention="skip",
|
|
428
|
+
soft_time_limit=110,
|
|
429
|
+
time_limit=150,
|
|
430
|
+
acks_late=True,
|
|
431
|
+
reject_on_worker_lost=True,
|
|
432
|
+
name="scheduling.prune_forecast_snapshots",
|
|
433
|
+
)
|
|
434
|
+
def prune_forecast_snapshots(self: object) -> None:
|
|
435
|
+
"""Apply the tiered retention curve to project forecast snapshots (ADR-0154, #388).
|
|
436
|
+
|
|
437
|
+
Runs nightly at 04:15 UTC via Celery Beat. Per ``settings.FORECAST_SNAPSHOT_RETENTION``:
|
|
438
|
+
keep all rows younger than ``daily_days`` (default 90); keep one-per-ISO-week up
|
|
439
|
+
to ``weekly_days`` (default 365); keep one-per-calendar-month beyond that (kept
|
|
440
|
+
forever). The same logic is exposed as the ``prune_forecast_snapshots`` management
|
|
441
|
+
command. Idempotent: re-running deletes nothing new.
|
|
442
|
+
"""
|
|
443
|
+
_do_prune_forecast_snapshots()
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _snapshot_ids_to_keep(rows: Any, daily_cutoff: Any, weekly_cutoff: Any) -> set[uuid.UUID]:
|
|
447
|
+
"""Pick the representative snapshot id to retain in each retention bucket.
|
|
448
|
+
|
|
449
|
+
``rows`` are ``(id, captured_at)`` pairs ordered newest-first, so the first
|
|
450
|
+
row seen in each ISO-week / calendar-month bucket is the freshest and wins.
|
|
451
|
+
Every row inside the recent (daily) tier is kept.
|
|
452
|
+
"""
|
|
453
|
+
keep: set[uuid.UUID] = set()
|
|
454
|
+
seen_weeks: set[tuple[int, int]] = set()
|
|
455
|
+
seen_months: set[tuple[int, int]] = set()
|
|
456
|
+
for row_id, captured_at in rows:
|
|
457
|
+
if captured_at >= daily_cutoff:
|
|
458
|
+
keep.add(row_id) # Recent tier: keep every row.
|
|
459
|
+
elif captured_at >= weekly_cutoff:
|
|
460
|
+
iso = captured_at.isocalendar()
|
|
461
|
+
key = (iso[0], iso[1])
|
|
462
|
+
if key not in seen_weeks:
|
|
463
|
+
seen_weeks.add(key)
|
|
464
|
+
keep.add(row_id) # Newest row in this ISO week.
|
|
465
|
+
else:
|
|
466
|
+
key = (captured_at.year, captured_at.month)
|
|
467
|
+
if key not in seen_months:
|
|
468
|
+
seen_months.add(key)
|
|
469
|
+
keep.add(row_id) # Newest row in this calendar month.
|
|
470
|
+
return keep
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _do_prune_forecast_snapshots() -> int:
|
|
474
|
+
"""Business logic for prune_forecast_snapshots — extracted for testability.
|
|
475
|
+
|
|
476
|
+
Returns the number of rows deleted. Scans each project's snapshots newest-first
|
|
477
|
+
and keeps the first (newest) row in each retention bucket, so the freshest
|
|
478
|
+
representative per day/week/month survives.
|
|
479
|
+
"""
|
|
480
|
+
from django.conf import settings
|
|
481
|
+
from django.utils import timezone
|
|
482
|
+
|
|
483
|
+
from trueppm_api.apps.scheduling.models import ProjectForecastSnapshot
|
|
484
|
+
|
|
485
|
+
policy = getattr(settings, "FORECAST_SNAPSHOT_RETENTION", {})
|
|
486
|
+
daily_days = int(policy.get("daily_days", 90))
|
|
487
|
+
weekly_days = int(policy.get("weekly_days", 365))
|
|
488
|
+
|
|
489
|
+
now = timezone.now()
|
|
490
|
+
daily_cutoff = now - timedelta(days=daily_days)
|
|
491
|
+
weekly_cutoff = now - timedelta(days=weekly_days)
|
|
492
|
+
|
|
493
|
+
project_ids = ProjectForecastSnapshot.objects.values_list("project_id", flat=True).distinct()
|
|
494
|
+
|
|
495
|
+
total_deleted = 0
|
|
496
|
+
for project_id in list(project_ids):
|
|
497
|
+
rows = (
|
|
498
|
+
ProjectForecastSnapshot.objects.filter(project_id=project_id)
|
|
499
|
+
.order_by("-captured_at")
|
|
500
|
+
.values_list("id", "captured_at")
|
|
501
|
+
)
|
|
502
|
+
keep = _snapshot_ids_to_keep(rows, daily_cutoff, weekly_cutoff)
|
|
503
|
+
|
|
504
|
+
deleted, _ = (
|
|
505
|
+
ProjectForecastSnapshot.objects.filter(project_id=project_id)
|
|
506
|
+
.exclude(id__in=keep)
|
|
507
|
+
.delete()
|
|
508
|
+
)
|
|
509
|
+
total_deleted += deleted
|
|
510
|
+
|
|
511
|
+
if total_deleted:
|
|
512
|
+
logger.info("prune_forecast_snapshots: deleted %d row(s)", total_deleted)
|
|
513
|
+
return total_deleted
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _dead_letter_current(task: object, project_id: str, exc: BaseException) -> None:
|
|
517
|
+
"""Write a FailedTask record for the current task invocation."""
|
|
518
|
+
from trueppm_api.apps.scheduling.deadletter import record_failed_task
|
|
519
|
+
|
|
520
|
+
request = task.request # type: ignore[attr-defined]
|
|
521
|
+
record_failed_task(
|
|
522
|
+
task_name=getattr(task, "name", "unknown"),
|
|
523
|
+
task_id=request.id or "unknown",
|
|
524
|
+
args=[project_id],
|
|
525
|
+
kwargs={},
|
|
526
|
+
exception=exc,
|
|
527
|
+
project_id=project_id,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
_WRITEBACK_BATCH_SIZE = 500
|
|
532
|
+
"""Rows per ``bulk_update`` statement when persisting CPM results (#1529).
|
|
533
|
+
|
|
534
|
+
On PostgreSQL Django's ``bulk_batch_size`` defaults to ``len(objs)``, so an
|
|
535
|
+
unbatched write collapses every task into a single UPDATE built from per-field
|
|
536
|
+
``CASE WHEN pk=… THEN …`` chains (~17 params/row → ~85K interpolated values at 5K
|
|
537
|
+
tasks). psycopg3's client-side binding keeps this under the 65,535 protocol
|
|
538
|
+
ceiling, but the multi-MB SQL string is slow to build, parse, and plan and lands
|
|
539
|
+
on the <500 ms schedule-trigger budget. Batching bounds each statement's size
|
|
540
|
+
without changing the result — the whole writeback still runs in one transaction."""
|
|
541
|
+
|
|
542
|
+
CPM_DELTA_BROADCAST_CAP = 500
|
|
543
|
+
"""Max moved-task count to ship as per-task ``task_dates_updated`` deltas (ADR-0091).
|
|
544
|
+
|
|
545
|
+
Above this the WS frame grows large enough that a client-side full re-fetch is cheaper
|
|
546
|
+
than splicing, so we emit a ``truncated`` signal instead and let the client invalidate.
|
|
547
|
+
A delta row serializes to ≈264 bytes, so a 500-row payload is ≈130 KB.
|
|
548
|
+
|
|
549
|
+
The cap counts the *moved* set, not the write-back set (#2573). A recalc always
|
|
550
|
+
re-assigns every engine-returned row, so counting the write-back set put every
|
|
551
|
+
project above ~500 tasks permanently into the truncated branch and made ADR-0091's
|
|
552
|
+
splice path unreachable at the documented envelope.
|
|
553
|
+
|
|
554
|
+
Sized against the moved subset the truncated branch is reachable only when the recalc
|
|
555
|
+
genuinely moved that many rows. Note that the backward pass seeds every task's late
|
|
556
|
+
dates from the project finish, so **any edit that shifts the project finish moves the
|
|
557
|
+
whole network's late dates and floats** — a critical-path edit therefore still
|
|
558
|
+
truncates on a large project even though few bars actually move. Narrowing that case
|
|
559
|
+
(splitting early dates from late/float churn, or re-sizing the cap) is tracked in
|
|
560
|
+
#2598; the other wide cases are a first-ever pass, a calendar change, and a
|
|
561
|
+
project-start shift.
|
|
562
|
+
"""
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# Per-task schedule-shift activity events (ADR-0207, #1604). The CPM writeback
|
|
566
|
+
# persists only the history-excluded early_*/late_* fields via bulk_update, so a
|
|
567
|
+
# recompute leaves no per-task audit row; these helpers emit one instead.
|
|
568
|
+
def _active_baseline_finishes(project_ids: list[Any]) -> dict[str, tuple[str, date]]:
|
|
569
|
+
"""Return ``{task_id: (baseline_id, baseline_finish)}`` for each project's active baseline.
|
|
570
|
+
|
|
571
|
+
One entry per snapshotted task with a non-null finish, drawn from the single
|
|
572
|
+
active baseline per project (enforced by a DB unique constraint). Two indexed
|
|
573
|
+
queries; empty and cheap when no project in ``project_ids`` has an active
|
|
574
|
+
baseline, so the drift check adds no cost to the common no-baseline recalc.
|
|
575
|
+
"""
|
|
576
|
+
from trueppm_api.apps.projects.models import Baseline, BaselineTask
|
|
577
|
+
|
|
578
|
+
baseline_ids = list(
|
|
579
|
+
Baseline.objects.filter(
|
|
580
|
+
project_id__in=project_ids, is_active=True, is_deleted=False
|
|
581
|
+
).values_list("id", flat=True)
|
|
582
|
+
)
|
|
583
|
+
if not baseline_ids:
|
|
584
|
+
return {}
|
|
585
|
+
finishes: dict[str, tuple[str, date]] = {}
|
|
586
|
+
for row in BaselineTask.objects.filter(
|
|
587
|
+
baseline_id__in=baseline_ids, finish__isnull=False
|
|
588
|
+
).values("task_id", "finish", "baseline_id"):
|
|
589
|
+
finish = row["finish"]
|
|
590
|
+
if finish is None: # narrows the nullable DateField for mypy; excluded by the filter
|
|
591
|
+
continue
|
|
592
|
+
finishes[str(row["task_id"])] = (str(row["baseline_id"]), finish)
|
|
593
|
+
return finishes
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _iso_or_none(value: Any) -> str | None:
|
|
597
|
+
return value.isoformat() if value is not None else None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _cpm_dates_changed(old: tuple[Any, Any, Any, Any], t: Any) -> bool:
|
|
601
|
+
"""True when any of a task's four CPM dates differ from its pre-writeback snapshot."""
|
|
602
|
+
old_es, old_ef, old_ls, old_lf = old
|
|
603
|
+
return bool(
|
|
604
|
+
old_es != t.early_start
|
|
605
|
+
or old_ef != t.early_finish
|
|
606
|
+
or old_ls != t.late_start
|
|
607
|
+
or old_lf != t.late_finish
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _recalc_project_aggregates(
|
|
612
|
+
tasks_to_update: list[Any],
|
|
613
|
+
old_dates: dict[str, tuple[Any, Any, Any, Any]],
|
|
614
|
+
) -> tuple[dict[Any, int], dict[Any, date], dict[Any, date]]:
|
|
615
|
+
"""Per-project recalc summary denormalized onto every ``cpm_recalculated`` row (#1948).
|
|
616
|
+
|
|
617
|
+
Returns ``(moved_by_project, new_finish_by_project, prior_finish_by_project)``.
|
|
618
|
+
WHY grouped strictly per ``project_id`` and not globally: this feeds a helper
|
|
619
|
+
shared by the program-scoped writeback, where ``tasks_to_update`` spans several
|
|
620
|
+
member projects in one call. A single program-wide count stamped onto every row
|
|
621
|
+
would leak one project's schedule scope onto another project's activity row and
|
|
622
|
+
violate the OSS project-isolation boundary (enterprise-check constraint #1) — so
|
|
623
|
+
each row carries only *its own* project's aggregate.
|
|
624
|
+
"""
|
|
625
|
+
moved_by_project: dict[Any, int] = {}
|
|
626
|
+
new_finish_by_project: dict[Any, date] = {}
|
|
627
|
+
prior_finish_by_project: dict[Any, date] = {}
|
|
628
|
+
for t in tasks_to_update:
|
|
629
|
+
pid = t.project_id
|
|
630
|
+
# New finish: latest early_finish across every task in this project,
|
|
631
|
+
# moved or not (an unmoved late task still defines where finish sits).
|
|
632
|
+
cur_new = new_finish_by_project.get(pid)
|
|
633
|
+
if t.early_finish is not None and (cur_new is None or t.early_finish > cur_new):
|
|
634
|
+
new_finish_by_project[pid] = t.early_finish
|
|
635
|
+
old = old_dates.get(str(t.id))
|
|
636
|
+
if old is None:
|
|
637
|
+
continue
|
|
638
|
+
old_ef = old[1]
|
|
639
|
+
cur_prior = prior_finish_by_project.get(pid)
|
|
640
|
+
if old_ef is not None and (cur_prior is None or old_ef > cur_prior):
|
|
641
|
+
prior_finish_by_project[pid] = old_ef
|
|
642
|
+
if _cpm_dates_changed(old, t):
|
|
643
|
+
moved_by_project[pid] = moved_by_project.get(pid, 0) + 1
|
|
644
|
+
return moved_by_project, new_finish_by_project, prior_finish_by_project
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _cpm_recalculated_event(
|
|
648
|
+
t: Any,
|
|
649
|
+
old: tuple[Any, Any, Any, Any],
|
|
650
|
+
moved_by_project: dict[Any, int],
|
|
651
|
+
new_finish_by_project: dict[Any, date],
|
|
652
|
+
prior_finish_by_project: dict[Any, date],
|
|
653
|
+
) -> Any:
|
|
654
|
+
"""Build a ``cpm_recalculated`` activity row for a task whose CPM dates moved."""
|
|
655
|
+
from trueppm_api.apps.projects.models import TaskActivityEvent
|
|
656
|
+
|
|
657
|
+
old_es, old_ef, old_ls, old_lf = old
|
|
658
|
+
new_f = new_finish_by_project.get(t.project_id)
|
|
659
|
+
prior_f = prior_finish_by_project.get(t.project_id)
|
|
660
|
+
# Signed day delta of the project finish (+ = slip later, - = pulled in).
|
|
661
|
+
# None when either side is missing — most importantly the first-ever recalc,
|
|
662
|
+
# where no prior early_finish exists for any task.
|
|
663
|
+
delta = (new_f - prior_f).days if new_f is not None and prior_f is not None else None
|
|
664
|
+
return TaskActivityEvent(
|
|
665
|
+
task_id=t.id,
|
|
666
|
+
actor=None,
|
|
667
|
+
event_type="cpm_recalculated",
|
|
668
|
+
detail={
|
|
669
|
+
"early_start": {"from": _iso_or_none(old_es), "to": _iso_or_none(t.early_start)},
|
|
670
|
+
"early_finish": {"from": _iso_or_none(old_ef), "to": _iso_or_none(t.early_finish)},
|
|
671
|
+
"late_start": {"from": _iso_or_none(old_ls), "to": _iso_or_none(t.late_start)},
|
|
672
|
+
"late_finish": {"from": _iso_or_none(old_lf), "to": _iso_or_none(t.late_finish)},
|
|
673
|
+
"total_float": t.total_float,
|
|
674
|
+
"is_critical": t.is_critical,
|
|
675
|
+
# Per-project recalc summary (#1948) — see _recalc_project_aggregates.
|
|
676
|
+
"recalc_moved_count": moved_by_project.get(t.project_id, 0),
|
|
677
|
+
"recalc_finish": _iso_or_none(new_f),
|
|
678
|
+
"recalc_finish_delta_days": delta,
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _baseline_drift_event(t: Any, old_ef: Any, baseline: tuple[str, date]) -> Any | None:
|
|
684
|
+
"""Build a ``baseline_drift_detected`` row, but only on the transition *into* drift.
|
|
685
|
+
|
|
686
|
+
Emitted only when a task was within its baseline finish and is now past it, so a
|
|
687
|
+
persistently-drifted task does not re-fire every recalc. Returns ``None`` otherwise.
|
|
688
|
+
"""
|
|
689
|
+
baseline_id, baseline_finish = baseline
|
|
690
|
+
was_drifted = old_ef is not None and old_ef > baseline_finish
|
|
691
|
+
is_drifted = t.early_finish is not None and t.early_finish > baseline_finish
|
|
692
|
+
if not (is_drifted and not was_drifted):
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
from trueppm_api.apps.projects.models import TaskActivityEvent
|
|
696
|
+
|
|
697
|
+
return TaskActivityEvent(
|
|
698
|
+
task_id=t.id,
|
|
699
|
+
actor=None,
|
|
700
|
+
event_type="baseline_drift_detected",
|
|
701
|
+
detail={
|
|
702
|
+
"baseline_id": baseline_id,
|
|
703
|
+
"baseline_finish": baseline_finish.isoformat(),
|
|
704
|
+
"early_finish": t.early_finish.isoformat(),
|
|
705
|
+
"drift_days": (t.early_finish - baseline_finish).days,
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _build_schedule_shift_events(
|
|
711
|
+
tasks_to_update: list[Any],
|
|
712
|
+
old_dates: dict[str, tuple[Any, Any, Any, Any]],
|
|
713
|
+
baseline_finishes: dict[str, tuple[str, date]],
|
|
714
|
+
*,
|
|
715
|
+
suppress_movement_events: bool = False,
|
|
716
|
+
) -> list[Any]:
|
|
717
|
+
"""Build ``TaskActivityEvent`` rows for CPM recomputes and baseline-drift crossings.
|
|
718
|
+
|
|
719
|
+
``old_dates`` maps a task id to its ``(early_start, early_finish, late_start,
|
|
720
|
+
late_finish)`` captured *before* the writeback loop overwrote the in-memory Task
|
|
721
|
+
fields, so a move is detected by comparing against the now-mutated values. A
|
|
722
|
+
``cpm_recalculated`` row is emitted for every task whose four CPM dates changed;
|
|
723
|
+
a ``baseline_drift_detected`` row is emitted only on the transition *into* drift
|
|
724
|
+
(was within baseline, now past it) so a persistently-drifted task does not
|
|
725
|
+
re-fire every recalc. Actor is null — CPM runs in Celery with no request user.
|
|
726
|
+
|
|
727
|
+
Each ``cpm_recalculated`` row also carries a per-project recalc summary
|
|
728
|
+
(#1948): ``recalc_moved_count`` (how many of the project's tasks moved),
|
|
729
|
+
``recalc_finish`` (the project's latest early_finish after the pass), and
|
|
730
|
+
``recalc_finish_delta_days`` (signed slip/pull-in of that finish). These are
|
|
731
|
+
grouped strictly per ``project_id`` because the helper is shared by the
|
|
732
|
+
program-scoped writeback, where a program-wide count would leak cross-project
|
|
733
|
+
scope (see :func:`_recalc_project_aggregates`).
|
|
734
|
+
|
|
735
|
+
``suppress_movement_events`` (ADR-0752 §7) skips the ``cpm_recalculated`` rows
|
|
736
|
+
specifically — the one-shot recalculation that arms a project's status_date
|
|
737
|
+
floor for the first time moves dates as an engine correction, not a user
|
|
738
|
+
action, and must not be recorded as ordinary per-task movement. This is a
|
|
739
|
+
feed-persistence half of the suppression; the caller must also withhold the
|
|
740
|
+
matching ADR-0091 ``task_dates_updated`` broadcast, or the entries reappear on
|
|
741
|
+
that surface instead (ADR-0752 §10). ``baseline_drift_detected`` is left
|
|
742
|
+
unsuppressed — it names an outcome (drift vs. plan), not a change attributed
|
|
743
|
+
to an actor, and is exactly the kind of information the echoed status_date
|
|
744
|
+
exists to make sense of.
|
|
745
|
+
"""
|
|
746
|
+
moved_by_project, new_finish_by_project, prior_finish_by_project = _recalc_project_aggregates(
|
|
747
|
+
tasks_to_update, old_dates
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
events: list[Any] = []
|
|
751
|
+
for t in tasks_to_update:
|
|
752
|
+
old = old_dates.get(str(t.id))
|
|
753
|
+
if old is None:
|
|
754
|
+
continue
|
|
755
|
+
if _cpm_dates_changed(old, t) and not suppress_movement_events:
|
|
756
|
+
events.append(
|
|
757
|
+
_cpm_recalculated_event(
|
|
758
|
+
t,
|
|
759
|
+
old,
|
|
760
|
+
moved_by_project,
|
|
761
|
+
new_finish_by_project,
|
|
762
|
+
prior_finish_by_project,
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
baseline = baseline_finishes.get(str(t.id))
|
|
766
|
+
if baseline is not None:
|
|
767
|
+
drift = _baseline_drift_event(t, old[1], baseline)
|
|
768
|
+
if drift is not None:
|
|
769
|
+
events.append(drift)
|
|
770
|
+
return events
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
_CPM_DELTA_FIELDS: tuple[str, ...] = (
|
|
774
|
+
"early_start",
|
|
775
|
+
"early_finish",
|
|
776
|
+
"late_start",
|
|
777
|
+
"late_finish",
|
|
778
|
+
"total_float",
|
|
779
|
+
"free_float",
|
|
780
|
+
"is_critical",
|
|
781
|
+
"duration",
|
|
782
|
+
# scheduled_start (ADR-0752): the task's span start, distinct from
|
|
783
|
+
# early_start's remaining-work window. Same write-back, same broadcast —
|
|
784
|
+
# a client rendering the span bar needs to know it moved too.
|
|
785
|
+
"scheduled_start",
|
|
786
|
+
)
|
|
787
|
+
"""Every CPM-owned field a ``task_dates_updated`` delta can carry (ADR-0091, #2573).
|
|
788
|
+
|
|
789
|
+
Deliberately the same set the write-back persists, so "this row changed" and "this
|
|
790
|
+
row needs to ship in the delta" are the same question. ``planned_start`` also rides
|
|
791
|
+
in the payload but is a user-owned field the CPM write-back never touches, so it
|
|
792
|
+
cannot make a row moved.
|
|
793
|
+
"""
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
_cpm_delta_snapshot = operator.attrgetter(*_CPM_DELTA_FIELDS)
|
|
797
|
+
"""Snapshot the broadcast-relevant CPM fields of a Task for a moved/unmoved diff.
|
|
798
|
+
|
|
799
|
+
``attrgetter`` rather than a ``getattr`` generator: this runs twice per task inside
|
|
800
|
+
the ``_apply_cpm_results`` hot loop, and the C-level multi-attribute form is ~4x
|
|
801
|
+
faster than the Python-level loop (0.19 µs vs 0.85 µs per call), which at 5,000
|
|
802
|
+
tasks is ~8 ms → ~2 ms. Every name is a concrete column on ``Task`` and neither CPM
|
|
803
|
+
load path defers columns, so no access can trigger a deferred-field query.
|
|
804
|
+
"""
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def summary_working_day_durations(
|
|
808
|
+
result_map: dict[str, Any],
|
|
809
|
+
summary_ids: set[str],
|
|
810
|
+
db_task_by_id: dict[str, Any],
|
|
811
|
+
calendar_by_project: Mapping[str, Any],
|
|
812
|
+
) -> dict[str, int]:
|
|
813
|
+
"""Working-day duration for each rolled-up summary row (#3530).
|
|
814
|
+
|
|
815
|
+
``Task.duration`` is documented and consumed everywhere as **working days**
|
|
816
|
+
(``help_text="Duration in working days"``; the MSPDI exporter multiplies it by
|
|
817
|
+
``HOURS_PER_WORKING_DAY``, ``project_span_days`` sums it, and
|
|
818
|
+
``build_sched_tasks`` feeds it straight back to the engine as
|
|
819
|
+
``timedelta(days=…)``). The write-back used to store the raw **calendar-day**
|
|
820
|
+
span ``(early_finish - early_start).days`` on summary rows instead, justified by
|
|
821
|
+
a comment asserting nothing read a summary's duration. That was locally true of
|
|
822
|
+
the leaf CPM pass and globally false — a ~1.4x inflation reached every consumer
|
|
823
|
+
above, and 49 of 50 summary rows in the dev database carried it.
|
|
824
|
+
|
|
825
|
+
So: count working days in the summary's rolled-up span, **inclusive of both
|
|
826
|
+
endpoints**, which is the engine's own duration convention
|
|
827
|
+
(``_finish_from_start(start, 1) == start``). The counter answers the half-open
|
|
828
|
+
``[start, end)``, so the finish day is added back — conditionally; see the
|
|
829
|
+
comment on that line for the weekend-``actual_finish`` case it guards.
|
|
830
|
+
|
|
831
|
+
WHY a batched pre-pass rather than a call inside the write-back loop: the scalar
|
|
832
|
+
counter is an O(span) day walk, so per-summary calls are O(summaries · span) —
|
|
833
|
+
a deeply nested WBS re-walks the whole project span once per level.
|
|
834
|
+
:class:`_WorkingDayCounter` (#822) pays O(span) once per calendar and answers
|
|
835
|
+
each span with two binary searches. Measured: one ``build`` over a 2-year span
|
|
836
|
+
costs about the same as one scalar walk of it (0.24 ms, 0.35 ms with 25 calendar
|
|
837
|
+
exceptions) and each ``between`` is 1.4 µs, so break-even is under two summaries
|
|
838
|
+
— and a project's root summary alone already spans the hull. At 50 summaries in
|
|
839
|
+
a 2-year project the batched form is ~0.35 ms against up to ~11 ms.
|
|
840
|
+
|
|
841
|
+
Be honest about the other comparison: what this replaced was an O(1) timedelta
|
|
842
|
+
subtraction, so the correct answer costs ~0.24–1.4 ms per project per recompute
|
|
843
|
+
where the wrong one cost nothing. Working days cannot be derived without walking
|
|
844
|
+
a calendar, and that is <0.3% of the recompute budget.
|
|
845
|
+
|
|
846
|
+
``schedule()`` builds a counter of its own for float computation, which this
|
|
847
|
+
duplicates on the single-project path. It is local to that function and never
|
|
848
|
+
returned, and the program pass needs one counter *per member calendar* anyway
|
|
849
|
+
(ADR-0120 D3), so reusing it would mean widening the PyPI package's public
|
|
850
|
+
surface for ~1 ms. Left duplicated deliberately.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
result_map: Engine rows keyed by task id, **after** ``apply_summary_rollups``
|
|
854
|
+
has added the synthetic summary rows.
|
|
855
|
+
summary_ids: Task ids that have at least one WBS child.
|
|
856
|
+
db_task_by_id: Django ``Task`` rows keyed by ``str(id)`` — read only for
|
|
857
|
+
each summary's ``project_id``, to pick its calendar.
|
|
858
|
+
calendar_by_project: Composed engine ``Calendar`` per ``str(project_id)``.
|
|
859
|
+
The program pass holds one per member project (ADR-0120 D3), so a
|
|
860
|
+
summary is always measured on its own project's working week.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
``{summary_id: working_day_duration}``, omitting any summary the engine did
|
|
864
|
+
not roll up (no scheduled leaves) or whose project has no calendar.
|
|
865
|
+
"""
|
|
866
|
+
# Private engine symbols, same boundary crossing `scheduling.services` already
|
|
867
|
+
# makes for `_collect_leaves`. `trueppm-scheduler` ships to PyPI on its own
|
|
868
|
+
# semver, so this couples the API to unexported internals: a change to either
|
|
869
|
+
# one's signature or complexity breaks the paragraph above, not just this call.
|
|
870
|
+
# The alternative — a public working-day-count export — is a public-surface
|
|
871
|
+
# change to an Apache 2.0 package and belongs in that package's own MR.
|
|
872
|
+
from trueppm_scheduler.engine import _WorkingDayCounter
|
|
873
|
+
|
|
874
|
+
# One pass: collect each summary's span and, per project, the enclosing range
|
|
875
|
+
# its counter must cover. Grouped as we go rather than re-scanning `spans` per
|
|
876
|
+
# project — a program pass holds many member projects and this would otherwise
|
|
877
|
+
# be O(projects · summaries).
|
|
878
|
+
spans: dict[str, tuple[str, Any, Any]] = {}
|
|
879
|
+
ranges: dict[str, tuple[Any, Any]] = {}
|
|
880
|
+
for sid in summary_ids:
|
|
881
|
+
sched = result_map.get(sid)
|
|
882
|
+
db_task = db_task_by_id.get(sid)
|
|
883
|
+
if sched is None or db_task is None:
|
|
884
|
+
continue
|
|
885
|
+
if sched.early_start is None or sched.early_finish is None:
|
|
886
|
+
continue
|
|
887
|
+
project_key = str(db_task.project_id)
|
|
888
|
+
spans[sid] = (project_key, sched.early_start, sched.early_finish)
|
|
889
|
+
lo, hi = ranges.get(project_key, (sched.early_start, sched.early_finish))
|
|
890
|
+
ranges[project_key] = (
|
|
891
|
+
min(lo, sched.early_start),
|
|
892
|
+
max(hi, sched.early_finish),
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
# One counter per calendar, covering that project's full summary date range.
|
|
896
|
+
# Paired with the calendar itself: the closed-interval correction below needs
|
|
897
|
+
# `is_working_day`, which the counter does not expose.
|
|
898
|
+
counters: dict[str, tuple[Any, Any]] = {}
|
|
899
|
+
for project_key, (lo, hi) in ranges.items():
|
|
900
|
+
cal = calendar_by_project.get(project_key)
|
|
901
|
+
if cal is not None:
|
|
902
|
+
counters[project_key] = (_WorkingDayCounter.build(lo, hi, cal), cal)
|
|
903
|
+
|
|
904
|
+
durations: dict[str, int] = {}
|
|
905
|
+
for sid, (project_key, early_start, early_finish) in spans.items():
|
|
906
|
+
entry = counters.get(project_key)
|
|
907
|
+
if entry is None:
|
|
908
|
+
# No calendar for this summary's project — leave its stored duration
|
|
909
|
+
# alone rather than write a value measured on the wrong working week.
|
|
910
|
+
continue
|
|
911
|
+
counter, cal = entry
|
|
912
|
+
# `between` counts the HALF-OPEN [early_start, early_finish); a duration is
|
|
913
|
+
# INCLUSIVE of its finish day, so add the finish back — but only if it is
|
|
914
|
+
# itself a working day. It need not be: a completed task pinned to a weekend
|
|
915
|
+
# `actual_finish` (ADR-0136) contributes a non-working `early_finish` to the
|
|
916
|
+
# rollup's max, and the engine says so in `_gather_successor_constraints`'
|
|
917
|
+
# own docstring. Adding an unconditional 1 there would report a phase as a
|
|
918
|
+
# day longer than the work inside it.
|
|
919
|
+
working = counter.between(early_start, early_finish)
|
|
920
|
+
if cal.is_working_day(early_finish):
|
|
921
|
+
working += 1
|
|
922
|
+
# Floor at 1: a summary whose whole span collapsed onto non-working days
|
|
923
|
+
# still occupied a day of the plan, and 0 reads as a milestone.
|
|
924
|
+
durations[sid] = max(1, working)
|
|
925
|
+
return durations
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _apply_cpm_results(
|
|
929
|
+
db_tasks: Iterable[Any],
|
|
930
|
+
result_map: dict[str, Any],
|
|
931
|
+
summary_durations: Mapping[str, int],
|
|
932
|
+
) -> tuple[list[Any], dict[str, tuple[Any, Any, Any, Any]], list[Any]]:
|
|
933
|
+
"""Write engine CPM results onto Task rows in memory.
|
|
934
|
+
|
|
935
|
+
Returns ``(tasks_to_update, old_cpm_dates, moved_tasks)``.
|
|
936
|
+
|
|
937
|
+
``old_cpm_dates`` snapshots each task's four CPM dates *before* they are
|
|
938
|
+
overwritten, so :func:`_build_schedule_shift_events` can tell which tasks
|
|
939
|
+
actually moved (ADR-0207).
|
|
940
|
+
|
|
941
|
+
``moved_tasks`` is the subset of ``tasks_to_update`` whose broadcast-relevant
|
|
942
|
+
CPM fields (:data:`_CPM_DELTA_FIELDS`) actually changed in this pass. WHY it is
|
|
943
|
+
computed here and not by the caller: a CPM run is a *full* write-back — every
|
|
944
|
+
engine-returned row is re-assigned whether or not its value moved — so the only
|
|
945
|
+
place the pre-write value still exists is inside this loop. Broadcasting the
|
|
946
|
+
full write-back set instead of the moved subset put every project above 500
|
|
947
|
+
tasks permanently into the ``truncated`` branch, which made each connected
|
|
948
|
+
client re-fetch the whole paginated task list on every single-field edit and
|
|
949
|
+
left ADR-0091's splice path effectively dead (#2573). ``tasks_to_update``
|
|
950
|
+
itself stays the full set — the write-back and the ADR-0207 activity events
|
|
951
|
+
must still see every row.
|
|
952
|
+
|
|
953
|
+
INTENTIONAL DESIGN: the caller persists these via ``bulk_update``, which
|
|
954
|
+
bypasses ``VersionedModel.save()`` so ``server_version`` is NOT incremented —
|
|
955
|
+
CPM fields (early_start, is_critical, etc.) are read-only computed values the
|
|
956
|
+
mobile client derives locally, and bumping server_version here would flood
|
|
957
|
+
every connected client with sync deltas on every recalc. Do NOT change the
|
|
958
|
+
call sites to ``save()`` without understanding that consequence.
|
|
959
|
+
"""
|
|
960
|
+
tasks_to_update: list[Any] = []
|
|
961
|
+
old_cpm_dates: dict[str, tuple[Any, Any, Any, Any]] = {}
|
|
962
|
+
moved_tasks: list[Any] = []
|
|
963
|
+
for db_task in db_tasks:
|
|
964
|
+
# One `str(UUID)` for the row, reused by all three lookups below. Same
|
|
965
|
+
# rationale as the `attrgetter` snapshot above: this loop runs once per task
|
|
966
|
+
# and the conversion is not free at 5,000 rows.
|
|
967
|
+
task_key = str(db_task.id)
|
|
968
|
+
sched = result_map.get(task_key)
|
|
969
|
+
if sched is None:
|
|
970
|
+
continue
|
|
971
|
+
old_cpm_dates[task_key] = (
|
|
972
|
+
db_task.early_start,
|
|
973
|
+
db_task.early_finish,
|
|
974
|
+
db_task.late_start,
|
|
975
|
+
db_task.late_finish,
|
|
976
|
+
)
|
|
977
|
+
before = _cpm_delta_snapshot(db_task)
|
|
978
|
+
db_task.early_start = sched.early_start
|
|
979
|
+
db_task.early_finish = sched.early_finish
|
|
980
|
+
db_task.late_start = sched.late_start
|
|
981
|
+
db_task.late_finish = sched.late_finish
|
|
982
|
+
# `is not None`, NOT truthiness: bool(timedelta(0)) is False, so a truthy
|
|
983
|
+
# test writes NULL for zero float — the defining value of every
|
|
984
|
+
# critical-path task. NULL means "not scheduled"; 0 means "no slack", and
|
|
985
|
+
# collapsing the two blanked the Float cell on exactly the tasks a PM
|
|
986
|
+
# inspects and made capture_forecast_snapshot's Min("total_float") skip
|
|
987
|
+
# them, reporting the project's tightest slack as the smallest *positive*
|
|
988
|
+
# float on the plan (#2802).
|
|
989
|
+
db_task.total_float = sched.total_float.days if sched.total_float is not None else None
|
|
990
|
+
db_task.free_float = sched.free_float.days if sched.free_float is not None else None
|
|
991
|
+
db_task.is_critical = sched.is_critical
|
|
992
|
+
# ADR-0752: the task's span start, distinct from early_start's
|
|
993
|
+
# remaining-work window.
|
|
994
|
+
db_task.scheduled_start = sched.scheduled_start
|
|
995
|
+
# Belt-and-suspenders: milestones are single-point gates. Even if the
|
|
996
|
+
# boundary normalisation is bypassed, a milestone's finish must equal its
|
|
997
|
+
# start so client-facing rows never render a date range.
|
|
998
|
+
if db_task.is_milestone:
|
|
999
|
+
db_task.early_finish = db_task.early_start
|
|
1000
|
+
db_task.late_finish = db_task.late_start
|
|
1001
|
+
# For summary tasks, overwrite duration with the WORKING-day span of the
|
|
1002
|
+
# rolled-up window, so the stored value means what the column documents and
|
|
1003
|
+
# what every consumer of it assumes (#3530). Precomputed by
|
|
1004
|
+
# `summary_working_day_durations` — the calendar walk is batched per
|
|
1005
|
+
# calendar rather than run inside this hot loop. `summary_durations` is keyed
|
|
1006
|
+
# exclusively by summary id, so the lookup IS the summary test; a leaf misses
|
|
1007
|
+
# it, and so does a summary the engine did not roll up (which keeps its
|
|
1008
|
+
# stored duration rather than getting a wrong one).
|
|
1009
|
+
new_duration = summary_durations.get(task_key)
|
|
1010
|
+
if new_duration is not None:
|
|
1011
|
+
db_task.duration = new_duration
|
|
1012
|
+
tasks_to_update.append(db_task)
|
|
1013
|
+
if _cpm_delta_snapshot(db_task) != before:
|
|
1014
|
+
moved_tasks.append(db_task)
|
|
1015
|
+
return tasks_to_update, old_cpm_dates, moved_tasks
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _apply_driving_flags(deps: list[Any], driving_edges: Iterable[Any]) -> None:
|
|
1019
|
+
"""Set ``is_driving`` on each stored Dependency whose edge has zero relationship
|
|
1020
|
+
free float (#2095).
|
|
1021
|
+
|
|
1022
|
+
Keyed by ``(predecessor_id, successor_id, dep_type)`` on leaf task ids exactly
|
|
1023
|
+
as the engine reports; summary-level edges (expanded before CPM) carry no
|
|
1024
|
+
driving fact and stay non-driving (the visual default). bulk_update at the call
|
|
1025
|
+
site bypasses ``VersionedModel.save`` — is_driving is a derived CPM output, not
|
|
1026
|
+
a user edit, and must not trigger a mobile-sync pull.
|
|
1027
|
+
"""
|
|
1028
|
+
driving_edge_keys = {(e.predecessor_id, e.successor_id, e.dep_type) for e in driving_edges}
|
|
1029
|
+
for dep in deps:
|
|
1030
|
+
dep.is_driving = (
|
|
1031
|
+
str(dep.predecessor_id),
|
|
1032
|
+
str(dep.successor_id),
|
|
1033
|
+
dep.dep_type,
|
|
1034
|
+
) in driving_edge_keys
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _clear_output_of_fully_groomed_project(project_id: str) -> None:
|
|
1038
|
+
"""Clear stale CPM output for a project whose committed set is empty.
|
|
1039
|
+
|
|
1040
|
+
Deliberately separate from the run's ``_settle_empty()``: its other callers are
|
|
1041
|
+
the project-not-found path (nothing to clear) and the two escalate-to-program
|
|
1042
|
+
paths, where the program run is the sole writer and clears the whole program's
|
|
1043
|
+
project set itself.
|
|
1044
|
+
"""
|
|
1045
|
+
from trueppm_api.apps.projects.models import Task
|
|
1046
|
+
|
|
1047
|
+
cleared = clear_uncommitted_cpm_output(Task, project_ids=[project_id])
|
|
1048
|
+
if cleared:
|
|
1049
|
+
logger.info(
|
|
1050
|
+
"recalculate_schedule: project %s cleared CPM output on %d unscheduled row(s)",
|
|
1051
|
+
project_id,
|
|
1052
|
+
cleared,
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _persist_driving_flags_and_shift_events(
|
|
1057
|
+
db_deps: list[Any], schedule_shift_events: list[Any]
|
|
1058
|
+
) -> None:
|
|
1059
|
+
"""Persist driving-link flags and schedule-shift events inside the caller's atomic block.
|
|
1060
|
+
|
|
1061
|
+
bulk_update bypasses VersionedModel.save so it never bumps server_version —
|
|
1062
|
+
is_driving is a derived CPM output, not a user edit, and must not trigger a
|
|
1063
|
+
mobile-sync pull (same rationale as Task).
|
|
1064
|
+
"""
|
|
1065
|
+
from trueppm_api.apps.projects.models import Dependency, TaskActivityEvent
|
|
1066
|
+
|
|
1067
|
+
if db_deps:
|
|
1068
|
+
Dependency.objects.bulk_update(db_deps, ["is_driving"], batch_size=_WRITEBACK_BATCH_SIZE)
|
|
1069
|
+
if schedule_shift_events:
|
|
1070
|
+
TaskActivityEvent.objects.bulk_create(
|
|
1071
|
+
schedule_shift_events, batch_size=_WRITEBACK_BATCH_SIZE
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _run_schedule(
|
|
1076
|
+
project_id: str,
|
|
1077
|
+
tracker: object = None,
|
|
1078
|
+
) -> None:
|
|
1079
|
+
"""Load tasks/dependencies, run CPM, bulk_update results, broadcast completion.
|
|
1080
|
+
|
|
1081
|
+
CPM runs on the full project and every affected task's results are written
|
|
1082
|
+
back. The engine-level incremental recompute (recompute only the changed
|
|
1083
|
+
subgraph) is tracked under #235 (0.5, ADR-0027).
|
|
1084
|
+
|
|
1085
|
+
Args:
|
|
1086
|
+
project_id: UUID string of the project to schedule.
|
|
1087
|
+
tracker: Optional TaskRunTracker for progress reporting.
|
|
1088
|
+
"""
|
|
1089
|
+
from trueppm_scheduler.engine import schedule
|
|
1090
|
+
from trueppm_scheduler.models import Dependency as SchedDependency
|
|
1091
|
+
from trueppm_scheduler.models import DependencyType
|
|
1092
|
+
from trueppm_scheduler.models import Project as SchedProject
|
|
1093
|
+
|
|
1094
|
+
from trueppm_api.apps.observability.otel import attributes
|
|
1095
|
+
from trueppm_api.apps.projects.models import (
|
|
1096
|
+
Dependency,
|
|
1097
|
+
EstimationMode,
|
|
1098
|
+
Project,
|
|
1099
|
+
Task,
|
|
1100
|
+
TaskStatus,
|
|
1101
|
+
TaskType,
|
|
1102
|
+
)
|
|
1103
|
+
from trueppm_api.apps.scheduling.calendars import compose_project_calendar
|
|
1104
|
+
from trueppm_api.apps.scheduling.services import (
|
|
1105
|
+
apply_summary_rollups,
|
|
1106
|
+
build_sched_graph,
|
|
1107
|
+
build_sched_tasks,
|
|
1108
|
+
resolve_cpm_status_date,
|
|
1109
|
+
)
|
|
1110
|
+
from trueppm_api.apps.scheduling.telemetry import cpm_span
|
|
1111
|
+
|
|
1112
|
+
def _update(pct: int, msg: str) -> None:
|
|
1113
|
+
if tracker is not None:
|
|
1114
|
+
tracker.update(pct, msg) # type: ignore[attr-defined]
|
|
1115
|
+
|
|
1116
|
+
def _settle_empty() -> None:
|
|
1117
|
+
"""Record a dateless-but-settled result on paths that return before the
|
|
1118
|
+
CPM write. Without this the tracker emits task_run_completed with
|
|
1119
|
+
result_summary=None, which the web client cannot distinguish from an
|
|
1120
|
+
in-flight run, so the "Recalculating…" badge spins forever (#1976). The
|
|
1121
|
+
frontend also clears on any completed scheduling run; this keeps the
|
|
1122
|
+
result_summary a non-null object for audit and other API consumers."""
|
|
1123
|
+
if tracker is not None:
|
|
1124
|
+
tracker.set_result({"project_finish": None, "critical_path": []}) # type: ignore[attr-defined]
|
|
1125
|
+
|
|
1126
|
+
_update(10, "Loading project data…")
|
|
1127
|
+
|
|
1128
|
+
try:
|
|
1129
|
+
db_project = (
|
|
1130
|
+
Project.objects.select_related("calendar", "program__calendar")
|
|
1131
|
+
# tasks__sprint: build_sched_tasks reads each task's sprint.start_date
|
|
1132
|
+
# for the ADR-0168 sprint-window floor; prefetch it to avoid an N+1.
|
|
1133
|
+
# calendar__exceptions + calendar_layers__calendar__exceptions:
|
|
1134
|
+
# compose_project_calendar reads every CalendarException row of the
|
|
1135
|
+
# base calendar (#1491) AND of every applied overlay (#906); prefetch
|
|
1136
|
+
# both to avoid an N+1. program__calendar__exceptions: when the project
|
|
1137
|
+
# inherits its base calendar from the program (ADR-0441), the resolver
|
|
1138
|
+
# reads the program calendar's exceptions too — prefetch to avoid an N+1.
|
|
1139
|
+
.prefetch_related(
|
|
1140
|
+
"tasks",
|
|
1141
|
+
"tasks__sprint",
|
|
1142
|
+
"tasks__predecessors",
|
|
1143
|
+
"calendar__exceptions",
|
|
1144
|
+
"calendar_layers__calendar__exceptions",
|
|
1145
|
+
"program__calendar__exceptions",
|
|
1146
|
+
)
|
|
1147
|
+
.get(pk=project_id)
|
|
1148
|
+
)
|
|
1149
|
+
except Project.DoesNotExist:
|
|
1150
|
+
logger.warning("recalculate_schedule: project %s not found, skipping", project_id)
|
|
1151
|
+
_settle_empty()
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
# ADR-0752 §4/§7: resolve the floor once, up front, from the state this
|
|
1155
|
+
# project was loaded in — before anything below can mutate it. A project
|
|
1156
|
+
# with no explicit status_date and no prior arming is about to have its
|
|
1157
|
+
# floor armed for the first time by *this* recalculation.
|
|
1158
|
+
resolved_status_date = resolve_cpm_status_date(db_project.status_date)
|
|
1159
|
+
is_first_status_date_floor_use = (
|
|
1160
|
+
db_project.status_date is None and db_project.status_date_floor_armed_at is None
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
# ADR-0120 D3: if this project belongs to a program that has ≥1 accepted
|
|
1164
|
+
# cross-project edge, a single-project CPM would compute program-FALSE floats
|
|
1165
|
+
# and criticality (it cannot see the cross-boundary demand). Escalate to the
|
|
1166
|
+
# merged program-scoped pass and stop here, *before* any write — the program
|
|
1167
|
+
# run is the sole writer for every member project while escalation holds, so no
|
|
1168
|
+
# stale single-project dates are ever persisted. The DISPATCHED outbox row is
|
|
1169
|
+
# left for the program run to mark done.
|
|
1170
|
+
if _escalate_to_program(project_id, db_project.program_id):
|
|
1171
|
+
_settle_empty()
|
|
1172
|
+
return
|
|
1173
|
+
|
|
1174
|
+
# Exclude recurrence templates and their generated occurrences from the CPM feed.
|
|
1175
|
+
# Recurring tasks are parallel, calendar-driven activities — admitting them to the
|
|
1176
|
+
# scheduling engine would corrupt float, the critical path, and Monte Carlo
|
|
1177
|
+
# P50/P80/P95. is_recurring is the single load-bearing exclusion key (ADR-0090);
|
|
1178
|
+
# CommittedTaskManager applies the same filter for Monte Carlo / capacity / PDF.
|
|
1179
|
+
#
|
|
1180
|
+
# type=EPIC is excluded for the same reason (ADR-0105): an epic is a grouping
|
|
1181
|
+
# node, not schedulable work — its rollup dates are computed from child stories, not
|
|
1182
|
+
# fed to CPM. CommittedTaskManager applies the matching exclusion at its boundary.
|
|
1183
|
+
#
|
|
1184
|
+
# status=BACKLOG and soft-deleted tombstones are excluded so the deterministic CPM
|
|
1185
|
+
# feed matches CommittedTaskManager exactly (#1772). Monte Carlo already reads from
|
|
1186
|
+
# Task.committed; feeding CPM from the unfiltered manager let backlog ideas and
|
|
1187
|
+
# deleted rows into the network, so grooming the backlog could move the deterministic
|
|
1188
|
+
# critical path and the two engines could structurally disagree on the Overview page.
|
|
1189
|
+
# Filtered in Python so the prefetch cache from the queryset above is reused.
|
|
1190
|
+
db_tasks = [
|
|
1191
|
+
t
|
|
1192
|
+
for t in db_project.tasks.all()
|
|
1193
|
+
if not t.is_recurring
|
|
1194
|
+
and t.type != TaskType.EPIC
|
|
1195
|
+
and t.status != TaskStatus.BACKLOG
|
|
1196
|
+
and not t.is_deleted
|
|
1197
|
+
]
|
|
1198
|
+
if not db_tasks:
|
|
1199
|
+
logger.info("recalculate_schedule: project %s has no tasks, skipping", project_id)
|
|
1200
|
+
# ADR-1152: a project whose entire committed set was groomed away still holds
|
|
1201
|
+
# every row's last schedule, and this return is BEFORE the write-back — so the
|
|
1202
|
+
# worst case (100% of rows stale) is exactly the one the atomic block below
|
|
1203
|
+
# never reaches. Clear here so the invariant holds on this path too (#3578).
|
|
1204
|
+
# Deliberately NOT inside _settle_empty(): its other three callers are the
|
|
1205
|
+
# project-not-found path (nothing to clear) and the two escalate-to-program
|
|
1206
|
+
# paths, where the program run is the sole writer and clears the whole
|
|
1207
|
+
# program's project set itself.
|
|
1208
|
+
_clear_output_of_fully_groomed_project(project_id)
|
|
1209
|
+
_settle_empty()
|
|
1210
|
+
return
|
|
1211
|
+
|
|
1212
|
+
_update(25, "Building schedule model…")
|
|
1213
|
+
|
|
1214
|
+
# Build a trueppm_scheduler.Calendar from the OVERLAY of the project's applied
|
|
1215
|
+
# calendars — base + holiday/shutdown overlays — as one composed non-working
|
|
1216
|
+
# mask (#906, ADR-0251). Routes through the shared composer so the CPM pass,
|
|
1217
|
+
# Monte Carlo, and program scheduling can never drift on which calendars apply.
|
|
1218
|
+
sched_calendar = compose_project_calendar(db_project)
|
|
1219
|
+
|
|
1220
|
+
# Convert Django Task objects to scheduler dataclasses through the shared
|
|
1221
|
+
# converter (ADR-0132), the single source of truth the Monte Carlo endpoint
|
|
1222
|
+
# also uses — so progress/actual fields and milestone normalisation can never
|
|
1223
|
+
# drift between the two engines (the cause of #1185). The suggest_approve gate
|
|
1224
|
+
# is a no-op for the deterministic CPM pass (which ignores PERT) but is passed
|
|
1225
|
+
# for parity so both paths run identical code.
|
|
1226
|
+
sched_tasks = build_sched_tasks(
|
|
1227
|
+
db_tasks,
|
|
1228
|
+
suggest_approve=db_project.estimation_mode == EstimationMode.SUGGEST_APPROVE,
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
# Convert Django Dependency objects to scheduler dataclasses. Drop any edge that
|
|
1232
|
+
# touches an excluded (recurring) task — its endpoint is absent from sched_tasks,
|
|
1233
|
+
# so handing it to the engine would create a dangling dependency. See ADR-0090.
|
|
1234
|
+
included_ids = {str(t.id) for t in db_tasks}
|
|
1235
|
+
# Dependency.live, not .objects: a soft-deleted edge is a deleted constraint.
|
|
1236
|
+
# Feeding it to the engine keeps a link the user removed in the UI binding the
|
|
1237
|
+
# persisted CPM dates forever (#3532).
|
|
1238
|
+
db_deps = list(
|
|
1239
|
+
Dependency.live.filter(predecessor__project_id=project_id).select_related(
|
|
1240
|
+
"predecessor", "successor"
|
|
1241
|
+
)
|
|
1242
|
+
)
|
|
1243
|
+
sched_deps = [
|
|
1244
|
+
SchedDependency(
|
|
1245
|
+
predecessor_id=str(d.predecessor_id),
|
|
1246
|
+
successor_id=str(d.successor_id),
|
|
1247
|
+
dep_type=DependencyType(d.dep_type),
|
|
1248
|
+
lag=timedelta(days=d.lag),
|
|
1249
|
+
)
|
|
1250
|
+
for d in db_deps
|
|
1251
|
+
if str(d.predecessor_id) in included_ids and str(d.successor_id) in included_ids
|
|
1252
|
+
]
|
|
1253
|
+
|
|
1254
|
+
# Strip summary rows and fan their edges down to leaves before CPM (ADR-0105).
|
|
1255
|
+
# Shared with every Monte Carlo path since #3527 — one shaping step, one network.
|
|
1256
|
+
db_task_by_id = {str(t.id): t for t in db_tasks}
|
|
1257
|
+
graph = build_sched_graph(db_tasks, sched_tasks, sched_deps)
|
|
1258
|
+
children_map = graph.children_map
|
|
1259
|
+
summary_ids = graph.summary_ids
|
|
1260
|
+
leaf_tasks, expanded_deps = graph.tasks, graph.dependencies
|
|
1261
|
+
|
|
1262
|
+
sched_project = SchedProject(
|
|
1263
|
+
id=project_id,
|
|
1264
|
+
name=db_project.name,
|
|
1265
|
+
start_date=db_project.start_date,
|
|
1266
|
+
tasks=leaf_tasks,
|
|
1267
|
+
dependencies=expanded_deps,
|
|
1268
|
+
calendar=sched_calendar,
|
|
1269
|
+
# The stored plan honors recorded actuals (completed tasks pin, in-progress
|
|
1270
|
+
# tasks use remaining duration) always, and floors not-started work at the
|
|
1271
|
+
# data date: the PM's explicit status_date when set, otherwise today
|
|
1272
|
+
# (resolved once, above, as resolved_status_date — ADR-0752 §4, correcting
|
|
1273
|
+
# ADR-0132 §1). The deterministic CPM pass and the Monte Carlo forecast now
|
|
1274
|
+
# share this same resolution; the CPM path no longer passes a null
|
|
1275
|
+
# status_date raw (which previously meant "no floor at all").
|
|
1276
|
+
status_date=resolved_status_date,
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
_update(50, "Running CPM…")
|
|
1280
|
+
|
|
1281
|
+
# Exceptions propagate to TaskRunTracker.__exit__, which marks the run FAILED
|
|
1282
|
+
# and broadcasts task_run_failed to connected clients. The manual CPM span
|
|
1283
|
+
# (#709) times the engine call and records the graph shape; it is a no-op span
|
|
1284
|
+
# unless OTel export is configured. Runs inside the enqueued Celery task span,
|
|
1285
|
+
# so the trace links the recompute back to the request that triggered it.
|
|
1286
|
+
with cpm_span(project_id, dependency_count=len(sched_deps)) as _cpm_span:
|
|
1287
|
+
result = schedule(sched_project)
|
|
1288
|
+
_cpm_span.set_attribute(attributes.SCHEDULE_TASK_COUNT, len(result.tasks))
|
|
1289
|
+
_cpm_span.set_attribute(attributes.SCHEDULE_CRITICAL_COUNT, len(result.critical_path))
|
|
1290
|
+
|
|
1291
|
+
_update(80, "Writing results…")
|
|
1292
|
+
|
|
1293
|
+
# Build a map from task id string to computed CPM values.
|
|
1294
|
+
result_map = {t.id: t for t in result.tasks}
|
|
1295
|
+
|
|
1296
|
+
# Compute summary task dates by rolling up from their leaf descendants
|
|
1297
|
+
# (ADR-0105) via the shared helper, so this single-project write-back and the
|
|
1298
|
+
# program-scoped write-back derive summary dates through identical code.
|
|
1299
|
+
apply_summary_rollups(result_map, summary_ids, children_map, db_task_by_id)
|
|
1300
|
+
|
|
1301
|
+
# Working-day duration for each rolled-up summary (#3530), on the project's own
|
|
1302
|
+
# composed calendar — the same one the CPM pass just ran on.
|
|
1303
|
+
summary_durations = summary_working_day_durations(
|
|
1304
|
+
result_map, summary_ids, db_task_by_id, {str(db_project.pk): sched_calendar}
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
# Driving-link flags (#2095): set in memory here and persisted via bulk_update
|
|
1308
|
+
# below (like Task CPM fields — no server_version bump).
|
|
1309
|
+
_apply_driving_flags(db_deps, result.driving_edges)
|
|
1310
|
+
|
|
1311
|
+
# Write CPM results back for every task the engine returned (server_version is
|
|
1312
|
+
# intentionally NOT bumped — see _apply_cpm_results). old_cpm_dates snapshots
|
|
1313
|
+
# each task's dates before the overwrite so the shift-events helper can tell
|
|
1314
|
+
# which tasks actually moved (ADR-0207); moved_tasks is that same moved subset
|
|
1315
|
+
# as a list, and is what the ADR-0091 delta broadcast ships (#2573).
|
|
1316
|
+
tasks_to_update, old_cpm_dates, moved_tasks = _apply_cpm_results(
|
|
1317
|
+
db_tasks, result_map, summary_durations
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
# ADR-0752 §7: the suppression only makes sense once there is a prior
|
|
1321
|
+
# schedule to have moved *from*. A project's very first-ever CPM pass
|
|
1322
|
+
# transitions every task from null to computed — exactly like any other
|
|
1323
|
+
# first schedule, floor or no floor — and is not the "someone moved my
|
|
1324
|
+
# schedule" confusion this ADR guards against, so it is reported normally
|
|
1325
|
+
# (matching the existing null -> computed behavior). is_first_status_date_
|
|
1326
|
+
# floor_use still governs whether the armed flag gets persisted below,
|
|
1327
|
+
# regardless of this refinement, so the *next* recalculation is never
|
|
1328
|
+
# treated as arming again.
|
|
1329
|
+
is_arming_status_date_floor = is_first_status_date_floor_use and any(
|
|
1330
|
+
old[0] is not None for old in old_cpm_dates.values()
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
from django.db import transaction
|
|
1334
|
+
|
|
1335
|
+
from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
|
|
1336
|
+
|
|
1337
|
+
cpm_payload: dict[str, object] = {
|
|
1338
|
+
"project_finish": result.project_finish.isoformat(),
|
|
1339
|
+
"critical_path": result.critical_path,
|
|
1340
|
+
# The data date this run was actually computed against (ADR-0752 §4) —
|
|
1341
|
+
# echoed unconditionally (including on the arming run) so every consumer
|
|
1342
|
+
# of cpm_complete/task_run_completed/the schedule.recalculated webhook
|
|
1343
|
+
# knows which "today" produced these dates, mirroring the MC status_date
|
|
1344
|
+
# provenance convention (#2638).
|
|
1345
|
+
"status_date": resolved_status_date.isoformat(),
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
# Per-task CPM date deltas (ADR-0091). Broadcast the tasks whose dates just moved so
|
|
1349
|
+
# collaborators' Gantt bars slide in real time, with no full re-fetch. Built from
|
|
1350
|
+
# moved_tasks (already in scope, fields mutated in-memory) so it costs no query.
|
|
1351
|
+
# It MUST be the moved subset, not the full write-back set: a CPM pass re-assigns
|
|
1352
|
+
# every engine-returned row, so shipping tasks_to_update put any project over
|
|
1353
|
+
# CPM_DELTA_BROADCAST_CAP permanently into the truncated branch and turned every
|
|
1354
|
+
# single-field edit into a full paginated re-fetch per connected client (#2573).
|
|
1355
|
+
# Best-effort, same tier as cpm_complete. Field names mirror SyncTaskSerializer so the
|
|
1356
|
+
# web client can splice the payload straight into its task cache (and a future mobile
|
|
1357
|
+
# client could too); the server_version carve-out above is preserved — this is an
|
|
1358
|
+
# optimization layer over the sync protocol, not a replacement for it. server_version
|
|
1359
|
+
# is intentionally NOT in the payload: clients splice these as optimistic CPM updates,
|
|
1360
|
+
# they are not a sync anchor (bulk_update bypasses VersionedModel.save(), ADR-0091).
|
|
1361
|
+
# Built via the shared helper so the single-project and program-pass deltas (and
|
|
1362
|
+
# their >CPM_DELTA_BROADCAST_CAP truncation) can never drift.
|
|
1363
|
+
delta_payload = _member_cpm_delta(moved_tasks)
|
|
1364
|
+
|
|
1365
|
+
# Persist results and mark the outbox row done in a single transaction, and
|
|
1366
|
+
# defer the board broadcasts to on_commit (#896). Previously the bulk_update
|
|
1367
|
+
# and the two broadcasts ran in autocommit, *before* the ScheduleRequest
|
|
1368
|
+
# status update — a failure on that update (e.g. a lost DB connection) left
|
|
1369
|
+
# clients showing CPM dates that were never committed. Wrapping the writes in
|
|
1370
|
+
# one atomic block and registering the broadcasts with transaction.on_commit
|
|
1371
|
+
# means clients only ever see dates that actually persisted; a rollback
|
|
1372
|
+
# broadcasts nothing. Default-arg binding pins the payloads so the deferred
|
|
1373
|
+
# callbacks can't late-bind a mutated value.
|
|
1374
|
+
# ADR-0120 D3 pre-write guard: a cross-project edge may have been accepted
|
|
1375
|
+
# while this single-project CPM was running. Re-check the escalation predicate
|
|
1376
|
+
# immediately before the write — if the program now has an accepted cross edge,
|
|
1377
|
+
# discard this (now program-FALSE) single-project result and hand off to the
|
|
1378
|
+
# program pass rather than persisting stale single-project dates. Cheap EXISTS.
|
|
1379
|
+
if _escalate_to_program(project_id, db_project.program_id):
|
|
1380
|
+
_settle_empty()
|
|
1381
|
+
return
|
|
1382
|
+
|
|
1383
|
+
# Per-task schedule-shift activity events (ADR-0207, #1604), built from the
|
|
1384
|
+
# mutated in-memory tasks and the project's active baseline. Written inside the
|
|
1385
|
+
# same atomic block as the writeback so they commit or roll back with it.
|
|
1386
|
+
# suppress_movement_events (ADR-0752 §7) withholds the cpm_recalculated rows
|
|
1387
|
+
# on this project's one-shot floor-arming recalculation only.
|
|
1388
|
+
schedule_shift_events = _build_schedule_shift_events(
|
|
1389
|
+
tasks_to_update,
|
|
1390
|
+
old_cpm_dates,
|
|
1391
|
+
_active_baseline_finishes([project_id]),
|
|
1392
|
+
suppress_movement_events=is_arming_status_date_floor,
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
with transaction.atomic():
|
|
1396
|
+
Task.objects.bulk_update(
|
|
1397
|
+
tasks_to_update,
|
|
1398
|
+
[
|
|
1399
|
+
"early_start",
|
|
1400
|
+
"early_finish",
|
|
1401
|
+
"late_start",
|
|
1402
|
+
"late_finish",
|
|
1403
|
+
"total_float",
|
|
1404
|
+
"free_float",
|
|
1405
|
+
"is_critical",
|
|
1406
|
+
"duration",
|
|
1407
|
+
"scheduled_start",
|
|
1408
|
+
],
|
|
1409
|
+
batch_size=_WRITEBACK_BATCH_SIZE,
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
# ADR-1152 (#3578): the write-back above covers only the rows the engine
|
|
1413
|
+
# returned, which is the committed set. Rows that LEFT it keep their last
|
|
1414
|
+
# schedule forever unless something clears them, and nothing did — so a
|
|
1415
|
+
# groomed-out card was still read as current by the task serializer and by
|
|
1416
|
+
# nine project-level aggregates over Task.objects. Same atomic block as the
|
|
1417
|
+
# bulk_update on purpose: the committed set's new dates and the non-committed
|
|
1418
|
+
# set's nulls are one consistent picture, or neither lands.
|
|
1419
|
+
clear_uncommitted_cpm_output(Task, project_ids=[project_id])
|
|
1420
|
+
|
|
1421
|
+
# Persist driving-link flags (#2095). bulk_update bypasses VersionedModel.save
|
|
1422
|
+
# so it never bumps server_version — is_driving is a derived CPM output, not a
|
|
1423
|
+
# user edit, and must not trigger a mobile-sync pull (same rationale as Task).
|
|
1424
|
+
_persist_driving_flags_and_shift_events(db_deps, schedule_shift_events)
|
|
1425
|
+
|
|
1426
|
+
# Mark the outbox row done so the drain task knows this project is clean.
|
|
1427
|
+
# Filter on status=dispatched to avoid racing with the drain during orphan
|
|
1428
|
+
# recovery (which resets rows back to pending).
|
|
1429
|
+
ScheduleRequest.objects.filter(
|
|
1430
|
+
project_id=project_id, status=ScheduleRequestStatus.DISPATCHED
|
|
1431
|
+
).update(status=ScheduleRequestStatus.DONE)
|
|
1432
|
+
|
|
1433
|
+
# ADR-0752 §4/§7: persist the one-shot arming flag (if this is the
|
|
1434
|
+
# first use of the floor) and register the on-commit broadcasts,
|
|
1435
|
+
# withholding the per-task movement broadcast on the arming run.
|
|
1436
|
+
# Extracted to its own function to keep this transaction block's
|
|
1437
|
+
# branching flat.
|
|
1438
|
+
_finalize_status_date_floor_arming(
|
|
1439
|
+
project_id,
|
|
1440
|
+
cpm_payload=cpm_payload,
|
|
1441
|
+
delta_payload=delta_payload,
|
|
1442
|
+
is_first_status_date_floor_use=is_first_status_date_floor_use,
|
|
1443
|
+
is_arming_status_date_floor=is_arming_status_date_floor,
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
# Capture a project-grain forecast snapshot for drift history (ADR-0154,
|
|
1447
|
+
# #388). Strictly post-commit and best-effort: a capture failure must never
|
|
1448
|
+
# roll back the CPM write above. Any miss is backfilled by the daily-floor
|
|
1449
|
+
# task, so we do not need an outbox here.
|
|
1450
|
+
def _capture_forecast(pid: str = project_id) -> None:
|
|
1451
|
+
from trueppm_api.apps.scheduling.models import ForecastSnapshotTrigger
|
|
1452
|
+
from trueppm_api.apps.scheduling.services import safe_capture_forecast_snapshot
|
|
1453
|
+
|
|
1454
|
+
safe_capture_forecast_snapshot(pid, ForecastSnapshotTrigger.RECOMPUTE)
|
|
1455
|
+
|
|
1456
|
+
transaction.on_commit(_capture_forecast)
|
|
1457
|
+
|
|
1458
|
+
logger.info(
|
|
1459
|
+
"recalculate_schedule: updated %d tasks for project %s (finish=%s)",
|
|
1460
|
+
len(tasks_to_update),
|
|
1461
|
+
project_id,
|
|
1462
|
+
result.project_finish,
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
# Store CPM result summary for audit / frontend access.
|
|
1466
|
+
if tracker is not None:
|
|
1467
|
+
tracker.set_result(cpm_payload) # type: ignore[attr-defined]
|
|
1468
|
+
|
|
1469
|
+
# Dispatch schedule.recalculated webhook to external subscribers. Fired after
|
|
1470
|
+
# the transaction so subscribers are only notified of committed results.
|
|
1471
|
+
from trueppm_api.apps.webhooks.dispatch import dispatch_webhooks
|
|
1472
|
+
|
|
1473
|
+
dispatch_webhooks(
|
|
1474
|
+
project_id=project_id,
|
|
1475
|
+
event_type="schedule.recalculated",
|
|
1476
|
+
payload={"project": project_id, **cpm_payload},
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _broadcast_cpm_complete(
|
|
1481
|
+
*,
|
|
1482
|
+
project_id: str,
|
|
1483
|
+
project_finish: str | None,
|
|
1484
|
+
critical_path: list[str],
|
|
1485
|
+
status_date: str | None,
|
|
1486
|
+
) -> None:
|
|
1487
|
+
"""Emit ``cpm_complete`` with the one payload shape shared by both CPM passes.
|
|
1488
|
+
|
|
1489
|
+
Both the single-project write-back (``_finalize_status_date_floor_arming``)
|
|
1490
|
+
and the program-scoped write-back (``_register_program_broadcasts``) route
|
|
1491
|
+
through this one function so the two passes can never again drift on this
|
|
1492
|
+
event's keys (#3776 — before this fix they built the payload as two
|
|
1493
|
+
separate dict *variables*, one carrying ``status_date`` and one not, and
|
|
1494
|
+
both landed in ``test_broadcast_payload_shape.py``'s unscannable bucket,
|
|
1495
|
+
which is exactly what let the drift go undetected). The literal dict below
|
|
1496
|
+
is what makes this call site — now the *only* ``cpm_complete`` call site —
|
|
1497
|
+
scannable by that gate instead of contributing two entries to it.
|
|
1498
|
+
|
|
1499
|
+
Call from inside a ``transaction.on_commit()`` registration (via
|
|
1500
|
+
``functools.partial`` to pin the arguments at registration time, matching
|
|
1501
|
+
the default-arg-binding pattern used elsewhere in this module) so clients
|
|
1502
|
+
only ever see a payload for a write that actually committed.
|
|
1503
|
+
|
|
1504
|
+
``status_date`` is the CPM data date this run resolved (ADR-0752 §4),
|
|
1505
|
+
echoed so a consumer knows which "today" produced these dates. It is
|
|
1506
|
+
``None`` on the program-scoped pass: ``gather_program_schedule`` runs the
|
|
1507
|
+
merged engine with ``status_date=None`` on purpose, because member
|
|
1508
|
+
projects can carry different ``status_date`` values and no single one
|
|
1509
|
+
would honestly describe what the merged pass floored against — the
|
|
1510
|
+
program view shows the earliest honest schedule, not a data-date floor.
|
|
1511
|
+
Echoing ``None`` states that plainly rather than fabricating a value or
|
|
1512
|
+
silently dropping the key.
|
|
1513
|
+
"""
|
|
1514
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1515
|
+
|
|
1516
|
+
broadcast_board_event(
|
|
1517
|
+
project_id=project_id,
|
|
1518
|
+
event_type="cpm_complete",
|
|
1519
|
+
payload={
|
|
1520
|
+
"project_finish": project_finish,
|
|
1521
|
+
"critical_path": critical_path,
|
|
1522
|
+
"status_date": status_date,
|
|
1523
|
+
},
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _finalize_status_date_floor_arming(
|
|
1528
|
+
project_id: str,
|
|
1529
|
+
*,
|
|
1530
|
+
cpm_payload: dict[str, object],
|
|
1531
|
+
delta_payload: dict[str, object],
|
|
1532
|
+
is_first_status_date_floor_use: bool,
|
|
1533
|
+
is_arming_status_date_floor: bool,
|
|
1534
|
+
) -> None:
|
|
1535
|
+
"""Persist the one-shot arming flag and register the on-commit CPM broadcasts.
|
|
1536
|
+
|
|
1537
|
+
ADR-0752 §4/§7: ``is_first_status_date_floor_use`` marks the recalculation
|
|
1538
|
+
that first resolves this project's null ``status_date`` to today — the
|
|
1539
|
+
armed flag is persisted here so no later recalculation is ever treated as
|
|
1540
|
+
the first use again. ``is_arming_status_date_floor`` is the narrower
|
|
1541
|
+
condition that actually withholds the ADR-0091 ``task_dates_updated``
|
|
1542
|
+
per-task delta broadcast: first use *and* at least one task already had a
|
|
1543
|
+
prior computed date (i.e. this is a genuine correction to an established
|
|
1544
|
+
schedule, not a brand-new project's first-ever pass, which reports
|
|
1545
|
+
null -> computed transitions exactly like it always has). This is the
|
|
1546
|
+
pub/sub surface the §10 threat model names explicitly; suppressing only
|
|
1547
|
+
the ``cpm_recalculated`` TaskActivityEvent rows (see
|
|
1548
|
+
``_build_schedule_shift_events``) and still firing this broadcast would
|
|
1549
|
+
let the same movement reach connected clients as ordinary activity through
|
|
1550
|
+
a different channel. ``cpm_complete`` still fires unconditionally,
|
|
1551
|
+
including on the arming run — it carries ``project_finish``/
|
|
1552
|
+
``status_date``, not per-task movement, so it is not part of what §7
|
|
1553
|
+
suppresses.
|
|
1554
|
+
|
|
1555
|
+
MUST be called from inside the writeback's ``transaction.atomic()`` block,
|
|
1556
|
+
matching ``_register_program_broadcasts`` below: the ``.update()`` call
|
|
1557
|
+
needs to commit or roll back with the Task writeback, and ``on_commit``
|
|
1558
|
+
callbacks must be registered before that block exits. Default-arg binding
|
|
1559
|
+
(and, for ``cpm_complete``, ``functools.partial``) pins each payload
|
|
1560
|
+
against late mutation.
|
|
1561
|
+
"""
|
|
1562
|
+
from typing import cast
|
|
1563
|
+
|
|
1564
|
+
from django.db import transaction
|
|
1565
|
+
from django.utils import timezone
|
|
1566
|
+
|
|
1567
|
+
from trueppm_api.apps.projects.models import Project
|
|
1568
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1569
|
+
|
|
1570
|
+
if is_first_status_date_floor_use:
|
|
1571
|
+
# A plain .update() bypasses VersionedModel.save() — no server_version
|
|
1572
|
+
# bump, no history row, no mobile-sync pull — for this server-owned
|
|
1573
|
+
# bookkeeping field, matching the existing recalculated_at pattern.
|
|
1574
|
+
Project.objects.filter(pk=project_id).update(status_date_floor_armed_at=timezone.now())
|
|
1575
|
+
|
|
1576
|
+
# Routed through the shared _broadcast_cpm_complete() (#3776) so this and
|
|
1577
|
+
# the program-scoped pass can never again ship cpm_complete with different
|
|
1578
|
+
# keys. functools.partial evaluates its keyword arguments immediately,
|
|
1579
|
+
# pinning cpm_payload's fields at registration time exactly like the
|
|
1580
|
+
# default-arg pattern used for task_dates_updated just below. The casts
|
|
1581
|
+
# only narrow cpm_payload's declared dict[str, object] value type back to
|
|
1582
|
+
# what _run_schedule actually put there (str project_finish/status_date,
|
|
1583
|
+
# list[str] critical_path) — this function never sees a program-pass
|
|
1584
|
+
# cpm_payload, whose caller passes its own concretely-typed values instead.
|
|
1585
|
+
transaction.on_commit(
|
|
1586
|
+
functools.partial(
|
|
1587
|
+
_broadcast_cpm_complete,
|
|
1588
|
+
project_id=project_id,
|
|
1589
|
+
project_finish=cast("str", cpm_payload["project_finish"]),
|
|
1590
|
+
critical_path=cast("list[str]", cpm_payload["critical_path"]),
|
|
1591
|
+
status_date=cast("str", cpm_payload["status_date"]),
|
|
1592
|
+
)
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
if not is_arming_status_date_floor:
|
|
1596
|
+
|
|
1597
|
+
def _broadcast_dates(pid: str = project_id, pay: dict[str, object] = delta_payload) -> None:
|
|
1598
|
+
broadcast_board_event(project_id=pid, event_type="task_dates_updated", payload=pay)
|
|
1599
|
+
|
|
1600
|
+
transaction.on_commit(_broadcast_dates)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
# ---------------------------------------------------------------------------
|
|
1604
|
+
# Program-scoped CPM dispatch pass — ADR-0120 D3 (#1117)
|
|
1605
|
+
# ---------------------------------------------------------------------------
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _escalate_to_program(project_id: str, program_id: object) -> bool:
|
|
1609
|
+
"""Dispatch the program-scoped pass instead of a single-project one, if needed.
|
|
1610
|
+
|
|
1611
|
+
Returns ``True`` when the project's program has ≥1 accepted cross-project edge
|
|
1612
|
+
(ADR-0120 D3) and a ``recalculate_program_schedule`` was dispatched (best
|
|
1613
|
+
effort) — the caller must then return *without* writing single-project results,
|
|
1614
|
+
because a single-project CPM cannot see the cross-boundary demand and would
|
|
1615
|
+
persist program-FALSE floats/criticality. ``False`` keeps today's per-project
|
|
1616
|
+
path. If the dispatch itself fails (broker down) the outbox row stays
|
|
1617
|
+
DISPATCHED and the 10-minute orphan sweep re-escalates it — so this never
|
|
1618
|
+
silently drops the recompute.
|
|
1619
|
+
"""
|
|
1620
|
+
if program_id is None:
|
|
1621
|
+
return False
|
|
1622
|
+
from trueppm_api.apps.projects.program_schedule import program_has_accepted_cross_edges
|
|
1623
|
+
|
|
1624
|
+
if not program_has_accepted_cross_edges(program_id):
|
|
1625
|
+
return False
|
|
1626
|
+
try:
|
|
1627
|
+
recalculate_program_schedule.delay(str(program_id))
|
|
1628
|
+
except Exception:
|
|
1629
|
+
logger.exception(
|
|
1630
|
+
"recalculate_schedule: could not dispatch program pass for program %s "
|
|
1631
|
+
"— orphan recovery will re-escalate within 10 min",
|
|
1632
|
+
program_id,
|
|
1633
|
+
)
|
|
1634
|
+
return True
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
@idempotent_task(
|
|
1638
|
+
lock_key_template="schedule_lock:program:{0}",
|
|
1639
|
+
lock_ttl=300,
|
|
1640
|
+
on_contention="queue",
|
|
1641
|
+
queue_countdown=10,
|
|
1642
|
+
max_queue_attempts=30,
|
|
1643
|
+
autoretry_for=_RETRIABLE,
|
|
1644
|
+
retry_backoff=30,
|
|
1645
|
+
retry_backoff_max=300,
|
|
1646
|
+
retry_jitter=True,
|
|
1647
|
+
max_retries=3,
|
|
1648
|
+
soft_time_limit=480,
|
|
1649
|
+
time_limit=600,
|
|
1650
|
+
acks_late=True,
|
|
1651
|
+
reject_on_worker_lost=True,
|
|
1652
|
+
)
|
|
1653
|
+
def recalculate_program_schedule(self: object, program_id: str) -> None:
|
|
1654
|
+
"""Run the merged, program-true CPM for one program and persist it (ADR-0120 D3).
|
|
1655
|
+
|
|
1656
|
+
Escalated to from ``recalculate_schedule`` whenever a member project's program
|
|
1657
|
+
holds ≥1 accepted cross-project edge. It merges every member project's tasks
|
|
1658
|
+
and every accepted cross edge into one engine graph (the shared
|
|
1659
|
+
``gather_program_schedule``), runs CPM once, writes program-true CPM fields back
|
|
1660
|
+
to every member project, fans the ``cpm_complete`` / ``task_dates_updated``
|
|
1661
|
+
broadcasts out to each member project's board channel (there is no program WS
|
|
1662
|
+
channel in 0.3 — the program view subscribes per member project), upserts any
|
|
1663
|
+
D4 sprint-boundary conflicts, and coalesces every member project's outbox row.
|
|
1664
|
+
|
|
1665
|
+
Idempotent: a pure recompute, safe to run twice. ``schedule_lock:program:{id}``
|
|
1666
|
+
serializes program runs; ``max_queue_attempts`` is sized so contenders ride out
|
|
1667
|
+
a held lock rather than dropping, and the outbox orphan sweep is the backstop on
|
|
1668
|
+
a crash. The lock namespace is disjoint from the per-project ``schedule_lock:{id}``,
|
|
1669
|
+
but a per-project task can never *write* while escalation holds (it self-aborts
|
|
1670
|
+
at the pre-write guard), so the two never produce a torn write.
|
|
1671
|
+
"""
|
|
1672
|
+
_run_program_schedule(program_id)
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _member_cpm_delta(moved_tasks: list[Any]) -> dict[str, object]:
|
|
1676
|
+
"""Build one project's ``task_dates_updated`` delta payload (ADR-0091 shape).
|
|
1677
|
+
|
|
1678
|
+
``moved_tasks`` must be the *changed set* — only the rows whose CPM fields
|
|
1679
|
+
actually moved this pass — not the full write-back set. ``count`` is therefore
|
|
1680
|
+
the moved-task count in both branches, as ADR-0091 specifies. Mirrors the
|
|
1681
|
+
single-project delta exactly so a web client can splice program-pass results
|
|
1682
|
+
into its task cache the same way; above ``CPM_DELTA_BROADCAST_CAP`` it emits a
|
|
1683
|
+
``truncated`` signal and lets the client re-fetch.
|
|
1684
|
+
"""
|
|
1685
|
+
if len(moved_tasks) > CPM_DELTA_BROADCAST_CAP:
|
|
1686
|
+
return {"count": len(moved_tasks), "truncated": True}
|
|
1687
|
+
return {
|
|
1688
|
+
"count": len(moved_tasks),
|
|
1689
|
+
"tasks": [
|
|
1690
|
+
{
|
|
1691
|
+
"id": str(t.id),
|
|
1692
|
+
"early_start": t.early_start.isoformat() if t.early_start else None,
|
|
1693
|
+
"early_finish": t.early_finish.isoformat() if t.early_finish else None,
|
|
1694
|
+
"late_start": t.late_start.isoformat() if t.late_start else None,
|
|
1695
|
+
"late_finish": t.late_finish.isoformat() if t.late_finish else None,
|
|
1696
|
+
"total_float": t.total_float,
|
|
1697
|
+
"free_float": t.free_float,
|
|
1698
|
+
"is_critical": t.is_critical,
|
|
1699
|
+
"planned_start": t.planned_start.isoformat() if t.planned_start else None,
|
|
1700
|
+
"duration": t.duration,
|
|
1701
|
+
# ADR-0752: the task's span start, distinct from early_start's
|
|
1702
|
+
# remaining-work window. Reaches the same project subscribers
|
|
1703
|
+
# early_start already does — no new field-leak (ADR §10).
|
|
1704
|
+
"scheduled_start": t.scheduled_start.isoformat() if t.scheduled_start else None,
|
|
1705
|
+
}
|
|
1706
|
+
for t in moved_tasks
|
|
1707
|
+
],
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
_PROGRAM_WRITE_FIELDS = [
|
|
1712
|
+
"early_start",
|
|
1713
|
+
"early_finish",
|
|
1714
|
+
"late_start",
|
|
1715
|
+
"late_finish",
|
|
1716
|
+
"total_float",
|
|
1717
|
+
"free_float",
|
|
1718
|
+
"is_critical",
|
|
1719
|
+
"duration",
|
|
1720
|
+
"scheduled_start",
|
|
1721
|
+
]
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def _register_program_broadcasts(
|
|
1725
|
+
graph: Any,
|
|
1726
|
+
by_project: dict[object, list[Any]],
|
|
1727
|
+
moved_by_project: dict[object, list[Any]],
|
|
1728
|
+
crit_order: list[str],
|
|
1729
|
+
) -> None:
|
|
1730
|
+
"""Register each member project's post-commit broadcasts for a program pass.
|
|
1731
|
+
|
|
1732
|
+
Ships a ``cpm_complete`` (full program-true critical path restricted to the
|
|
1733
|
+
project) and ``task_dates_updated`` delta per project, plus a forecast-capture,
|
|
1734
|
+
all deferred to commit (#896) so clients only ever see dates that persisted; a
|
|
1735
|
+
rollback broadcasts nothing. MUST be called from inside the writeback's
|
|
1736
|
+
``transaction.atomic()`` block. Default-arg binding (``task_dates_updated``,
|
|
1737
|
+
the forecast capture) and ``functools.partial`` (``cpm_complete``, via the
|
|
1738
|
+
shared ``_broadcast_cpm_complete()``) both pin each project's payload against
|
|
1739
|
+
late mutation from this loop's next iteration.
|
|
1740
|
+
|
|
1741
|
+
Two groupings, deliberately: ``by_project`` is the full write-back set and
|
|
1742
|
+
defines each project's ``project_finish`` (an *unmoved* late task still sets
|
|
1743
|
+
where finish sits), while ``moved_by_project`` is the changed subset the
|
|
1744
|
+
ADR-0091 delta ships (#2573). Collapsing them would either mis-report the
|
|
1745
|
+
finish or re-introduce the full-project delta.
|
|
1746
|
+
"""
|
|
1747
|
+
from django.db import transaction
|
|
1748
|
+
|
|
1749
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1750
|
+
|
|
1751
|
+
for p in graph.member_projects:
|
|
1752
|
+
pid = str(p.id)
|
|
1753
|
+
project_tasks = by_project.get(p.id, [])
|
|
1754
|
+
# The project's full program-true critical path — every critical task in this
|
|
1755
|
+
# project, NOT just the ones whose dates moved this pass (a client that
|
|
1756
|
+
# replaces its critical-path state on cpm_complete must get the whole set,
|
|
1757
|
+
# matching the single-project _run_schedule which ships the full path).
|
|
1758
|
+
project_crit = [
|
|
1759
|
+
tid
|
|
1760
|
+
for tid in crit_order
|
|
1761
|
+
if tid in graph.db_task_by_id and graph.db_task_by_id[tid].project_id == p.id
|
|
1762
|
+
]
|
|
1763
|
+
project_finish = max(
|
|
1764
|
+
(t.early_finish for t in project_tasks if t.early_finish is not None),
|
|
1765
|
+
default=None,
|
|
1766
|
+
)
|
|
1767
|
+
delta_payload = _member_cpm_delta(moved_by_project.get(p.id, []))
|
|
1768
|
+
|
|
1769
|
+
def _dates(pid: str = pid, pay: dict[str, object] = delta_payload) -> None:
|
|
1770
|
+
broadcast_board_event(project_id=pid, event_type="task_dates_updated", payload=pay)
|
|
1771
|
+
|
|
1772
|
+
def _capture(pid: str = pid) -> None:
|
|
1773
|
+
from trueppm_api.apps.scheduling.models import ForecastSnapshotTrigger
|
|
1774
|
+
from trueppm_api.apps.scheduling.services import safe_capture_forecast_snapshot
|
|
1775
|
+
|
|
1776
|
+
safe_capture_forecast_snapshot(pid, ForecastSnapshotTrigger.RECOMPUTE)
|
|
1777
|
+
|
|
1778
|
+
# Routed through the shared _broadcast_cpm_complete() (#3776) so this
|
|
1779
|
+
# pass and the single-project write-back can never again ship
|
|
1780
|
+
# cpm_complete with different keys — status_date=None documents that
|
|
1781
|
+
# the merged program pass floors nothing (see that function's
|
|
1782
|
+
# docstring). functools.partial pins pid/project_finish/project_crit
|
|
1783
|
+
# at this loop iteration, same as the default-arg pattern _dates and
|
|
1784
|
+
# _capture use just above.
|
|
1785
|
+
transaction.on_commit(
|
|
1786
|
+
functools.partial(
|
|
1787
|
+
_broadcast_cpm_complete,
|
|
1788
|
+
project_id=pid,
|
|
1789
|
+
project_finish=project_finish.isoformat() if project_finish else None,
|
|
1790
|
+
critical_path=project_crit,
|
|
1791
|
+
status_date=None,
|
|
1792
|
+
)
|
|
1793
|
+
)
|
|
1794
|
+
transaction.on_commit(_dates)
|
|
1795
|
+
transaction.on_commit(_capture)
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def _run_program_schedule(program_id: str) -> None:
|
|
1799
|
+
"""Gather, run, and persist the program-true schedule for ``program_id``.
|
|
1800
|
+
|
|
1801
|
+
Extracted from the Celery task for testability — exercised directly by the
|
|
1802
|
+
program-pass tests without a broker.
|
|
1803
|
+
"""
|
|
1804
|
+
from django.db import transaction
|
|
1805
|
+
from django.utils import timezone
|
|
1806
|
+
|
|
1807
|
+
from trueppm_api.apps.projects.lifecycle import visible_projects
|
|
1808
|
+
from trueppm_api.apps.projects.models import (
|
|
1809
|
+
Dependency,
|
|
1810
|
+
Program,
|
|
1811
|
+
Project,
|
|
1812
|
+
Task,
|
|
1813
|
+
TaskActivityEvent,
|
|
1814
|
+
)
|
|
1815
|
+
from trueppm_api.apps.projects.program_schedule import gather_program_schedule
|
|
1816
|
+
from trueppm_api.apps.projects.slip_conflict import detect_and_upsert_slip_conflicts
|
|
1817
|
+
from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
|
|
1818
|
+
from trueppm_api.apps.scheduling.services import apply_summary_rollups
|
|
1819
|
+
from trueppm_api.apps.webhooks.dispatch import dispatch_webhooks
|
|
1820
|
+
|
|
1821
|
+
try:
|
|
1822
|
+
program = Program.objects.get(pk=program_id, is_deleted=False)
|
|
1823
|
+
except Program.DoesNotExist:
|
|
1824
|
+
logger.warning("recalculate_program_schedule: program %s not found, skipping", program_id)
|
|
1825
|
+
return
|
|
1826
|
+
|
|
1827
|
+
# Drafts are excluded (#2962/#3128) to stay consistent with
|
|
1828
|
+
# ``gather_program_schedule``, which drops them from the merged graph. If this
|
|
1829
|
+
# list still carried a draft, the pass would stamp ``recalculated_at`` on a
|
|
1830
|
+
# project it never scheduled and drain its ScheduleRequests against a result
|
|
1831
|
+
# that does not mention it.
|
|
1832
|
+
member_ids = list(
|
|
1833
|
+
visible_projects(
|
|
1834
|
+
Project.objects.filter(program_id=program_id, is_deleted=False)
|
|
1835
|
+
).values_list("id", flat=True)
|
|
1836
|
+
)
|
|
1837
|
+
if not member_ids:
|
|
1838
|
+
return
|
|
1839
|
+
|
|
1840
|
+
# CLAIM before reading data: capture exactly the member outbox rows that are
|
|
1841
|
+
# already DISPATCHED. Only these row ids are marked DONE at the end, so a row
|
|
1842
|
+
# dispatched *after* this point (a concurrent edit while we run) is never
|
|
1843
|
+
# swallowed — it lands a fresh row and is serviced by the next pass.
|
|
1844
|
+
claimed_ids = list(
|
|
1845
|
+
ScheduleRequest.objects.filter(
|
|
1846
|
+
project_id__in=member_ids, status=ScheduleRequestStatus.DISPATCHED
|
|
1847
|
+
).values_list("id", flat=True)
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
graph = gather_program_schedule(program, enforce_max=False)
|
|
1851
|
+
|
|
1852
|
+
now = timezone.now()
|
|
1853
|
+
tasks_to_update: list[Task] = []
|
|
1854
|
+
# The subset of tasks_to_update whose CPM fields actually moved — what the
|
|
1855
|
+
# ADR-0091 delta broadcast ships (#2573).
|
|
1856
|
+
moved_tasks: list[Task] = []
|
|
1857
|
+
# Driving-link flags across every member project (#2095), persisted alongside
|
|
1858
|
+
# the task write-back below. Empty when the program has no schedulable result.
|
|
1859
|
+
program_db_deps: list[Dependency] = []
|
|
1860
|
+
# Snapshot CPM dates before overwrite, for the schedule-shift events (ADR-0207).
|
|
1861
|
+
old_cpm_dates: dict[str, tuple[Any, Any, Any, Any]] = {}
|
|
1862
|
+
if graph.result is not None:
|
|
1863
|
+
result_map = graph.result_map
|
|
1864
|
+
# Roll summary dates up from leaves through the shared helper (parity with
|
|
1865
|
+
# the single-project write-back).
|
|
1866
|
+
summary_ids = graph.summary_ids
|
|
1867
|
+
apply_summary_rollups(result_map, summary_ids, graph.children_map, graph.db_task_by_id)
|
|
1868
|
+
# Working-day summary durations (#3530). ADR-0120 D3 holds one calendar per
|
|
1869
|
+
# member project, so each summary is measured on its own project's working
|
|
1870
|
+
# week rather than a single program-wide one.
|
|
1871
|
+
summary_durations = summary_working_day_durations(
|
|
1872
|
+
result_map, summary_ids, graph.db_task_by_id, graph.calendars
|
|
1873
|
+
)
|
|
1874
|
+
# Full write-back across every member project (the program pass is coarse;
|
|
1875
|
+
# incremental subgraph writes are a later optimization). Shared helper with
|
|
1876
|
+
# _run_schedule, so field assignment — milestone single-point normalisation
|
|
1877
|
+
# and summary working-day duration included — can never drift.
|
|
1878
|
+
tasks_to_update, old_cpm_dates, moved_tasks = _apply_cpm_results(
|
|
1879
|
+
graph.db_task_by_id.values(), result_map, summary_durations
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
# Driving-link flags across the program (#2095), same shared helper as the
|
|
1883
|
+
# single-project path; summary-level edges stay non-driving.
|
|
1884
|
+
# Dependency.live: the driving-link flag is a CPM output, and the program
|
|
1885
|
+
# graph this result came from already excluded soft-deleted edges
|
|
1886
|
+
# (gather_program_schedule). Writing flags to a deleted edge would be
|
|
1887
|
+
# scoring a constraint that is not in the network (#3532).
|
|
1888
|
+
program_db_deps = list(Dependency.live.filter(predecessor__project_id__in=member_ids))
|
|
1889
|
+
_apply_driving_flags(program_db_deps, graph.result.driving_edges)
|
|
1890
|
+
|
|
1891
|
+
# Group the full write-back set by project — this is what defines each member
|
|
1892
|
+
# project's finish (an unmoved late task still sets where finish sits).
|
|
1893
|
+
by_project: dict[object, list[Task]] = {}
|
|
1894
|
+
for t in tasks_to_update:
|
|
1895
|
+
by_project.setdefault(t.project_id, []).append(t)
|
|
1896
|
+
|
|
1897
|
+
# And the moved subset by project — this is what the ADR-0091 delta ships, so a
|
|
1898
|
+
# program pass over many member projects does not force every subscriber into
|
|
1899
|
+
# the truncated full-refetch branch (#2573).
|
|
1900
|
+
moved_by_project: dict[object, list[Task]] = {}
|
|
1901
|
+
for t in moved_tasks:
|
|
1902
|
+
moved_by_project.setdefault(t.project_id, []).append(t)
|
|
1903
|
+
|
|
1904
|
+
# Program-true critical path restricted to each project (clients consuming a
|
|
1905
|
+
# project's cpm_complete expect that project's critical ids).
|
|
1906
|
+
crit_order = list(graph.result.critical_path) if graph.result is not None else []
|
|
1907
|
+
|
|
1908
|
+
# Per-task schedule-shift activity events across every member project (ADR-0207),
|
|
1909
|
+
# keyed off each project's own active baseline for the drift crossings.
|
|
1910
|
+
schedule_shift_events = _build_schedule_shift_events(
|
|
1911
|
+
tasks_to_update, old_cpm_dates, _active_baseline_finishes(member_ids)
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
with transaction.atomic():
|
|
1915
|
+
if tasks_to_update:
|
|
1916
|
+
Task.objects.bulk_update(
|
|
1917
|
+
tasks_to_update, _PROGRAM_WRITE_FIELDS, batch_size=_WRITEBACK_BATCH_SIZE
|
|
1918
|
+
)
|
|
1919
|
+
|
|
1920
|
+
# ADR-1152 (#3578): mirror of the single-project clear. Scoped to every member
|
|
1921
|
+
# project, NOT to the schedulable subset — a member whose whole committed set
|
|
1922
|
+
# was groomed away contributes no rows to `tasks_to_update` yet is precisely
|
|
1923
|
+
# the project holding the most residue. This also covers the two
|
|
1924
|
+
# escalate-to-program returns in _run_schedule, which deliberately clear
|
|
1925
|
+
# nothing themselves because the program run is the sole writer while
|
|
1926
|
+
# escalation holds (ADR-0120 D3).
|
|
1927
|
+
clear_uncommitted_cpm_output(Task, project_ids=member_ids)
|
|
1928
|
+
|
|
1929
|
+
# Persist driving-link flags (#2095) — bulk_update, no server_version bump.
|
|
1930
|
+
if program_db_deps:
|
|
1931
|
+
Dependency.objects.bulk_update(
|
|
1932
|
+
program_db_deps, ["is_driving"], batch_size=_WRITEBACK_BATCH_SIZE
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
if schedule_shift_events:
|
|
1936
|
+
TaskActivityEvent.objects.bulk_create(
|
|
1937
|
+
schedule_shift_events, batch_size=_WRITEBACK_BATCH_SIZE
|
|
1938
|
+
)
|
|
1939
|
+
|
|
1940
|
+
# Coalesce: mark exactly the claimed member outbox rows done (DISPATCHED →
|
|
1941
|
+
# DONE). Filtering on DISPATCHED avoids racing the drain's orphan recovery.
|
|
1942
|
+
if claimed_ids:
|
|
1943
|
+
ScheduleRequest.objects.filter(
|
|
1944
|
+
id__in=claimed_ids, status=ScheduleRequestStatus.DISPATCHED
|
|
1945
|
+
).update(status=ScheduleRequestStatus.DONE)
|
|
1946
|
+
|
|
1947
|
+
# Stamp recalculated_at on every member project so each project's Schedule
|
|
1948
|
+
# view clears its "recalculating" badge (#1053), bulk to skip history.
|
|
1949
|
+
Project.objects.filter(pk__in=member_ids).update(recalculated_at=now)
|
|
1950
|
+
|
|
1951
|
+
# D4 sprint-boundary firewall: detect and upsert cross-project slip
|
|
1952
|
+
# conflicts in the same transaction as the write-back, so a conflict is
|
|
1953
|
+
# never visible against dates that did not commit.
|
|
1954
|
+
if graph.result is not None:
|
|
1955
|
+
detect_and_upsert_slip_conflicts(graph)
|
|
1956
|
+
|
|
1957
|
+
# Per-project broadcasts + forecast capture, deferred to commit (#896) so
|
|
1958
|
+
# clients only ever see dates that persisted. Registered from inside this
|
|
1959
|
+
# atomic block so the on_commit callbacks are tied to this transaction.
|
|
1960
|
+
_register_program_broadcasts(graph, by_project, moved_by_project, crit_order)
|
|
1961
|
+
|
|
1962
|
+
logger.info(
|
|
1963
|
+
"recalculate_program_schedule: program %s — %d member project(s), %d task(s) updated",
|
|
1964
|
+
program_id,
|
|
1965
|
+
len(member_ids),
|
|
1966
|
+
len(tasks_to_update),
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
# schedule.recalculated webhook per member project, after commit.
|
|
1970
|
+
for p in graph.member_projects:
|
|
1971
|
+
pid = str(p.id)
|
|
1972
|
+
project_tasks = by_project.get(p.id, [])
|
|
1973
|
+
project_finish = max(
|
|
1974
|
+
(t.early_finish for t in project_tasks if t.early_finish is not None),
|
|
1975
|
+
default=None,
|
|
1976
|
+
)
|
|
1977
|
+
dispatch_webhooks(
|
|
1978
|
+
project_id=pid,
|
|
1979
|
+
event_type="schedule.recalculated",
|
|
1980
|
+
payload={
|
|
1981
|
+
"project": pid,
|
|
1982
|
+
"project_finish": project_finish.isoformat() if project_finish else None,
|
|
1983
|
+
},
|
|
1984
|
+
)
|