trueppm-api 0.4.0__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.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,2248 @@
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.apps.scheduling.finish_reading import (
19
+ Finish,
20
+ finish_shift_days,
21
+ latest_finish,
22
+ task_finish_at_day_start,
23
+ )
24
+ from trueppm_api.core.idempotent import idempotent_task
25
+
26
+ #: An active-baseline finish for one task: ``(baseline_id, finish, at_day_start)``,
27
+ #: ``at_day_start`` being ``BaselineTask.finish_at_day_start`` (#4197).
28
+ BaselineFinish = tuple[str, date, bool | None]
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Exceptions that are transient and should trigger Celery's built-in retry.
33
+ _RETRIABLE = (ConnectionError, redis_lib.ConnectionError, OperationalError)
34
+
35
+
36
+ @idempotent_task(
37
+ lock_key_template="schedule_lock:{0}",
38
+ lock_ttl=300,
39
+ on_contention="queue",
40
+ queue_countdown=10,
41
+ max_queue_attempts=5,
42
+ autoretry_for=_RETRIABLE,
43
+ retry_backoff=30,
44
+ retry_backoff_max=300,
45
+ retry_jitter=True,
46
+ max_retries=3,
47
+ soft_time_limit=480,
48
+ time_limit=600,
49
+ acks_late=True,
50
+ reject_on_worker_lost=True,
51
+ )
52
+ def recalculate_schedule(
53
+ self: object,
54
+ project_id: str,
55
+ ) -> None:
56
+ """Run CPM on a project and persist the results.
57
+
58
+ Idempotency is enforced by the ``@idempotent_task`` decorator which
59
+ acquires a Redis SET NX lock keyed by project_id and auto-extends it
60
+ for long-running schedules. On lock contention the task is re-queued
61
+ with a 10-second countdown (up to 5 attempts).
62
+
63
+ Progress is tracked via TaskRunTracker, which persists status to the
64
+ TaskRun model and broadcasts task_run_* WebSocket events. The frontend
65
+ subscribes to these events instead of the old cpm_queued/complete/error
66
+ events (replaced by ADR-0020).
67
+
68
+ A full CPM recompute runs on every dispatch: the engine-level incremental
69
+ recompute designed in ADR-0027 is tracked separately under #235 (0.5). The
70
+ earlier API-layer write-back narrowing was removed in #1528 as unreachable
71
+ dead code — no dispatch site ever supplied the changed-task set.
72
+
73
+ Args:
74
+ project_id: UUID string of the project to reschedule.
75
+ """
76
+ from trueppm_api.apps.taskruns.tracker import TaskRunTracker
77
+
78
+ try:
79
+ with TaskRunTracker(
80
+ self,
81
+ project_id=project_id,
82
+ task_name="scheduling.recalculate",
83
+ ) as tracker:
84
+ _run_schedule(project_id, tracker)
85
+ # Stamp the successful CPM completion so the web Schedule view can show
86
+ # the "recalculating" badge until the first post-import pass lands
87
+ # (#1053). A bulk update avoids a save() + history row + server_version
88
+ # bump for a non-domain timestamp.
89
+ from django.utils import timezone
90
+
91
+ from trueppm_api.apps.projects.models import Project
92
+
93
+ Project.objects.filter(pk=project_id).update(recalculated_at=timezone.now())
94
+ except SoftTimeLimitExceeded:
95
+ logger.error(
96
+ "recalculate_schedule: soft time limit exceeded for project %s",
97
+ project_id,
98
+ )
99
+ from django.db import transaction
100
+
101
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
102
+
103
+ # Record the failure first, then broadcast cpm_error only after that row
104
+ # commits (#896): clients should not be told a recompute failed unless the
105
+ # dead-letter record actually persisted. Default-arg binding pins the id.
106
+ with transaction.atomic():
107
+ _dead_letter_current(
108
+ self, project_id, SoftTimeLimitExceeded("CPM computation timed out")
109
+ )
110
+
111
+ def _broadcast_error(pid: str = project_id) -> None:
112
+ broadcast_board_event(
113
+ project_id=pid, event_type="cpm_error", payload={"error": "timeout"}
114
+ )
115
+
116
+ transaction.on_commit(_broadcast_error)
117
+
118
+
119
+ @idempotent_task(
120
+ lock_key_template="drain_schedule_queue",
121
+ lock_ttl=60,
122
+ on_contention="skip",
123
+ soft_time_limit=25,
124
+ time_limit=30,
125
+ acks_late=True,
126
+ reject_on_worker_lost=True,
127
+ name="scheduling.drain_schedule_queue",
128
+ )
129
+ def drain_schedule_queue(self: object) -> None:
130
+ """Dispatch any pending ScheduleRequest outbox rows.
131
+
132
+ Runs every 30 seconds via Celery Beat. For each project with a pending
133
+ row, calls recalculate_schedule.delay() and marks the row as dispatched.
134
+
135
+ Also recovers orphaned dispatched rows older than 10 minutes — the
136
+ recalculate_schedule soft_time_limit is 480 s (8 min), so 10 min ensures
137
+ any prior task invocation has either completed or timed out before we
138
+ re-dispatch.
139
+
140
+ The @idempotent_task singleton lock ensures at most one drain runs at a
141
+ time; on_contention="skip" drops any concurrent trigger rather than
142
+ queuing, because the next Beat tick will cover it.
143
+ """
144
+ _do_drain()
145
+
146
+
147
+ @idempotent_task(
148
+ lock_key_template="purge_old_schedule_requests",
149
+ lock_ttl=120,
150
+ on_contention="skip",
151
+ soft_time_limit=55,
152
+ time_limit=90,
153
+ acks_late=True,
154
+ reject_on_worker_lost=True,
155
+ name="scheduling.purge_old_schedule_requests",
156
+ )
157
+ def purge_old_schedule_requests(self: object) -> None:
158
+ """Delete done/dead ScheduleRequest rows older than 7 days.
159
+
160
+ Runs nightly at 02:15 UTC via Celery Beat. Keeps the outbox table small
161
+ so index scans on (status, requested_at) stay fast.
162
+ """
163
+ _do_purge()
164
+
165
+
166
+ def _do_drain() -> None:
167
+ """Business logic for drain_schedule_queue — extracted for testability."""
168
+ from django.utils import timezone
169
+
170
+ from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
171
+
172
+ now = timezone.now()
173
+ orphan_cutoff = now - timedelta(minutes=10)
174
+
175
+ # Recover orphaned rows: dispatched but not completed within 10 minutes.
176
+ #
177
+ # A blanket flip of every stale dispatched row back to pending is unsafe: if
178
+ # the project already has a pending row (a fresh edit arrived after the row was
179
+ # dispatched but before its task finished/died), resurrecting the dead row
180
+ # creates a *second* pending row and violates the
181
+ # schedule_request_one_pending_per_project partial-unique constraint — which
182
+ # aborts the whole UPDATE, so no orphan is recovered and the drain wedges on
183
+ # every tick (#1693). Because CPM is a full recompute, that existing pending
184
+ # row already supersedes the dead dispatched one, so retire the dead row as
185
+ # done rather than resurrecting it. Only stale dispatched rows with no
186
+ # coexisting pending row are flipped back to pending for re-dispatch.
187
+ projects_with_pending = set(
188
+ ScheduleRequest.objects.filter(status=ScheduleRequestStatus.PENDING).values_list(
189
+ "project_id", flat=True
190
+ )
191
+ )
192
+ stale_dispatched = ScheduleRequest.objects.filter(
193
+ status=ScheduleRequestStatus.DISPATCHED,
194
+ dispatched_at__lt=orphan_cutoff,
195
+ )
196
+ superseded = stale_dispatched.filter(project_id__in=projects_with_pending).update(
197
+ status=ScheduleRequestStatus.DONE
198
+ )
199
+ recovered = stale_dispatched.exclude(project_id__in=projects_with_pending).update(
200
+ status=ScheduleRequestStatus.PENDING, celery_task_id=""
201
+ )
202
+ if recovered:
203
+ logger.warning("drain_schedule_queue: recovered %d orphaned dispatched row(s)", recovered)
204
+ if superseded:
205
+ logger.warning(
206
+ "drain_schedule_queue: retired %d stale dispatched row(s) superseded by a pending row",
207
+ superseded,
208
+ )
209
+
210
+ # Dispatch all currently pending rows (one Celery task per project).
211
+ pending = list(ScheduleRequest.objects.filter(status=ScheduleRequestStatus.PENDING))
212
+ dispatched = 0
213
+ for req in pending:
214
+ try:
215
+ result = recalculate_schedule.delay(str(req.project_id))
216
+ except SoftTimeLimitExceeded:
217
+ raise
218
+ except Exception:
219
+ logger.warning(
220
+ "drain_schedule_queue: broker unavailable — project %s stays pending",
221
+ req.project_id,
222
+ )
223
+ continue
224
+ # Conditional update: only flip to dispatched if it's still pending
225
+ # (guards against a concurrent on_commit _enqueue_recalculate call).
226
+ ScheduleRequest.objects.filter(id=req.id, status=ScheduleRequestStatus.PENDING).update(
227
+ status=ScheduleRequestStatus.DISPATCHED,
228
+ celery_task_id=result.id,
229
+ dispatched_at=now,
230
+ )
231
+ dispatched += 1
232
+
233
+ if dispatched or recovered:
234
+ logger.info("drain_schedule_queue: dispatched=%d recovered=%d", dispatched, recovered)
235
+
236
+
237
+ def _do_purge() -> None:
238
+ """Business logic for purge_old_schedule_requests — extracted for testability."""
239
+ from django.utils import timezone
240
+
241
+ from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
242
+
243
+ cutoff = timezone.now() - timedelta(days=7)
244
+ deleted, _ = ScheduleRequest.objects.filter(
245
+ status__in=[ScheduleRequestStatus.DONE, ScheduleRequestStatus.DEAD],
246
+ requested_at__lt=cutoff,
247
+ ).delete()
248
+ logger.info("purge_old_schedule_requests: deleted %d row(s)", deleted)
249
+
250
+
251
+ @idempotent_task(
252
+ lock_key_template="purge_resolved_slip_conflicts",
253
+ lock_ttl=120,
254
+ on_contention="skip",
255
+ soft_time_limit=55,
256
+ time_limit=90,
257
+ acks_late=True,
258
+ reject_on_worker_lost=True,
259
+ name="scheduling.purge_resolved_slip_conflicts",
260
+ )
261
+ def purge_resolved_slip_conflicts(self: object) -> None:
262
+ """Delete cross-project slip conflicts 90 days past resolution (ADR-0120 D4).
263
+
264
+ Runs nightly at 02:25 UTC. Removes ``CrossProjectSlipConflict`` rows whose
265
+ acknowledgment or auto-resolution is older than 90 days; unresolved and
266
+ unacknowledged rows are kept indefinitely (they are still live conflicts).
267
+ Idempotent: a re-run deletes nothing new.
268
+ """
269
+ _do_purge_resolved_slip_conflicts()
270
+
271
+
272
+ def _do_purge_resolved_slip_conflicts() -> None:
273
+ """Business logic for purge_resolved_slip_conflicts — extracted for testability."""
274
+ from django.db.models import Q
275
+ from django.utils import timezone
276
+
277
+ from trueppm_api.apps.projects.models import CrossProjectSlipConflict
278
+
279
+ cutoff = timezone.now() - timedelta(days=90)
280
+ deleted, _ = CrossProjectSlipConflict.objects.filter(
281
+ Q(acknowledged_at__lt=cutoff) | Q(resolved_at__lt=cutoff)
282
+ ).delete()
283
+ if deleted:
284
+ logger.info("purge_resolved_slip_conflicts: deleted %d row(s)", deleted)
285
+
286
+
287
+ @idempotent_task(
288
+ lock_key_template="purge_old_monte_carlo_runs",
289
+ lock_ttl=120,
290
+ on_contention="skip",
291
+ soft_time_limit=55,
292
+ time_limit=90,
293
+ acks_late=True,
294
+ reject_on_worker_lost=True,
295
+ name="scheduling.purge_old_monte_carlo_runs",
296
+ )
297
+ def purge_old_monte_carlo_runs(self: object) -> None:
298
+ """Trim project Monte Carlo run history to the newest cap per project.
299
+
300
+ Runs nightly at 02:20 UTC via Celery Beat. Keeps the most recent N
301
+ ``MonteCarloRun`` rows per project, where N is the per-workspace-effective
302
+ retention cap (ADR-0144, #1232) — no longer the global constant — so
303
+ forecast-drift history stays bounded. No-ops for a project whose effective cap
304
+ is ``None`` (Enterprise unlimited). Idempotent: rank-based delete is safe to run
305
+ repeatedly.
306
+ """
307
+ _do_monte_carlo_run_purge()
308
+
309
+
310
+ def _do_monte_carlo_run_purge() -> None:
311
+ """Business logic for purge_old_monte_carlo_runs — extracted for testability.
312
+
313
+ Each project's retention cap is resolved per-workspace-effective (ADR-0144,
314
+ #1232) via ``resolve_effective_mc_history`` rather than the global
315
+ ``MC_HISTORY_CAP`` constant — a workspace/program/project may raise or lower it
316
+ (clamped to MC_HISTORY_HARD_CAP in the resolver). For each project exceeding its
317
+ own cap, delete every run older than its cap-th most recent. The candidate set
318
+ is the projects whose total run count exceeds the global hard cap floor, so the
319
+ common case (projects under the cap) does no delete work; the per-project cap is
320
+ then applied exactly. A project whose effective cap is None (Enterprise
321
+ unlimited) is skipped.
322
+ """
323
+ from django.db.models import Count
324
+
325
+ from trueppm_api.apps.scheduling.forecast_history_settings import resolve_effective_mc_history
326
+ from trueppm_api.apps.scheduling.models import MonteCarloRun
327
+
328
+ # Candidate projects: any with more than one run. We can't pre-filter by a single
329
+ # global cap because each project's effective cap may differ; resolving per
330
+ # project below is bounded by the (small) set of projects that actually have
331
+ # history.
332
+ project_ids = (
333
+ MonteCarloRun.objects.values("project_id")
334
+ .annotate(n=Count("id"))
335
+ .filter(n__gt=1)
336
+ .values_list("project_id", flat=True)
337
+ )
338
+
339
+ from trueppm_api.apps.projects.models import Project
340
+ from trueppm_api.apps.workspace.models import Workspace
341
+
342
+ # Load the workspace singleton once and thread it through every per-project
343
+ # resolve. Workspace.load() is a get_or_create round-trip (not memoized), so
344
+ # passing it in avoids a redundant workspace query per candidate project.
345
+ workspace = Workspace.load()
346
+
347
+ total_deleted = 0
348
+ for project_id in list(project_ids):
349
+ project = Project.objects.filter(pk=project_id).select_related("program").first()
350
+ if project is None:
351
+ continue
352
+ cap: int | None = resolve_effective_mc_history(
353
+ project, "mc_history_retention_cap", workspace=workspace
354
+ )
355
+ if cap is None:
356
+ continue # Enterprise unlimited: nothing to purge for this project.
357
+ # Find the taken_at of the cap-th most recent run; everything strictly
358
+ # older is surplus. Ordered by -taken_at so [cap - 1] is the boundary row.
359
+ boundary = list(
360
+ MonteCarloRun.objects.filter(project_id=project_id)
361
+ .order_by("-taken_at")
362
+ .values_list("taken_at", flat=True)[cap - 1 : cap]
363
+ )
364
+ if not boundary:
365
+ continue
366
+ deleted, _ = MonteCarloRun.objects.filter(
367
+ project_id=project_id, taken_at__lt=boundary[0]
368
+ ).delete()
369
+ total_deleted += deleted
370
+
371
+ if total_deleted:
372
+ logger.info("purge_old_monte_carlo_runs: deleted %d row(s)", total_deleted)
373
+
374
+
375
+ @idempotent_task(
376
+ lock_key_template="capture_daily_forecast_floor",
377
+ lock_ttl=120,
378
+ on_contention="skip",
379
+ soft_time_limit=110,
380
+ time_limit=150,
381
+ acks_late=True,
382
+ reject_on_worker_lost=True,
383
+ name="scheduling.capture_daily_forecast_floor",
384
+ )
385
+ def capture_daily_forecast_floor(self: object) -> None:
386
+ """Guarantee ≥1 forecast snapshot per active project per day (ADR-0154, #388).
387
+
388
+ Runs nightly at 00:30 UTC via Celery Beat. Captures a ``scheduled`` snapshot
389
+ for every non-deleted, non-archived project that has no snapshot in the last
390
+ 24 h — covering quiet days with no recompute, and acting as the durability
391
+ backstop that backfills any ``recompute`` capture missed by a broker blip or a
392
+ worker death between commit and on_commit. Idempotent: a project already
393
+ covered in the window is skipped.
394
+ """
395
+ _do_daily_forecast_floor()
396
+
397
+
398
+ def _do_daily_forecast_floor() -> None:
399
+ """Business logic for capture_daily_forecast_floor — extracted for testability."""
400
+ from django.utils import timezone
401
+
402
+ from trueppm_api.apps.projects.lifecycle import visible_projects
403
+ from trueppm_api.apps.projects.models import Project
404
+ from trueppm_api.apps.scheduling.models import ForecastSnapshotTrigger, ProjectForecastSnapshot
405
+ from trueppm_api.apps.scheduling.services import safe_capture_forecast_snapshot
406
+
407
+ cutoff = timezone.now() - timedelta(hours=24)
408
+ # Projects already covered in the window — skip them with a single query rather
409
+ # than letting the capture-path dedup absorb each one (dedup only skips when the
410
+ # forecast is *unchanged*; the floor's intent is one row/day regardless).
411
+ covered = set(
412
+ ProjectForecastSnapshot.objects.filter(captured_at__gte=cutoff)
413
+ .values_list("project_id", flat=True)
414
+ .distinct()
415
+ )
416
+ # Drafts are excluded (#2962/#3128). Two reasons, and the second is the one on
417
+ # the exclusion list: a forecast history for a plan nobody has committed to is
418
+ # noise by construction (the dates move because the plan is still being
419
+ # written), and every captured snapshot whose end date moved fires
420
+ # ``notify_project_end_date_shift`` at each ADMIN — a daily "your end date
421
+ # slipped" for a schedule the author is in the middle of typing. The commit
422
+ # moment captures baseline v1, which is where a draft's history should start.
423
+ # TODO(#4420): persisted cursor / per-item fan-out for sweeps that overrun.
424
+ project_ids = list(
425
+ visible_projects(Project.objects.filter(is_deleted=False, is_archived=False))
426
+ .order_by("id")
427
+ .values_list("id", flat=True)
428
+ )
429
+ captured = 0
430
+ for index, project_id in enumerate(project_ids):
431
+ if project_id in covered:
432
+ continue
433
+ try:
434
+ safe_capture_forecast_snapshot(project_id, ForecastSnapshotTrigger.SCHEDULED)
435
+ except SoftTimeLimitExceeded:
436
+ unreached = [pid for pid in project_ids[index:] if pid not in covered]
437
+ logger.warning(
438
+ "capture_daily_forecast_floor: soft time limit exceeded; %d project(s) "
439
+ "not processed (first: %s)",
440
+ len(unreached),
441
+ ", ".join(str(pid) for pid in unreached[:20]),
442
+ )
443
+ raise
444
+ captured += 1
445
+ if captured:
446
+ logger.info("capture_daily_forecast_floor: captured %d snapshot(s)", captured)
447
+
448
+
449
+ @idempotent_task(
450
+ lock_key_template="prune_forecast_snapshots",
451
+ lock_ttl=120,
452
+ on_contention="skip",
453
+ soft_time_limit=110,
454
+ time_limit=150,
455
+ acks_late=True,
456
+ reject_on_worker_lost=True,
457
+ name="scheduling.prune_forecast_snapshots",
458
+ )
459
+ def prune_forecast_snapshots(self: object) -> None:
460
+ """Apply the tiered retention curve to project forecast snapshots (ADR-0154, #388).
461
+
462
+ Runs nightly at 04:15 UTC via Celery Beat. Per ``settings.FORECAST_SNAPSHOT_RETENTION``:
463
+ keep all rows younger than ``daily_days`` (default 90); keep one-per-ISO-week up
464
+ to ``weekly_days`` (default 365); keep one-per-calendar-month beyond that (kept
465
+ forever). The same logic is exposed as the ``prune_forecast_snapshots`` management
466
+ command. Idempotent: re-running deletes nothing new.
467
+ """
468
+ _do_prune_forecast_snapshots()
469
+
470
+
471
+ def _snapshot_ids_to_keep(rows: Any, daily_cutoff: Any, weekly_cutoff: Any) -> set[uuid.UUID]:
472
+ """Pick the representative snapshot id to retain in each retention bucket.
473
+
474
+ ``rows`` are ``(id, captured_at)`` pairs ordered newest-first, so the first
475
+ row seen in each ISO-week / calendar-month bucket is the freshest and wins.
476
+ Every row inside the recent (daily) tier is kept.
477
+ """
478
+ keep: set[uuid.UUID] = set()
479
+ seen_weeks: set[tuple[int, int]] = set()
480
+ seen_months: set[tuple[int, int]] = set()
481
+ for row_id, captured_at in rows:
482
+ if captured_at >= daily_cutoff:
483
+ keep.add(row_id) # Recent tier: keep every row.
484
+ elif captured_at >= weekly_cutoff:
485
+ iso = captured_at.isocalendar()
486
+ key = (iso[0], iso[1])
487
+ if key not in seen_weeks:
488
+ seen_weeks.add(key)
489
+ keep.add(row_id) # Newest row in this ISO week.
490
+ else:
491
+ key = (captured_at.year, captured_at.month)
492
+ if key not in seen_months:
493
+ seen_months.add(key)
494
+ keep.add(row_id) # Newest row in this calendar month.
495
+ return keep
496
+
497
+
498
+ def _do_prune_forecast_snapshots() -> int:
499
+ """Business logic for prune_forecast_snapshots — extracted for testability.
500
+
501
+ Returns the number of rows deleted. Scans each project's snapshots newest-first
502
+ and keeps the first (newest) row in each retention bucket, so the freshest
503
+ representative per day/week/month survives.
504
+ """
505
+ from django.conf import settings
506
+ from django.utils import timezone
507
+
508
+ from trueppm_api.apps.scheduling.models import ProjectForecastSnapshot
509
+
510
+ policy = getattr(settings, "FORECAST_SNAPSHOT_RETENTION", {})
511
+ daily_days = int(policy.get("daily_days", 90))
512
+ weekly_days = int(policy.get("weekly_days", 365))
513
+
514
+ now = timezone.now()
515
+ daily_cutoff = now - timedelta(days=daily_days)
516
+ weekly_cutoff = now - timedelta(days=weekly_days)
517
+
518
+ project_ids = ProjectForecastSnapshot.objects.values_list("project_id", flat=True).distinct()
519
+
520
+ total_deleted = 0
521
+ for project_id in list(project_ids):
522
+ rows = (
523
+ ProjectForecastSnapshot.objects.filter(project_id=project_id)
524
+ .order_by("-captured_at")
525
+ .values_list("id", "captured_at")
526
+ )
527
+ keep = _snapshot_ids_to_keep(rows, daily_cutoff, weekly_cutoff)
528
+
529
+ deleted, _ = (
530
+ ProjectForecastSnapshot.objects.filter(project_id=project_id)
531
+ .exclude(id__in=keep)
532
+ .delete()
533
+ )
534
+ total_deleted += deleted
535
+
536
+ if total_deleted:
537
+ logger.info("prune_forecast_snapshots: deleted %d row(s)", total_deleted)
538
+ return total_deleted
539
+
540
+
541
+ def _dead_letter_current(task: object, project_id: str, exc: BaseException) -> None:
542
+ """Write a FailedTask record for the current task invocation."""
543
+ from trueppm_api.apps.scheduling.deadletter import record_failed_task
544
+
545
+ request = task.request # type: ignore[attr-defined]
546
+ record_failed_task(
547
+ task_name=getattr(task, "name", "unknown"),
548
+ task_id=request.id or "unknown",
549
+ args=[project_id],
550
+ kwargs={},
551
+ exception=exc,
552
+ project_id=project_id,
553
+ )
554
+
555
+
556
+ _WRITEBACK_BATCH_SIZE = 500
557
+ """Rows per ``bulk_update`` statement when persisting CPM results (#1529).
558
+
559
+ On PostgreSQL Django's ``bulk_batch_size`` defaults to ``len(objs)``, so an
560
+ unbatched write collapses every task into a single UPDATE built from per-field
561
+ ``CASE WHEN pk=… THEN …`` chains (~17 params/row → ~85K interpolated values at 5K
562
+ tasks). psycopg3's client-side binding keeps this under the 65,535 protocol
563
+ ceiling, but the multi-MB SQL string is slow to build, parse, and plan and lands
564
+ on the <500 ms schedule-trigger budget. Batching bounds each statement's size
565
+ without changing the result — the whole writeback still runs in one transaction."""
566
+
567
+ CPM_DELTA_BROADCAST_CAP = 500
568
+ """Max moved-task count to ship as per-task ``task_dates_updated`` deltas (ADR-0091).
569
+
570
+ Above this the WS frame grows large enough that a client-side full re-fetch is cheaper
571
+ than splicing, so we emit a ``truncated`` signal instead and let the client invalidate.
572
+ A delta row serializes to ≈264 bytes, so a 500-row payload is ≈130 KB.
573
+
574
+ The cap counts the *moved* set, not the write-back set (#2573). A recalc always
575
+ re-assigns every engine-returned row, so counting the write-back set put every
576
+ project above ~500 tasks permanently into the truncated branch and made ADR-0091's
577
+ splice path unreachable at the documented envelope.
578
+
579
+ Sized against the moved subset the truncated branch is reachable only when the recalc
580
+ genuinely moved that many rows. Note that the backward pass seeds every task's late
581
+ dates from the project finish, so **any edit that shifts the project finish moves the
582
+ whole network's late dates and floats** — a critical-path edit therefore still
583
+ truncates on a large project even though few bars actually move. Narrowing that case
584
+ (splitting early dates from late/float churn, or re-sizing the cap) is tracked in
585
+ #2598; the other wide cases are a first-ever pass, a calendar change, and a
586
+ project-start shift.
587
+ """
588
+
589
+
590
+ # Per-task schedule-shift activity events (ADR-0207, #1604). The CPM writeback
591
+ # persists only the history-excluded early_*/late_* fields via bulk_update, so a
592
+ # recompute leaves no per-task audit row; these helpers emit one instead.
593
+ def _active_baseline_finishes(project_ids: list[Any]) -> dict[str, BaselineFinish]:
594
+ """Return ``{task_id: (baseline_id, baseline_finish, at_day_start)}`` per active baseline.
595
+
596
+ One entry per snapshotted task with a non-null finish, drawn from the single
597
+ active baseline per project (enforced by a DB unique constraint). Two indexed
598
+ queries; empty and cheap when no project in ``project_ids`` has an active
599
+ baseline, so the drift check adds no cost to the common no-baseline recalc.
600
+ ``at_day_start`` is ``BaselineTask.finish_at_day_start`` (#4197) — which edge
601
+ of the day the baselined finish sat on, ``None`` for a row captured before
602
+ that was recorded.
603
+ """
604
+ from trueppm_api.apps.projects.models import Baseline, BaselineTask
605
+
606
+ baseline_ids = list(
607
+ Baseline.objects.filter(
608
+ project_id__in=project_ids, is_active=True, is_deleted=False
609
+ ).values_list("id", flat=True)
610
+ )
611
+ if not baseline_ids:
612
+ return {}
613
+ finishes: dict[str, BaselineFinish] = {}
614
+ for row in BaselineTask.objects.filter(
615
+ baseline_id__in=baseline_ids, finish__isnull=False
616
+ ).values("task_id", "finish", "finish_at_day_start", "baseline_id"):
617
+ finish = row["finish"]
618
+ if finish is None: # narrows the nullable DateField for mypy; excluded by the filter
619
+ continue
620
+ finishes[str(row["task_id"])] = (
621
+ str(row["baseline_id"]),
622
+ finish,
623
+ row["finish_at_day_start"],
624
+ )
625
+ return finishes
626
+
627
+
628
+ def _iso_or_none(value: Any) -> str | None:
629
+ return value.isoformat() if value is not None else None
630
+
631
+
632
+ def _cpm_dates_changed(old: tuple[Any, Any, Any, Any], t: Any) -> bool:
633
+ """True when any of a task's four CPM dates differ from its pre-writeback snapshot."""
634
+ old_es, old_ef, old_ls, old_lf = old
635
+ return bool(
636
+ old_es != t.early_start
637
+ or old_ef != t.early_finish
638
+ or old_ls != t.late_start
639
+ or old_lf != t.late_finish
640
+ )
641
+
642
+
643
+ def _recalc_project_aggregates(
644
+ tasks_to_update: list[Any],
645
+ old_dates: dict[str, tuple[Any, Any, Any, Any]],
646
+ old_day_end: Mapping[str, bool] | None = None,
647
+ ) -> tuple[dict[Any, int], dict[Any, Finish], dict[Any, Finish]]:
648
+ """Per-project recalc summary denormalized onto every ``cpm_recalculated`` row (#1948).
649
+
650
+ Returns ``(moved_by_project, new_finish_by_project, prior_finish_by_project)``,
651
+ each finish a ``(day, at_day_start)`` pair (:mod:`.finish_reading`, #4178) so
652
+ the delta can be measured in working time. ``old_day_end`` is each task's
653
+ ``milestone_at_day_end`` before the writeback overwrote it; a task missing
654
+ from it is read with its current flag. The reading depends on nothing else
655
+ (:func:`.finish_reading.task_finish_at_day_start` reads no actuals since
656
+ #4433), so the prior reading is exact.
657
+
658
+ WHY grouped strictly per ``project_id`` and not globally: this feeds a helper
659
+ shared by the program-scoped writeback, where ``tasks_to_update`` spans several
660
+ member projects in one call. A single program-wide count stamped onto every row
661
+ would leak one project's schedule scope onto another project's activity row and
662
+ violate the OSS project-isolation boundary (enterprise-check constraint #1) — so
663
+ each row carries only *its own* project's aggregate.
664
+ """
665
+ moved_by_project: dict[Any, int] = {}
666
+ new_rows: dict[Any, list[tuple[Any, bool, Any]]] = {}
667
+ prior_rows: dict[Any, list[tuple[Any, bool, Any]]] = {}
668
+ for t in tasks_to_update:
669
+ pid = t.project_id
670
+ wbs_path = getattr(t, "wbs_path", None)
671
+ # New finish: latest early_finish across every task in this project,
672
+ # moved or not (an unmoved late task still defines where finish sits).
673
+ new_rows.setdefault(pid, []).append((t.early_finish, task_finish_at_day_start(t), wbs_path))
674
+ task_key = str(t.id)
675
+ old = old_dates.get(task_key)
676
+ if old is None:
677
+ continue
678
+ prior_day_end = old_day_end.get(task_key) if old_day_end is not None else None
679
+ prior_rows.setdefault(pid, []).append(
680
+ (
681
+ old[1],
682
+ task_finish_at_day_start(t, milestone_at_day_end=prior_day_end),
683
+ wbs_path,
684
+ )
685
+ )
686
+ if _cpm_dates_changed(old, t):
687
+ moved_by_project[pid] = moved_by_project.get(pid, 0) + 1
688
+ new_finish_by_project = {
689
+ pid: f for pid, rows in new_rows.items() if (f := latest_finish(rows)) is not None
690
+ }
691
+ prior_finish_by_project = {
692
+ pid: f for pid, rows in prior_rows.items() if (f := latest_finish(rows)) is not None
693
+ }
694
+ return moved_by_project, new_finish_by_project, prior_finish_by_project
695
+
696
+
697
+ def _cpm_recalculated_event(
698
+ t: Any,
699
+ old: tuple[Any, Any, Any, Any],
700
+ moved_by_project: dict[Any, int],
701
+ new_finish_by_project: dict[Any, Finish],
702
+ prior_finish_by_project: dict[Any, Finish],
703
+ calendars: Mapping[str, Any] | None = None,
704
+ ) -> Any:
705
+ """Build a ``cpm_recalculated`` activity row for a task whose CPM dates moved.
706
+
707
+ ``recalc_finish_delta_days`` is measured in working time on the project's own
708
+ calendar (:func:`.finish_reading.finish_shift_days`, #4178): a finish whose
709
+ shown day hops a weekend without moving in working time reports 0, not 3.
710
+ With no calendar for the project it falls back to the shown-day difference.
711
+ """
712
+ from trueppm_api.apps.projects.models import TaskActivityEvent
713
+
714
+ old_es, old_ef, old_ls, old_lf = old
715
+ new_f = new_finish_by_project.get(t.project_id)
716
+ prior_f = prior_finish_by_project.get(t.project_id)
717
+ # Signed day delta of the project finish (+ = slip later, - = pulled in).
718
+ # None when either side is missing — most importantly the first-ever recalc,
719
+ # where no prior early_finish exists for any task.
720
+ cal = calendars.get(str(t.project_id)) if calendars is not None else None
721
+ delta = (
722
+ finish_shift_days(prior_f, new_f, cal)
723
+ if new_f is not None and prior_f is not None
724
+ else None
725
+ )
726
+ return TaskActivityEvent(
727
+ task_id=t.id,
728
+ actor=None,
729
+ event_type="cpm_recalculated",
730
+ detail={
731
+ "early_start": {"from": _iso_or_none(old_es), "to": _iso_or_none(t.early_start)},
732
+ "early_finish": {"from": _iso_or_none(old_ef), "to": _iso_or_none(t.early_finish)},
733
+ "late_start": {"from": _iso_or_none(old_ls), "to": _iso_or_none(t.late_start)},
734
+ "late_finish": {"from": _iso_or_none(old_lf), "to": _iso_or_none(t.late_finish)},
735
+ "total_float": t.total_float,
736
+ "is_critical": t.is_critical,
737
+ # Per-project recalc summary (#1948) — see _recalc_project_aggregates.
738
+ "recalc_moved_count": moved_by_project.get(t.project_id, 0),
739
+ "recalc_finish": _iso_or_none(new_f[0] if new_f is not None else None),
740
+ "recalc_finish_delta_days": delta,
741
+ },
742
+ )
743
+
744
+
745
+ def _baseline_drift_event(
746
+ t: Any,
747
+ old_ef: Any,
748
+ baseline: BaselineFinish,
749
+ *,
750
+ prior_day_end: bool | None = None,
751
+ calendar: Any | None = None,
752
+ ) -> Any | None:
753
+ """Build a ``baseline_drift_detected`` row, but only on the transition *into* drift.
754
+
755
+ Emitted only when a task was within its baseline finish and is now past it, so a
756
+ persistently-drifted task does not re-fire every recalc. Returns ``None`` otherwise.
757
+
758
+ Drift is measured in working time (:func:`.finish_reading.finish_shift_days`,
759
+ #4197), not by the shown day: a start-of-day milestone whose shown day hops a
760
+ weekend relative to its baseline — the end of Friday against the start of the
761
+ next Monday — has not drifted, and ``drift_days`` stays a calendar-day count.
762
+ ``prior_day_end`` is the task's ``milestone_at_day_end`` before this pass
763
+ overwrote it (``None`` reads the current flag); ``calendar`` is the project's
764
+ composed scheduler calendar, and without one the comparison falls back to the
765
+ shown-day difference.
766
+ """
767
+ baseline_id, baseline_finish, baseline_at_start = baseline
768
+ base: Finish = (baseline_finish, baseline_at_start)
769
+
770
+ def drift(finish: Any, at_start: bool) -> int | None:
771
+ if finish is None:
772
+ return None
773
+ return finish_shift_days(base, (finish, at_start), calendar)
774
+
775
+ old_drift = drift(old_ef, task_finish_at_day_start(t, milestone_at_day_end=prior_day_end))
776
+ new_drift = drift(t.early_finish, task_finish_at_day_start(t))
777
+ was_drifted = old_drift is not None and old_drift > 0
778
+ is_drifted = new_drift is not None and new_drift > 0
779
+ if not (is_drifted and not was_drifted):
780
+ return None
781
+
782
+ from trueppm_api.apps.projects.models import TaskActivityEvent
783
+
784
+ return TaskActivityEvent(
785
+ task_id=t.id,
786
+ actor=None,
787
+ event_type="baseline_drift_detected",
788
+ detail={
789
+ "baseline_id": baseline_id,
790
+ "baseline_finish": baseline_finish.isoformat(),
791
+ "early_finish": t.early_finish.isoformat(),
792
+ "drift_days": new_drift,
793
+ },
794
+ )
795
+
796
+
797
+ def _build_schedule_shift_events(
798
+ tasks_to_update: list[Any],
799
+ old_dates: dict[str, tuple[Any, Any, Any, Any]],
800
+ baseline_finishes: dict[str, BaselineFinish],
801
+ *,
802
+ suppress_movement_events: bool = False,
803
+ old_day_end: Mapping[str, bool] | None = None,
804
+ calendars: Mapping[str, Any] | None = None,
805
+ ) -> list[Any]:
806
+ """Build ``TaskActivityEvent`` rows for CPM recomputes and baseline-drift crossings.
807
+
808
+ ``old_dates`` maps a task id to its ``(early_start, early_finish, late_start,
809
+ late_finish)`` captured *before* the writeback loop overwrote the in-memory Task
810
+ fields, so a move is detected by comparing against the now-mutated values. A
811
+ ``cpm_recalculated`` row is emitted for every task whose four CPM dates changed;
812
+ a ``baseline_drift_detected`` row is emitted only on the transition *into* drift
813
+ (was within baseline, now past it) so a persistently-drifted task does not
814
+ re-fire every recalc. Actor is null — CPM runs in Celery with no request user.
815
+
816
+ Each ``cpm_recalculated`` row also carries a per-project recalc summary
817
+ (#1948): ``recalc_moved_count`` (how many of the project's tasks moved),
818
+ ``recalc_finish`` (the project's latest early_finish after the pass), and
819
+ ``recalc_finish_delta_days`` (signed slip/pull-in of that finish). These are
820
+ grouped strictly per ``project_id`` because the helper is shared by the
821
+ program-scoped writeback, where a program-wide count would leak cross-project
822
+ scope (see :func:`_recalc_project_aggregates`).
823
+
824
+ ``suppress_movement_events`` (ADR-0752 §7) skips the ``cpm_recalculated`` rows
825
+ specifically — the one-shot recalculation that arms a project's status_date
826
+ floor for the first time moves dates as an engine correction, not a user
827
+ action, and must not be recorded as ordinary per-task movement. This is a
828
+ feed-persistence half of the suppression; the caller must also withhold the
829
+ matching ADR-0091 ``task_dates_updated`` broadcast, or the entries reappear on
830
+ that surface instead (ADR-0752 §10). ``baseline_drift_detected`` is left
831
+ unsuppressed — it names an outcome (drift vs. plan), not a change attributed
832
+ to an actor, and is exactly the kind of information the echoed status_date
833
+ exists to make sense of.
834
+ """
835
+ moved_by_project, new_finish_by_project, prior_finish_by_project = _recalc_project_aggregates(
836
+ tasks_to_update, old_dates, old_day_end
837
+ )
838
+
839
+ events: list[Any] = []
840
+ for t in tasks_to_update:
841
+ old = old_dates.get(str(t.id))
842
+ if old is None:
843
+ continue
844
+ if _cpm_dates_changed(old, t) and not suppress_movement_events:
845
+ events.append(
846
+ _cpm_recalculated_event(
847
+ t,
848
+ old,
849
+ moved_by_project,
850
+ new_finish_by_project,
851
+ prior_finish_by_project,
852
+ calendars,
853
+ )
854
+ )
855
+ baseline = baseline_finishes.get(str(t.id))
856
+ if baseline is not None:
857
+ drift = _baseline_drift_event(
858
+ t,
859
+ old[1],
860
+ baseline,
861
+ prior_day_end=old_day_end.get(str(t.id)) if old_day_end is not None else None,
862
+ calendar=calendars.get(str(t.project_id)) if calendars is not None else None,
863
+ )
864
+ if drift is not None:
865
+ events.append(drift)
866
+ return events
867
+
868
+
869
+ _CPM_DELTA_FIELDS: tuple[str, ...] = (
870
+ "early_start",
871
+ "early_finish",
872
+ "late_start",
873
+ "late_finish",
874
+ "total_float",
875
+ "free_float",
876
+ "is_critical",
877
+ "duration",
878
+ # scheduled_start (ADR-0752): the task's span start, distinct from
879
+ # early_start's remaining-work window. Same write-back, same broadcast —
880
+ # a client rendering the span bar needs to know it moved too.
881
+ "scheduled_start",
882
+ # #4079: a milestone flipping between the start and the end of its day moves
883
+ # the diamond without moving any date, so it must count as a move.
884
+ "milestone_at_day_end",
885
+ )
886
+ """Every CPM-owned field a ``task_dates_updated`` delta can carry (ADR-0091, #2573).
887
+
888
+ Deliberately the same set the write-back persists, so "this row changed" and "this
889
+ row needs to ship in the delta" are the same question. ``planned_start`` also rides
890
+ in the payload but is a user-owned field the CPM write-back never touches, so it
891
+ cannot make a row moved.
892
+ """
893
+
894
+
895
+ _cpm_delta_snapshot = operator.attrgetter(*_CPM_DELTA_FIELDS)
896
+ """Snapshot the broadcast-relevant CPM fields of a Task for a moved/unmoved diff.
897
+
898
+ ``attrgetter`` rather than a ``getattr`` generator: this runs twice per task inside
899
+ the ``_apply_cpm_results`` hot loop, and the C-level multi-attribute form is ~4x
900
+ faster than the Python-level loop (0.19 µs vs 0.85 µs per call), which at 5,000
901
+ tasks is ~8 ms → ~2 ms. Every name is a concrete column on ``Task`` and neither CPM
902
+ load path defers columns, so no access can trigger a deferred-field query.
903
+ """
904
+
905
+
906
+ def summary_working_day_durations(
907
+ result_map: dict[str, Any],
908
+ summary_ids: set[str],
909
+ db_task_by_id: dict[str, Any],
910
+ calendar_by_project: Mapping[str, Any],
911
+ ) -> dict[str, int]:
912
+ """Working-day duration for each rolled-up summary row (#3530).
913
+
914
+ ``Task.duration`` is documented and consumed everywhere as **working days**
915
+ (``help_text="Duration in working days"``; the MSPDI exporter multiplies it by
916
+ ``HOURS_PER_WORKING_DAY``, ``project_span_days`` sums it, and
917
+ ``build_sched_tasks`` feeds it straight back to the engine as
918
+ ``timedelta(days=…)``). The write-back used to store the raw **calendar-day**
919
+ span ``(early_finish - early_start).days`` on summary rows instead, justified by
920
+ a comment asserting nothing read a summary's duration. That was locally true of
921
+ the leaf CPM pass and globally false — a ~1.4x inflation reached every consumer
922
+ above, and 49 of 50 summary rows in the dev database carried it.
923
+
924
+ So: count working days in the summary's rolled-up span, **inclusive of both
925
+ endpoints**, which is the engine's own duration convention
926
+ (``_finish_from_start(start, 1) == start``). The counter answers the half-open
927
+ ``[start, end)``, so the finish day is added back — conditionally; see the
928
+ comment on that line for the weekend-``actual_finish`` case it guards.
929
+
930
+ WHY a batched pre-pass rather than a call inside the write-back loop: the scalar
931
+ counter is an O(span) day walk, so per-summary calls are O(summaries · span) —
932
+ a deeply nested WBS re-walks the whole project span once per level.
933
+ :class:`_WorkingDayCounter` (#822) pays O(span) once per calendar and answers
934
+ each span with two binary searches. Measured: one ``build`` over a 2-year span
935
+ costs about the same as one scalar walk of it (0.24 ms, 0.35 ms with 25 calendar
936
+ exceptions) and each ``between`` is 1.4 µs, so break-even is under two summaries
937
+ — and a project's root summary alone already spans the hull. At 50 summaries in
938
+ a 2-year project the batched form is ~0.35 ms against up to ~11 ms.
939
+
940
+ Be honest about the other comparison: what this replaced was an O(1) timedelta
941
+ subtraction, so the correct answer costs ~0.24–1.4 ms per project per recompute
942
+ where the wrong one cost nothing. Working days cannot be derived without walking
943
+ a calendar, and that is <0.3% of the recompute budget.
944
+
945
+ ``schedule()`` builds a counter of its own for float computation, which this
946
+ duplicates on the single-project path. It is local to that function and never
947
+ returned, and the program pass needs one counter *per member calendar* anyway
948
+ (ADR-0120 D3), so reusing it would mean widening the PyPI package's public
949
+ surface for ~1 ms. Left duplicated deliberately.
950
+
951
+ Args:
952
+ result_map: Engine rows keyed by task id, **after** ``apply_summary_rollups``
953
+ has added the synthetic summary rows.
954
+ summary_ids: Task ids that have at least one WBS child.
955
+ db_task_by_id: Django ``Task`` rows keyed by ``str(id)`` — read only for
956
+ each summary's ``project_id``, to pick its calendar.
957
+ calendar_by_project: Composed engine ``Calendar`` per ``str(project_id)``.
958
+ The program pass holds one per member project (ADR-0120 D3), so a
959
+ summary is always measured on its own project's working week.
960
+
961
+ Returns:
962
+ ``{summary_id: working_day_duration}``, omitting any summary the engine did
963
+ not roll up (no scheduled leaves) or whose project has no calendar.
964
+ """
965
+ # Private engine symbols, same boundary crossing `scheduling.services` already
966
+ # makes for `_collect_leaves`. `trueppm-scheduler` ships to PyPI on its own
967
+ # semver, so this couples the API to unexported internals: a change to either
968
+ # one's signature or complexity breaks the paragraph above, not just this call.
969
+ # The alternative — a public working-day-count export — is a public-surface
970
+ # change to an Apache 2.0 package and belongs in that package's own MR.
971
+ from trueppm_scheduler.engine import _WorkingDayCounter
972
+
973
+ # One pass: collect each summary's span and, per project, the enclosing range
974
+ # its counter must cover. Grouped as we go rather than re-scanning `spans` per
975
+ # project — a program pass holds many member projects and this would otherwise
976
+ # be O(projects · summaries).
977
+ spans: dict[str, tuple[str, Any, Any]] = {}
978
+ ranges: dict[str, tuple[Any, Any]] = {}
979
+ for sid in summary_ids:
980
+ sched = result_map.get(sid)
981
+ db_task = db_task_by_id.get(sid)
982
+ if sched is None or db_task is None:
983
+ continue
984
+ if sched.early_start is None or sched.early_finish is None:
985
+ continue
986
+ project_key = str(db_task.project_id)
987
+ spans[sid] = (project_key, sched.early_start, sched.early_finish)
988
+ lo, hi = ranges.get(project_key, (sched.early_start, sched.early_finish))
989
+ ranges[project_key] = (
990
+ min(lo, sched.early_start),
991
+ max(hi, sched.early_finish),
992
+ )
993
+
994
+ # One counter per calendar, covering that project's full summary date range.
995
+ # Paired with the calendar itself: the closed-interval correction below needs
996
+ # `is_working_day`, which the counter does not expose.
997
+ counters: dict[str, tuple[Any, Any]] = {}
998
+ for project_key, (lo, hi) in ranges.items():
999
+ cal = calendar_by_project.get(project_key)
1000
+ if cal is not None:
1001
+ counters[project_key] = (_WorkingDayCounter.build(lo, hi, cal), cal)
1002
+
1003
+ durations: dict[str, int] = {}
1004
+ for sid, (project_key, early_start, early_finish) in spans.items():
1005
+ entry = counters.get(project_key)
1006
+ if entry is None:
1007
+ # No calendar for this summary's project — leave its stored duration
1008
+ # alone rather than write a value measured on the wrong working week.
1009
+ continue
1010
+ counter, cal = entry
1011
+ # `between` counts the HALF-OPEN [early_start, early_finish); a duration is
1012
+ # INCLUSIVE of its finish day, so add the finish back — but only if it is
1013
+ # itself a working day. It need not be: a completed task pinned to a weekend
1014
+ # `actual_finish` (ADR-0136) contributes a non-working `early_finish` to the
1015
+ # rollup's max, and the engine says so in `_gather_successor_constraints`'
1016
+ # own docstring. Adding an unconditional 1 there would report a phase as a
1017
+ # day longer than the work inside it.
1018
+ working = counter.between(early_start, early_finish)
1019
+ if cal.is_working_day(early_finish):
1020
+ working += 1
1021
+ # Floor at 1: a summary whose whole span collapsed onto non-working days
1022
+ # still occupied a day of the plan, and 0 reads as a milestone.
1023
+ durations[sid] = max(1, working)
1024
+ return durations
1025
+
1026
+
1027
+ def _apply_cpm_results(
1028
+ db_tasks: Iterable[Any],
1029
+ result_map: dict[str, Any],
1030
+ summary_durations: Mapping[str, int],
1031
+ ) -> tuple[list[Any], dict[str, tuple[Any, Any, Any, Any]], list[Any], dict[str, bool]]:
1032
+ """Write engine CPM results onto Task rows in memory.
1033
+
1034
+ Returns ``(tasks_to_update, old_cpm_dates, moved_tasks, old_day_end)``.
1035
+
1036
+ ``old_day_end`` is each task's ``milestone_at_day_end`` before the overwrite,
1037
+ which the shift-events helper needs to read the *prior* project finish in
1038
+ working time rather than by its shown day (#4178).
1039
+
1040
+ ``old_cpm_dates`` snapshots each task's four CPM dates *before* they are
1041
+ overwritten, so :func:`_build_schedule_shift_events` can tell which tasks
1042
+ actually moved (ADR-0207).
1043
+
1044
+ ``moved_tasks`` is the subset of ``tasks_to_update`` whose broadcast-relevant
1045
+ CPM fields (:data:`_CPM_DELTA_FIELDS`) actually changed in this pass. WHY it is
1046
+ computed here and not by the caller: a CPM run is a *full* write-back — every
1047
+ engine-returned row is re-assigned whether or not its value moved — so the only
1048
+ place the pre-write value still exists is inside this loop. Broadcasting the
1049
+ full write-back set instead of the moved subset put every project above 500
1050
+ tasks permanently into the ``truncated`` branch, which made each connected
1051
+ client re-fetch the whole paginated task list on every single-field edit and
1052
+ left ADR-0091's splice path effectively dead (#2573). ``tasks_to_update``
1053
+ itself stays the full set — the write-back and the ADR-0207 activity events
1054
+ must still see every row.
1055
+
1056
+ INTENTIONAL DESIGN: the caller persists these via ``bulk_update``, which
1057
+ bypasses ``VersionedModel.save()`` so ``server_version`` is NOT incremented —
1058
+ CPM fields (early_start, is_critical, etc.) are read-only computed values the
1059
+ mobile client derives locally, and bumping server_version here would flood
1060
+ every connected client with sync deltas on every recalc. Do NOT change the
1061
+ call sites to ``save()`` without understanding that consequence.
1062
+ """
1063
+ tasks_to_update: list[Any] = []
1064
+ old_cpm_dates: dict[str, tuple[Any, Any, Any, Any]] = {}
1065
+ old_day_end: dict[str, bool] = {}
1066
+ moved_tasks: list[Any] = []
1067
+ for db_task in db_tasks:
1068
+ # One `str(UUID)` for the row, reused by all three lookups below. Same
1069
+ # rationale as the `attrgetter` snapshot above: this loop runs once per task
1070
+ # and the conversion is not free at 5,000 rows.
1071
+ task_key = str(db_task.id)
1072
+ sched = result_map.get(task_key)
1073
+ if sched is None:
1074
+ continue
1075
+ old_cpm_dates[task_key] = (
1076
+ db_task.early_start,
1077
+ db_task.early_finish,
1078
+ db_task.late_start,
1079
+ db_task.late_finish,
1080
+ )
1081
+ old_day_end[task_key] = bool(db_task.milestone_at_day_end)
1082
+ before = _cpm_delta_snapshot(db_task)
1083
+ db_task.early_start = sched.early_start
1084
+ db_task.early_finish = sched.early_finish
1085
+ db_task.late_start = sched.late_start
1086
+ db_task.late_finish = sched.late_finish
1087
+ # `is not None`, NOT truthiness: bool(timedelta(0)) is False, so a truthy
1088
+ # test writes NULL for zero float — the defining value of every
1089
+ # critical-path task. NULL means "not scheduled"; 0 means "no slack", and
1090
+ # collapsing the two blanked the Float cell on exactly the tasks a PM
1091
+ # inspects and made capture_forecast_snapshot's Min("total_float") skip
1092
+ # them, reporting the project's tightest slack as the smallest *positive*
1093
+ # float on the plan (#2802).
1094
+ db_task.total_float = sched.total_float.days if sched.total_float is not None else None
1095
+ db_task.free_float = sched.free_float.days if sched.free_float is not None else None
1096
+ db_task.is_critical = sched.is_critical
1097
+ # ADR-0752: the task's span start, distinct from early_start's
1098
+ # remaining-work window.
1099
+ db_task.scheduled_start = sched.scheduled_start
1100
+ # #4079: which edge of its shown day a milestone sits on; always False for
1101
+ # work and for a summary's synthetic row.
1102
+ db_task.milestone_at_day_end = bool(getattr(sched, "milestone_at_day_end", False))
1103
+ # Belt-and-suspenders: milestones are single-point gates. Even if the
1104
+ # boundary normalisation is bypassed, a milestone's finish must equal its
1105
+ # start so client-facing rows never render a date range.
1106
+ #
1107
+ # Collapse onto the engine's early_finish, not its early_start (#4525). The
1108
+ # two differ only for a closed milestone with an actual_start on an earlier
1109
+ # day than its actual_finish (one that passed through In Progress): the
1110
+ # engine reports that pair as its pinned window but places the instant —
1111
+ # and measures its successors — from the sign-off day, actual_finish. That
1112
+ # is also the day build_sched_tasks hands back as live_day_end_on, which
1113
+ # the engine compares against actual_finish; persisting actual_start there
1114
+ # released a closed milestone's frozen edge on the next recalculation and
1115
+ # moved its successors with no change by anyone.
1116
+ if db_task.is_milestone:
1117
+ db_task.early_start = db_task.early_finish
1118
+ db_task.late_finish = db_task.late_start
1119
+ # For summary tasks, overwrite duration with the WORKING-day span of the
1120
+ # rolled-up window, so the stored value means what the column documents and
1121
+ # what every consumer of it assumes (#3530). Precomputed by
1122
+ # `summary_working_day_durations` — the calendar walk is batched per
1123
+ # calendar rather than run inside this hot loop. `summary_durations` is keyed
1124
+ # exclusively by summary id, so the lookup IS the summary test; a leaf misses
1125
+ # it, and so does a summary the engine did not roll up (which keeps its
1126
+ # stored duration rather than getting a wrong one).
1127
+ new_duration = summary_durations.get(task_key)
1128
+ if new_duration is not None:
1129
+ db_task.duration = new_duration
1130
+ tasks_to_update.append(db_task)
1131
+ if _cpm_delta_snapshot(db_task) != before:
1132
+ moved_tasks.append(db_task)
1133
+ return tasks_to_update, old_cpm_dates, moved_tasks, old_day_end
1134
+
1135
+
1136
+ _CPM_WRITE_FIELDS: tuple[str, ...] = (
1137
+ "early_start",
1138
+ "early_finish",
1139
+ "late_start",
1140
+ "late_finish",
1141
+ "total_float",
1142
+ "free_float",
1143
+ "is_critical",
1144
+ "scheduled_start",
1145
+ "milestone_at_day_end",
1146
+ )
1147
+ """Columns the CPM write-back persists on EVERY scheduled row, both passes.
1148
+
1149
+ Every entry is an engine OUTPUT: read-only on ``TaskSerializer`` and recomputed from
1150
+ the inputs on each pass. The write-back is a T0-snapshot/T1-write with no row lock and
1151
+ no ``server_version`` predicate, so a column listed here is written with whatever the
1152
+ recalc loaded — and that is only safe for a value the next recalc rederives. An edit
1153
+ that races the recalc enqueues that next recalc, so a stale output is transient.
1154
+
1155
+ ``duration`` is deliberately NOT here (#4371). On a leaf it is a user INPUT the engine
1156
+ never computes: writing it back reverted a PATCH committed mid-recalc, the follow-up
1157
+ recalc then read the reverted value as truth, and ``bulk_update`` allocates no
1158
+ ``sync_seq``, so no client ever learned of it. Summary durations, which the engine
1159
+ does compute (#3530), go through a separate ``duration``-only write restricted to
1160
+ summary rows — see :func:`_bulk_write_cpm_results`. Any narrowing of the write-back to
1161
+ rows that moved (#4096) must keep that split.
1162
+ """
1163
+
1164
+
1165
+ def _ids_with_live_child(task_model: Any, candidates: list[Any]) -> set[str]:
1166
+ """Ids of ``candidates`` that have at least one live direct WBS child right now.
1167
+
1168
+ "Direct child" is the same test :func:`build_children_map` used to make these rows
1169
+ summaries at load time, so a row drops out only if its children are gone.
1170
+
1171
+ After re-reading the candidates' current paths: one query per chunk, matched with
1172
+ an ltree ``lquery[]`` (``<path>.*{1}`` per candidate) and scoped by
1173
+ ``project_id`` so the planner reads only the affected projects' rows through the
1174
+ project index. The two obvious alternatives are slower
1175
+ on a 5,100-task project with 300 summaries, measured with ``EXPLAIN ANALYZE``: a
1176
+ correlated ``EXISTS`` with ``wbs_path LIKE parent || '.%'`` casts to text (101 ms),
1177
+ and the same ``EXISTS`` with ltree ``<@`` is decorrelated into a hash semi-join on
1178
+ ``project_id`` that rechecks every pair (30 ms); this form ran in 9 ms. It runs
1179
+ while the write-back holds row locks on every touched row, so it must stay cheap.
1180
+ Paths are bound as parameters; the ltree type already restricts labels to
1181
+ characters that carry no lquery meaning.
1182
+ """
1183
+ from django.db import connections
1184
+
1185
+ # Key on the CURRENT path, not the load-time one on the instance: a row re-pathed
1186
+ # mid-recalc must be judged by the children it has now, at the place it is now.
1187
+ by_key = {
1188
+ (str(project_id), str(wbs_path)): str(pk)
1189
+ for pk, project_id, wbs_path in task_model.objects.filter(
1190
+ pk__in=[t.pk for t in candidates], wbs_path__isnull=False
1191
+ )
1192
+ .order_by()
1193
+ .values_list("pk", "project_id", "wbs_path")
1194
+ }
1195
+ if not by_key:
1196
+ return set()
1197
+ # Both callers pass the Task model; the guard keeps the literal table name below honest.
1198
+ assert task_model._meta.db_table == "projects_task"
1199
+ sql = (
1200
+ "SELECT DISTINCT project_id::text, subpath(wbs_path, 0, nlevel(wbs_path) - 1)::text "
1201
+ "FROM projects_task WHERE NOT is_deleted AND project_id = ANY(%s::uuid[]) "
1202
+ "AND wbs_path ? %s::lquery[]"
1203
+ )
1204
+ keys = list(by_key)
1205
+ live: set[str] = set()
1206
+ with connections[task_model.objects.db].cursor() as cursor:
1207
+ for i in range(0, len(keys), _WRITEBACK_BATCH_SIZE):
1208
+ chunk = keys[i : i + _WRITEBACK_BATCH_SIZE]
1209
+ project_ids = sorted({pid for pid, _ in chunk})
1210
+ patterns = [f"{path}.*{{1}}" for _, path in chunk]
1211
+ cursor.execute(sql, [project_ids, patterns])
1212
+ for project_id, parent_path in cursor.fetchall():
1213
+ tid = by_key.get((project_id, parent_path))
1214
+ if tid is not None:
1215
+ live.add(tid)
1216
+ return live
1217
+
1218
+
1219
+ def _bulk_write_cpm_results(
1220
+ task_model: Any,
1221
+ tasks_to_update: list[Any],
1222
+ summary_durations: Mapping[str, int],
1223
+ moved_tasks: list[Any],
1224
+ ) -> None:
1225
+ """Persist ``_apply_cpm_results``' in-memory mutations, without clobbering user input.
1226
+
1227
+ Must be called inside the write-back's ``transaction.atomic()``, and before the
1228
+ ``task_dates_updated`` delta is built from ``moved_tasks``. Three steps:
1229
+
1230
+ 1. The engine outputs (:data:`_CPM_WRITE_FIELDS`) on every row. This UPDATE also
1231
+ takes the row lock on every row the recalc touches, which is what makes the two
1232
+ re-reads below see a committed state no concurrent writer can change before
1233
+ this transaction commits.
1234
+ 2. ``duration`` on summary rows only — the only rows whose duration the recalc
1235
+ computes (#3530) — and only those that STILL have a live child now. The
1236
+ summary set was captured at load time; if an accidental container lost its last
1237
+ child meanwhile, ``sync_structure_shadow_values`` has already restored its own
1238
+ estimate into ``duration`` and saved it, and writing the rolled-up span over
1239
+ that would be a permanent lost update: the next recalc reads the row as a leaf
1240
+ and never writes its duration again (#4371).
1241
+ 3. Re-read ``duration`` (non-summary rows) and ``planned_start`` (every row) for
1242
+ ``moved_tasks`` so the WebSocket delta carries the committed value rather than
1243
+ the load-time snapshot. Both are user-owned inputs the recalc never writes; a
1244
+ client splices the delta straight into its cache, so a stale value there would
1245
+ flicker a just-saved edit back until the next refetch. Skipped for a project
1246
+ above ``CPM_DELTA_BROADCAST_CAP``, whose delta ships no rows.
1247
+ """
1248
+ task_model.objects.bulk_update(
1249
+ tasks_to_update, list(_CPM_WRITE_FIELDS), batch_size=_WRITEBACK_BATCH_SIZE
1250
+ )
1251
+
1252
+ candidates = [t for t in tasks_to_update if str(t.id) in summary_durations]
1253
+ written_summary_ids: set[str] = set()
1254
+ if candidates:
1255
+ written_summary_ids = _ids_with_live_child(task_model, candidates)
1256
+ summary_rows = [t for t in candidates if str(t.id) in written_summary_ids]
1257
+ if summary_rows:
1258
+ task_model.objects.bulk_update(
1259
+ summary_rows, ["duration"], batch_size=_WRITEBACK_BATCH_SIZE
1260
+ )
1261
+
1262
+ # The delta is built per project (the program pass ships one per member), so the
1263
+ # cap is applied per project: a project over it ships `truncated` and no rows.
1264
+ moved_by_project: dict[object, list[Any]] = {}
1265
+ for t in moved_tasks:
1266
+ moved_by_project.setdefault(t.project_id, []).append(t)
1267
+ shipped = [
1268
+ t
1269
+ for rows in moved_by_project.values()
1270
+ if len(rows) <= CPM_DELTA_BROADCAST_CAP
1271
+ for t in rows
1272
+ ]
1273
+ current: dict[str, dict[str, Any]] = {}
1274
+ for i in range(0, len(shipped), _WRITEBACK_BATCH_SIZE):
1275
+ chunk = [t.pk for t in shipped[i : i + _WRITEBACK_BATCH_SIZE]]
1276
+ for row in task_model.objects.filter(pk__in=chunk).values(
1277
+ "pk", "duration", "planned_start"
1278
+ ):
1279
+ current[str(row["pk"])] = row
1280
+ if current:
1281
+ for t in shipped:
1282
+ row = current.get(str(t.id))
1283
+ if row is None:
1284
+ continue
1285
+ t.planned_start = row["planned_start"]
1286
+ if str(t.id) not in written_summary_ids:
1287
+ t.duration = row["duration"]
1288
+
1289
+
1290
+ def _apply_driving_flags(deps: list[Any], driving_edges: Iterable[Any]) -> None:
1291
+ """Set ``is_driving`` on each stored Dependency whose edge has zero relationship
1292
+ free float (#2095).
1293
+
1294
+ Keyed by ``(predecessor_id, successor_id, dep_type)`` on leaf task ids exactly
1295
+ as the engine reports; summary-level edges (expanded before CPM) carry no
1296
+ driving fact and stay non-driving (the visual default). bulk_update at the call
1297
+ site bypasses ``VersionedModel.save`` — is_driving is a derived CPM output, not
1298
+ a user edit, and must not trigger a mobile-sync pull.
1299
+ """
1300
+ driving_edge_keys = {(e.predecessor_id, e.successor_id, e.dep_type) for e in driving_edges}
1301
+ for dep in deps:
1302
+ dep.is_driving = (
1303
+ str(dep.predecessor_id),
1304
+ str(dep.successor_id),
1305
+ dep.dep_type,
1306
+ ) in driving_edge_keys
1307
+
1308
+
1309
+ def _clear_output_of_fully_groomed_project(project_id: str) -> None:
1310
+ """Clear stale CPM output for a project whose committed set is empty.
1311
+
1312
+ Deliberately separate from the run's ``_settle_empty()``: its other callers are
1313
+ the project-not-found path (nothing to clear) and the two escalate-to-program
1314
+ paths, where the program run is the sole writer and clears the whole program's
1315
+ project set itself.
1316
+ """
1317
+ from trueppm_api.apps.projects.models import Task
1318
+
1319
+ cleared = clear_uncommitted_cpm_output(Task, project_ids=[project_id])
1320
+ if cleared:
1321
+ logger.info(
1322
+ "recalculate_schedule: project %s cleared CPM output on %d unscheduled row(s)",
1323
+ project_id,
1324
+ cleared,
1325
+ )
1326
+
1327
+
1328
+ def _persist_driving_flags_and_shift_events(
1329
+ db_deps: list[Any], schedule_shift_events: list[Any]
1330
+ ) -> None:
1331
+ """Persist driving-link flags and schedule-shift events inside the caller's atomic block.
1332
+
1333
+ bulk_update bypasses VersionedModel.save so it never bumps server_version —
1334
+ is_driving is a derived CPM output, not a user edit, and must not trigger a
1335
+ mobile-sync pull (same rationale as Task).
1336
+ """
1337
+ from trueppm_api.apps.projects.models import Dependency, TaskActivityEvent
1338
+
1339
+ if db_deps:
1340
+ Dependency.objects.bulk_update(db_deps, ["is_driving"], batch_size=_WRITEBACK_BATCH_SIZE)
1341
+ if schedule_shift_events:
1342
+ TaskActivityEvent.objects.bulk_create(
1343
+ schedule_shift_events, batch_size=_WRITEBACK_BATCH_SIZE
1344
+ )
1345
+
1346
+
1347
+ def _run_schedule(
1348
+ project_id: str,
1349
+ tracker: object = None,
1350
+ ) -> None:
1351
+ """Load tasks/dependencies, run CPM, bulk_update results, broadcast completion.
1352
+
1353
+ CPM runs on the full project and every affected task's results are written
1354
+ back. The engine-level incremental recompute (recompute only the changed
1355
+ subgraph) is tracked under #235 (0.5, ADR-0027).
1356
+
1357
+ Args:
1358
+ project_id: UUID string of the project to schedule.
1359
+ tracker: Optional TaskRunTracker for progress reporting.
1360
+ """
1361
+ from trueppm_scheduler.engine import schedule
1362
+ from trueppm_scheduler.models import Dependency as SchedDependency
1363
+ from trueppm_scheduler.models import DependencyType
1364
+ from trueppm_scheduler.models import Project as SchedProject
1365
+
1366
+ from trueppm_api.apps.observability.otel import attributes
1367
+ from trueppm_api.apps.projects.models import (
1368
+ Dependency,
1369
+ EstimationMode,
1370
+ Project,
1371
+ Task,
1372
+ TaskStatus,
1373
+ TaskType,
1374
+ )
1375
+ from trueppm_api.apps.scheduling.calendars import compose_project_calendar
1376
+ from trueppm_api.apps.scheduling.services import (
1377
+ apply_summary_rollups,
1378
+ build_sched_graph,
1379
+ build_sched_tasks,
1380
+ resolve_cpm_status_date,
1381
+ )
1382
+ from trueppm_api.apps.scheduling.telemetry import cpm_span
1383
+
1384
+ def _update(pct: int, msg: str) -> None:
1385
+ if tracker is not None:
1386
+ tracker.update(pct, msg) # type: ignore[attr-defined]
1387
+
1388
+ def _settle_empty() -> None:
1389
+ """Record a dateless-but-settled result on paths that return before the
1390
+ CPM write. Without this the tracker emits task_run_completed with
1391
+ result_summary=None, which the web client cannot distinguish from an
1392
+ in-flight run, so the "Recalculating…" badge spins forever (#1976). The
1393
+ frontend also clears on any completed scheduling run; this keeps the
1394
+ result_summary a non-null object for audit and other API consumers."""
1395
+ if tracker is not None:
1396
+ tracker.set_result({"project_finish": None, "critical_path": []}) # type: ignore[attr-defined]
1397
+
1398
+ _update(10, "Loading project data…")
1399
+
1400
+ try:
1401
+ db_project = (
1402
+ Project.objects.select_related("calendar", "program__calendar")
1403
+ # tasks__sprint: build_sched_tasks reads each task's sprint.start_date
1404
+ # for the ADR-0168 sprint-window floor; prefetch it to avoid an N+1.
1405
+ # calendar__exceptions + calendar_layers__calendar__exceptions:
1406
+ # compose_project_calendar reads every CalendarException row of the
1407
+ # base calendar (#1491) AND of every applied overlay (#906); prefetch
1408
+ # both to avoid an N+1. program__calendar__exceptions: when the project
1409
+ # inherits its base calendar from the program (ADR-0441), the resolver
1410
+ # reads the program calendar's exceptions too — prefetch to avoid an N+1.
1411
+ .prefetch_related(
1412
+ "tasks",
1413
+ "tasks__sprint",
1414
+ "tasks__predecessors",
1415
+ "calendar__exceptions",
1416
+ "calendar_layers__calendar__exceptions",
1417
+ "program__calendar__exceptions",
1418
+ )
1419
+ .get(pk=project_id)
1420
+ )
1421
+ except Project.DoesNotExist:
1422
+ logger.warning("recalculate_schedule: project %s not found, skipping", project_id)
1423
+ _settle_empty()
1424
+ return
1425
+
1426
+ # ADR-0752 §4/§7: resolve the floor once, up front, from the state this
1427
+ # project was loaded in — before anything below can mutate it. A project
1428
+ # with no explicit status_date and no prior arming is about to have its
1429
+ # floor armed for the first time by *this* recalculation.
1430
+ resolved_status_date = resolve_cpm_status_date(db_project.status_date)
1431
+ is_first_status_date_floor_use = (
1432
+ db_project.status_date is None and db_project.status_date_floor_armed_at is None
1433
+ )
1434
+
1435
+ # ADR-0120 D3: if this project belongs to a program that has ≥1 accepted
1436
+ # cross-project edge, a single-project CPM would compute program-FALSE floats
1437
+ # and criticality (it cannot see the cross-boundary demand). Escalate to the
1438
+ # merged program-scoped pass and stop here, *before* any write — the program
1439
+ # run is the sole writer for every member project while escalation holds, so no
1440
+ # stale single-project dates are ever persisted. The DISPATCHED outbox row is
1441
+ # left for the program run to mark done.
1442
+ if _escalate_to_program(project_id, db_project.program_id):
1443
+ _settle_empty()
1444
+ return
1445
+
1446
+ # Exclude recurrence templates and their generated occurrences from the CPM feed.
1447
+ # Recurring tasks are parallel, calendar-driven activities — admitting them to the
1448
+ # scheduling engine would corrupt float, the critical path, and Monte Carlo
1449
+ # P50/P80/P95. is_recurring is the single load-bearing exclusion key (ADR-0090);
1450
+ # CommittedTaskManager applies the same filter for Monte Carlo / capacity / PDF.
1451
+ #
1452
+ # type=EPIC is excluded for the same reason (ADR-0105): an epic is a grouping
1453
+ # node, not schedulable work — its rollup dates are computed from child stories, not
1454
+ # fed to CPM. CommittedTaskManager applies the matching exclusion at its boundary.
1455
+ #
1456
+ # status=BACKLOG and soft-deleted tombstones are excluded so the deterministic CPM
1457
+ # feed matches CommittedTaskManager exactly (#1772). Monte Carlo already reads from
1458
+ # Task.committed; feeding CPM from the unfiltered manager let backlog ideas and
1459
+ # deleted rows into the network, so grooming the backlog could move the deterministic
1460
+ # critical path and the two engines could structurally disagree on the Overview page.
1461
+ # Filtered in Python so the prefetch cache from the queryset above is reused.
1462
+ db_tasks = [
1463
+ t
1464
+ for t in db_project.tasks.all()
1465
+ if not t.is_recurring
1466
+ and t.type != TaskType.EPIC
1467
+ and t.status != TaskStatus.BACKLOG
1468
+ and not t.is_deleted
1469
+ ]
1470
+ if not db_tasks:
1471
+ logger.info("recalculate_schedule: project %s has no tasks, skipping", project_id)
1472
+ # ADR-1152: a project whose entire committed set was groomed away still holds
1473
+ # every row's last schedule, and this return is BEFORE the write-back — so the
1474
+ # worst case (100% of rows stale) is exactly the one the atomic block below
1475
+ # never reaches. Clear here so the invariant holds on this path too (#3578).
1476
+ # Deliberately NOT inside _settle_empty(): its other three callers are the
1477
+ # project-not-found path (nothing to clear) and the two escalate-to-program
1478
+ # paths, where the program run is the sole writer and clears the whole
1479
+ # program's project set itself.
1480
+ _clear_output_of_fully_groomed_project(project_id)
1481
+ _settle_empty()
1482
+ return
1483
+
1484
+ _update(25, "Building schedule model…")
1485
+
1486
+ # Build a trueppm_scheduler.Calendar from the OVERLAY of the project's applied
1487
+ # calendars — base + holiday/shutdown overlays — as one composed non-working
1488
+ # mask (#906, ADR-0251). Routes through the shared composer so the CPM pass,
1489
+ # Monte Carlo, and program scheduling can never drift on which calendars apply.
1490
+ sched_calendar = compose_project_calendar(db_project)
1491
+
1492
+ # Convert Django Task objects to scheduler dataclasses through the shared
1493
+ # converter (ADR-0132), the single source of truth the Monte Carlo endpoint
1494
+ # also uses — so progress/actual fields and milestone normalisation can never
1495
+ # drift between the two engines (the cause of #1185). The suggest_approve gate
1496
+ # is a no-op for the deterministic CPM pass (which ignores PERT) but is passed
1497
+ # for parity so both paths run identical code.
1498
+ sched_tasks = build_sched_tasks(
1499
+ db_tasks,
1500
+ suggest_approve=db_project.estimation_mode == EstimationMode.SUGGEST_APPROVE,
1501
+ )
1502
+
1503
+ # Convert Django Dependency objects to scheduler dataclasses. Drop any edge that
1504
+ # touches an excluded (recurring) task — its endpoint is absent from sched_tasks,
1505
+ # so handing it to the engine would create a dangling dependency. See ADR-0090.
1506
+ included_ids = {str(t.id) for t in db_tasks}
1507
+ # Dependency.live, not .objects: a soft-deleted edge is a deleted constraint.
1508
+ # Feeding it to the engine keeps a link the user removed in the UI binding the
1509
+ # persisted CPM dates forever (#3532).
1510
+ db_deps = list(
1511
+ Dependency.live.filter(predecessor__project_id=project_id).select_related(
1512
+ "predecessor", "successor"
1513
+ )
1514
+ )
1515
+ sched_deps = [
1516
+ SchedDependency(
1517
+ predecessor_id=str(d.predecessor_id),
1518
+ successor_id=str(d.successor_id),
1519
+ dep_type=DependencyType(d.dep_type),
1520
+ lag=timedelta(days=d.lag),
1521
+ )
1522
+ for d in db_deps
1523
+ if str(d.predecessor_id) in included_ids and str(d.successor_id) in included_ids
1524
+ ]
1525
+
1526
+ # Strip summary rows and fan their edges down to leaves before CPM (ADR-0105).
1527
+ # Shared with every Monte Carlo path since #3527 — one shaping step, one network.
1528
+ db_task_by_id = {str(t.id): t for t in db_tasks}
1529
+ graph = build_sched_graph(db_tasks, sched_tasks, sched_deps)
1530
+ children_map = graph.children_map
1531
+ summary_ids = graph.summary_ids
1532
+ leaf_tasks, expanded_deps = graph.tasks, graph.dependencies
1533
+
1534
+ sched_project = SchedProject(
1535
+ # The engine rejects a non-str id; a caller may hand this a UUID (kombu's
1536
+ # JSON serializer round-trips uuid.UUID), so coerce rather than trust the hint.
1537
+ id=str(project_id),
1538
+ name=db_project.name,
1539
+ start_date=db_project.start_date,
1540
+ tasks=leaf_tasks,
1541
+ dependencies=expanded_deps,
1542
+ calendar=sched_calendar,
1543
+ # The stored plan honors recorded actuals (completed tasks pin, in-progress
1544
+ # tasks use remaining duration) always, and floors not-started work at the
1545
+ # data date: the PM's explicit status_date when set, otherwise today
1546
+ # (resolved once, above, as resolved_status_date — ADR-0752 §4, correcting
1547
+ # ADR-0132 §1). The deterministic CPM pass and the Monte Carlo forecast now
1548
+ # share this same resolution; the CPM path no longer passes a null
1549
+ # status_date raw (which previously meant "no floor at all").
1550
+ status_date=resolved_status_date,
1551
+ )
1552
+
1553
+ _update(50, "Running CPM…")
1554
+
1555
+ # Exceptions propagate to TaskRunTracker.__exit__, which marks the run FAILED
1556
+ # and broadcasts task_run_failed to connected clients. The manual CPM span
1557
+ # (#709) times the engine call and records the graph shape; it is a no-op span
1558
+ # unless OTel export is configured. Runs inside the enqueued Celery task span,
1559
+ # so the trace links the recompute back to the request that triggered it.
1560
+ with cpm_span(project_id, dependency_count=len(sched_deps)) as _cpm_span:
1561
+ result = schedule(sched_project)
1562
+ _cpm_span.set_attribute(attributes.SCHEDULE_TASK_COUNT, len(result.tasks))
1563
+ _cpm_span.set_attribute(attributes.SCHEDULE_CRITICAL_COUNT, len(result.critical_path))
1564
+
1565
+ _update(80, "Writing results…")
1566
+
1567
+ # Build a map from task id string to computed CPM values.
1568
+ result_map = {t.id: t for t in result.tasks}
1569
+
1570
+ # Compute summary task dates by rolling up from their leaf descendants
1571
+ # (ADR-0105) via the shared helper, so this single-project write-back and the
1572
+ # program-scoped write-back derive summary dates through identical code.
1573
+ apply_summary_rollups(result_map, summary_ids, children_map, db_task_by_id)
1574
+
1575
+ # Working-day duration for each rolled-up summary (#3530), on the project's own
1576
+ # composed calendar — the same one the CPM pass just ran on.
1577
+ summary_durations = summary_working_day_durations(
1578
+ result_map, summary_ids, db_task_by_id, {str(db_project.pk): sched_calendar}
1579
+ )
1580
+
1581
+ # Driving-link flags (#2095): set in memory here and persisted via bulk_update
1582
+ # below (like Task CPM fields — no server_version bump).
1583
+ _apply_driving_flags(db_deps, result.driving_edges)
1584
+
1585
+ # Write CPM results back for every task the engine returned (server_version is
1586
+ # intentionally NOT bumped — see _apply_cpm_results). old_cpm_dates snapshots
1587
+ # each task's dates before the overwrite so the shift-events helper can tell
1588
+ # which tasks actually moved (ADR-0207); moved_tasks is that same moved subset
1589
+ # as a list, and is what the ADR-0091 delta broadcast ships (#2573).
1590
+ tasks_to_update, old_cpm_dates, moved_tasks, old_day_end = _apply_cpm_results(
1591
+ db_tasks, result_map, summary_durations
1592
+ )
1593
+
1594
+ # ADR-0752 §7: the suppression only makes sense once there is a prior
1595
+ # schedule to have moved *from*. A project's very first-ever CPM pass
1596
+ # transitions every task from null to computed — exactly like any other
1597
+ # first schedule, floor or no floor — and is not the "someone moved my
1598
+ # schedule" confusion this ADR guards against, so it is reported normally
1599
+ # (matching the existing null -> computed behavior). is_first_status_date_
1600
+ # floor_use still governs whether the armed flag gets persisted below,
1601
+ # regardless of this refinement, so the *next* recalculation is never
1602
+ # treated as arming again.
1603
+ is_arming_status_date_floor = is_first_status_date_floor_use and any(
1604
+ old[0] is not None for old in old_cpm_dates.values()
1605
+ )
1606
+
1607
+ from django.db import transaction
1608
+
1609
+ from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
1610
+
1611
+ cpm_payload: dict[str, object] = {
1612
+ "project_finish": result.project_finish.isoformat(),
1613
+ "critical_path": result.critical_path,
1614
+ # The data date this run was actually computed against (ADR-0752 §4) —
1615
+ # echoed unconditionally (including on the arming run) so every consumer
1616
+ # of cpm_complete/task_run_completed/the schedule.recalculated webhook
1617
+ # knows which "today" produced these dates, mirroring the MC status_date
1618
+ # provenance convention (#2638).
1619
+ "status_date": resolved_status_date.isoformat(),
1620
+ }
1621
+
1622
+ # Per-task CPM date deltas (ADR-0091). Broadcast the tasks whose dates just moved so
1623
+ # collaborators' Gantt bars slide in real time, with no full re-fetch. Built from
1624
+ # moved_tasks (already in scope, fields mutated in-memory) so it costs no query.
1625
+ # It MUST be the moved subset, not the full write-back set: a CPM pass re-assigns
1626
+ # every engine-returned row, so shipping tasks_to_update put any project over
1627
+ # CPM_DELTA_BROADCAST_CAP permanently into the truncated branch and turned every
1628
+ # single-field edit into a full paginated re-fetch per connected client (#2573).
1629
+ # Best-effort, same tier as cpm_complete. Field names mirror SyncTaskSerializer so the
1630
+ # web client can splice the payload straight into its task cache (and a future mobile
1631
+ # client could too); the server_version carve-out above is preserved — this is an
1632
+ # optimization layer over the sync protocol, not a replacement for it. server_version
1633
+ # is intentionally NOT in the payload: clients splice these as optimistic CPM updates,
1634
+ # they are not a sync anchor (bulk_update bypasses VersionedModel.save(), ADR-0091).
1635
+ # Built via the shared helper so the single-project and program-pass deltas (and
1636
+ # their >CPM_DELTA_BROADCAST_CAP truncation) can never drift.
1637
+ #
1638
+ # Built INSIDE the atomic block below, after _bulk_write_cpm_results has re-read
1639
+ # the user-owned columns it ships (duration, planned_start) — building it here
1640
+ # would broadcast the load-time snapshot of a field a concurrent PATCH just
1641
+ # changed (#4371).
1642
+
1643
+ # Persist results and mark the outbox row done in a single transaction, and
1644
+ # defer the board broadcasts to on_commit (#896). Previously the bulk_update
1645
+ # and the two broadcasts ran in autocommit, *before* the ScheduleRequest
1646
+ # status update — a failure on that update (e.g. a lost DB connection) left
1647
+ # clients showing CPM dates that were never committed. Wrapping the writes in
1648
+ # one atomic block and registering the broadcasts with transaction.on_commit
1649
+ # means clients only ever see dates that actually persisted; a rollback
1650
+ # broadcasts nothing. Default-arg binding pins the payloads so the deferred
1651
+ # callbacks can't late-bind a mutated value.
1652
+ # ADR-0120 D3 pre-write guard: a cross-project edge may have been accepted
1653
+ # while this single-project CPM was running. Re-check the escalation predicate
1654
+ # immediately before the write — if the program now has an accepted cross edge,
1655
+ # discard this (now program-FALSE) single-project result and hand off to the
1656
+ # program pass rather than persisting stale single-project dates. Cheap EXISTS.
1657
+ if _escalate_to_program(project_id, db_project.program_id):
1658
+ _settle_empty()
1659
+ return
1660
+
1661
+ # Per-task schedule-shift activity events (ADR-0207, #1604), built from the
1662
+ # mutated in-memory tasks and the project's active baseline. Written inside the
1663
+ # same atomic block as the writeback so they commit or roll back with it.
1664
+ # suppress_movement_events (ADR-0752 §7) withholds the cpm_recalculated rows
1665
+ # on this project's one-shot floor-arming recalculation only.
1666
+ schedule_shift_events = _build_schedule_shift_events(
1667
+ tasks_to_update,
1668
+ old_cpm_dates,
1669
+ _active_baseline_finishes([project_id]),
1670
+ suppress_movement_events=is_arming_status_date_floor,
1671
+ old_day_end=old_day_end,
1672
+ calendars={str(db_project.pk): sched_calendar},
1673
+ )
1674
+
1675
+ with transaction.atomic():
1676
+ # Leaf `duration` is excluded on purpose (#4371): see _CPM_WRITE_FIELDS.
1677
+ _bulk_write_cpm_results(Task, tasks_to_update, summary_durations, moved_tasks)
1678
+ delta_payload = _member_cpm_delta(moved_tasks)
1679
+
1680
+ # ADR-1152 (#3578): the write-back above covers only the rows the engine
1681
+ # returned, which is the committed set. Rows that LEFT it keep their last
1682
+ # schedule forever unless something clears them, and nothing did — so a
1683
+ # groomed-out card was still read as current by the task serializer and by
1684
+ # nine project-level aggregates over Task.objects. Same atomic block as the
1685
+ # bulk_update on purpose: the committed set's new dates and the non-committed
1686
+ # set's nulls are one consistent picture, or neither lands.
1687
+ clear_uncommitted_cpm_output(Task, project_ids=[project_id])
1688
+
1689
+ # Persist driving-link flags (#2095). bulk_update bypasses VersionedModel.save
1690
+ # so it never bumps server_version — is_driving is a derived CPM output, not a
1691
+ # user edit, and must not trigger a mobile-sync pull (same rationale as Task).
1692
+ _persist_driving_flags_and_shift_events(db_deps, schedule_shift_events)
1693
+
1694
+ # Mark the outbox row done so the drain task knows this project is clean.
1695
+ # Filter on status=dispatched to avoid racing with the drain during orphan
1696
+ # recovery (which resets rows back to pending).
1697
+ ScheduleRequest.objects.filter(
1698
+ project_id=project_id, status=ScheduleRequestStatus.DISPATCHED
1699
+ ).update(status=ScheduleRequestStatus.DONE)
1700
+
1701
+ # ADR-0752 §4/§7: persist the one-shot arming flag (if this is the
1702
+ # first use of the floor) and register the on-commit broadcasts,
1703
+ # withholding the per-task movement broadcast on the arming run.
1704
+ # Extracted to its own function to keep this transaction block's
1705
+ # branching flat.
1706
+ _finalize_status_date_floor_arming(
1707
+ project_id,
1708
+ cpm_payload=cpm_payload,
1709
+ delta_payload=delta_payload,
1710
+ is_first_status_date_floor_use=is_first_status_date_floor_use,
1711
+ is_arming_status_date_floor=is_arming_status_date_floor,
1712
+ )
1713
+
1714
+ # Capture a project-grain forecast snapshot for drift history (ADR-0154,
1715
+ # #388). Strictly post-commit and best-effort: a capture failure must never
1716
+ # roll back the CPM write above. Any miss is backfilled by the daily-floor
1717
+ # task, so we do not need an outbox here.
1718
+ def _capture_forecast(pid: str = project_id) -> None:
1719
+ from trueppm_api.apps.scheduling.models import ForecastSnapshotTrigger
1720
+ from trueppm_api.apps.scheduling.services import safe_capture_forecast_snapshot
1721
+
1722
+ safe_capture_forecast_snapshot(pid, ForecastSnapshotTrigger.RECOMPUTE)
1723
+
1724
+ transaction.on_commit(_capture_forecast)
1725
+
1726
+ logger.info(
1727
+ "recalculate_schedule: updated %d tasks for project %s (finish=%s)",
1728
+ len(tasks_to_update),
1729
+ project_id,
1730
+ result.project_finish,
1731
+ )
1732
+
1733
+ # Store CPM result summary for audit / frontend access.
1734
+ if tracker is not None:
1735
+ tracker.set_result(cpm_payload) # type: ignore[attr-defined]
1736
+
1737
+ # Dispatch schedule.recalculated webhook to external subscribers. Fired after
1738
+ # the transaction so subscribers are only notified of committed results.
1739
+ from trueppm_api.apps.webhooks.dispatch import dispatch_webhooks
1740
+
1741
+ dispatch_webhooks(
1742
+ project_id=project_id,
1743
+ event_type="schedule.recalculated",
1744
+ payload={"project": project_id, **cpm_payload},
1745
+ )
1746
+
1747
+
1748
+ def _broadcast_cpm_complete(
1749
+ *,
1750
+ project_id: str,
1751
+ project_finish: str | None,
1752
+ critical_path: list[str],
1753
+ status_date: str | None,
1754
+ ) -> None:
1755
+ """Emit ``cpm_complete`` with the one payload shape shared by both CPM passes.
1756
+
1757
+ Both the single-project write-back (``_finalize_status_date_floor_arming``)
1758
+ and the program-scoped write-back (``_register_program_broadcasts``) route
1759
+ through this one function so the two passes can never again drift on this
1760
+ event's keys (#3776 — before this fix they built the payload as two
1761
+ separate dict *variables*, one carrying ``status_date`` and one not, and
1762
+ both landed in ``test_broadcast_payload_shape.py``'s unscannable bucket,
1763
+ which is exactly what let the drift go undetected). The literal dict below
1764
+ is what makes this call site — now the *only* ``cpm_complete`` call site —
1765
+ scannable by that gate instead of contributing two entries to it.
1766
+
1767
+ Call from inside a ``transaction.on_commit()`` registration (via
1768
+ ``functools.partial`` to pin the arguments at registration time, matching
1769
+ the default-arg-binding pattern used elsewhere in this module) so clients
1770
+ only ever see a payload for a write that actually committed.
1771
+
1772
+ ``status_date`` is the CPM data date this run resolved (ADR-0752 §4),
1773
+ echoed so a consumer knows which "today" produced these dates. It is
1774
+ ``None`` on the program-scoped pass: ``gather_program_schedule`` runs the
1775
+ merged engine with ``status_date=None`` on purpose, because member
1776
+ projects can carry different ``status_date`` values and no single one
1777
+ would honestly describe what the merged pass floored against — the
1778
+ program view shows the earliest honest schedule, not a data-date floor.
1779
+ Echoing ``None`` states that plainly rather than fabricating a value or
1780
+ silently dropping the key.
1781
+ """
1782
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1783
+
1784
+ broadcast_board_event(
1785
+ project_id=project_id,
1786
+ event_type="cpm_complete",
1787
+ payload={
1788
+ "project_finish": project_finish,
1789
+ "critical_path": critical_path,
1790
+ "status_date": status_date,
1791
+ },
1792
+ )
1793
+
1794
+
1795
+ def _finalize_status_date_floor_arming(
1796
+ project_id: str,
1797
+ *,
1798
+ cpm_payload: dict[str, object],
1799
+ delta_payload: dict[str, object],
1800
+ is_first_status_date_floor_use: bool,
1801
+ is_arming_status_date_floor: bool,
1802
+ ) -> None:
1803
+ """Persist the one-shot arming flag and register the on-commit CPM broadcasts.
1804
+
1805
+ ADR-0752 §4/§7: ``is_first_status_date_floor_use`` marks the recalculation
1806
+ that first resolves this project's null ``status_date`` to today — the
1807
+ armed flag is persisted here so no later recalculation is ever treated as
1808
+ the first use again. ``is_arming_status_date_floor`` is the narrower
1809
+ condition that actually withholds the ADR-0091 ``task_dates_updated``
1810
+ per-task delta broadcast: first use *and* at least one task already had a
1811
+ prior computed date (i.e. this is a genuine correction to an established
1812
+ schedule, not a brand-new project's first-ever pass, which reports
1813
+ null -> computed transitions exactly like it always has). This is the
1814
+ pub/sub surface the §10 threat model names explicitly; suppressing only
1815
+ the ``cpm_recalculated`` TaskActivityEvent rows (see
1816
+ ``_build_schedule_shift_events``) and still firing this broadcast would
1817
+ let the same movement reach connected clients as ordinary activity through
1818
+ a different channel. ``cpm_complete`` still fires unconditionally,
1819
+ including on the arming run — it carries ``project_finish``/
1820
+ ``status_date``, not per-task movement, so it is not part of what §7
1821
+ suppresses.
1822
+
1823
+ MUST be called from inside the writeback's ``transaction.atomic()`` block,
1824
+ matching ``_register_program_broadcasts`` below: the ``.update()`` call
1825
+ needs to commit or roll back with the Task writeback, and ``on_commit``
1826
+ callbacks must be registered before that block exits. Default-arg binding
1827
+ (and, for ``cpm_complete``, ``functools.partial``) pins each payload
1828
+ against late mutation.
1829
+ """
1830
+ from typing import cast
1831
+
1832
+ from django.db import transaction
1833
+ from django.utils import timezone
1834
+
1835
+ from trueppm_api.apps.projects.models import Project
1836
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1837
+
1838
+ if is_first_status_date_floor_use:
1839
+ # A plain .update() bypasses VersionedModel.save() — no server_version
1840
+ # bump, no history row, no mobile-sync pull — for this server-owned
1841
+ # bookkeeping field, matching the existing recalculated_at pattern.
1842
+ Project.objects.filter(pk=project_id).update(status_date_floor_armed_at=timezone.now())
1843
+
1844
+ # Routed through the shared _broadcast_cpm_complete() (#3776) so this and
1845
+ # the program-scoped pass can never again ship cpm_complete with different
1846
+ # keys. functools.partial evaluates its keyword arguments immediately,
1847
+ # pinning cpm_payload's fields at registration time exactly like the
1848
+ # default-arg pattern used for task_dates_updated just below. The casts
1849
+ # only narrow cpm_payload's declared dict[str, object] value type back to
1850
+ # what _run_schedule actually put there (str project_finish/status_date,
1851
+ # list[str] critical_path) — this function never sees a program-pass
1852
+ # cpm_payload, whose caller passes its own concretely-typed values instead.
1853
+ transaction.on_commit(
1854
+ functools.partial(
1855
+ _broadcast_cpm_complete,
1856
+ project_id=project_id,
1857
+ project_finish=cast("str", cpm_payload["project_finish"]),
1858
+ critical_path=cast("list[str]", cpm_payload["critical_path"]),
1859
+ status_date=cast("str", cpm_payload["status_date"]),
1860
+ )
1861
+ )
1862
+
1863
+ if not is_arming_status_date_floor:
1864
+
1865
+ def _broadcast_dates(pid: str = project_id, pay: dict[str, object] = delta_payload) -> None:
1866
+ broadcast_board_event(project_id=pid, event_type="task_dates_updated", payload=pay)
1867
+
1868
+ transaction.on_commit(_broadcast_dates)
1869
+
1870
+
1871
+ # ---------------------------------------------------------------------------
1872
+ # Program-scoped CPM dispatch pass — ADR-0120 D3 (#1117)
1873
+ # ---------------------------------------------------------------------------
1874
+
1875
+
1876
+ def _escalate_to_program(project_id: str, program_id: object) -> bool:
1877
+ """Dispatch the program-scoped pass instead of a single-project one, if needed.
1878
+
1879
+ Returns ``True`` when the project's program has ≥1 accepted cross-project edge
1880
+ (ADR-0120 D3) and a ``recalculate_program_schedule`` was dispatched (best
1881
+ effort) — the caller must then return *without* writing single-project results,
1882
+ because a single-project CPM cannot see the cross-boundary demand and would
1883
+ persist program-FALSE floats/criticality. ``False`` keeps today's per-project
1884
+ path. If the dispatch itself fails (broker down) the outbox row stays
1885
+ DISPATCHED and the 10-minute orphan sweep re-escalates it — so this never
1886
+ silently drops the recompute.
1887
+ """
1888
+ if program_id is None:
1889
+ return False
1890
+ from trueppm_api.apps.projects.program_schedule import program_has_accepted_cross_edges
1891
+
1892
+ if not program_has_accepted_cross_edges(program_id):
1893
+ return False
1894
+ try:
1895
+ recalculate_program_schedule.delay(str(program_id))
1896
+ except Exception:
1897
+ logger.exception(
1898
+ "recalculate_schedule: could not dispatch program pass for program %s "
1899
+ "— orphan recovery will re-escalate within 10 min",
1900
+ program_id,
1901
+ )
1902
+ return True
1903
+
1904
+
1905
+ @idempotent_task(
1906
+ lock_key_template="schedule_lock:program:{0}",
1907
+ lock_ttl=300,
1908
+ on_contention="queue",
1909
+ queue_countdown=10,
1910
+ max_queue_attempts=30,
1911
+ autoretry_for=_RETRIABLE,
1912
+ retry_backoff=30,
1913
+ retry_backoff_max=300,
1914
+ retry_jitter=True,
1915
+ max_retries=3,
1916
+ soft_time_limit=480,
1917
+ time_limit=600,
1918
+ acks_late=True,
1919
+ reject_on_worker_lost=True,
1920
+ )
1921
+ def recalculate_program_schedule(self: object, program_id: str) -> None:
1922
+ """Run the merged, program-true CPM for one program and persist it (ADR-0120 D3).
1923
+
1924
+ Escalated to from ``recalculate_schedule`` whenever a member project's program
1925
+ holds ≥1 accepted cross-project edge. It merges every member project's tasks
1926
+ and every accepted cross edge into one engine graph (the shared
1927
+ ``gather_program_schedule``), runs CPM once, writes program-true CPM fields back
1928
+ to every member project, fans the ``cpm_complete`` / ``task_dates_updated``
1929
+ broadcasts out to each member project's board channel (there is no program WS
1930
+ channel in 0.3 — the program view subscribes per member project), upserts any
1931
+ D4 sprint-boundary conflicts, and coalesces every member project's outbox row.
1932
+
1933
+ Idempotent: a pure recompute, safe to run twice. ``schedule_lock:program:{id}``
1934
+ serializes program runs; ``max_queue_attempts`` is sized so contenders ride out
1935
+ a held lock rather than dropping, and the outbox orphan sweep is the backstop on
1936
+ a crash. The lock namespace is disjoint from the per-project ``schedule_lock:{id}``,
1937
+ but a per-project task can never *write* while escalation holds (it self-aborts
1938
+ at the pre-write guard), so the two never produce a torn write.
1939
+ """
1940
+ _run_program_schedule(program_id)
1941
+
1942
+
1943
+ def _member_cpm_delta(moved_tasks: list[Any]) -> dict[str, object]:
1944
+ """Build one project's ``task_dates_updated`` delta payload (ADR-0091 shape).
1945
+
1946
+ ``moved_tasks`` must be the *changed set* — only the rows whose CPM fields
1947
+ actually moved this pass — not the full write-back set. ``count`` is therefore
1948
+ the moved-task count in both branches, as ADR-0091 specifies. Mirrors the
1949
+ single-project delta exactly so a web client can splice program-pass results
1950
+ into its task cache the same way; above ``CPM_DELTA_BROADCAST_CAP`` it emits a
1951
+ ``truncated`` signal and lets the client re-fetch.
1952
+ """
1953
+ if len(moved_tasks) > CPM_DELTA_BROADCAST_CAP:
1954
+ return {"count": len(moved_tasks), "truncated": True}
1955
+ return {
1956
+ "count": len(moved_tasks),
1957
+ "tasks": [
1958
+ {
1959
+ "id": str(t.id),
1960
+ "early_start": t.early_start.isoformat() if t.early_start else None,
1961
+ "early_finish": t.early_finish.isoformat() if t.early_finish else None,
1962
+ "late_start": t.late_start.isoformat() if t.late_start else None,
1963
+ "late_finish": t.late_finish.isoformat() if t.late_finish else None,
1964
+ "total_float": t.total_float,
1965
+ "free_float": t.free_float,
1966
+ "is_critical": t.is_critical,
1967
+ "planned_start": t.planned_start.isoformat() if t.planned_start else None,
1968
+ "duration": t.duration,
1969
+ # ADR-0752: the task's span start, distinct from early_start's
1970
+ # remaining-work window. Reaches the same project subscribers
1971
+ # early_start already does — no new field-leak (ADR §10).
1972
+ "scheduled_start": t.scheduled_start.isoformat() if t.scheduled_start else None,
1973
+ "milestone_at_day_end": t.milestone_at_day_end,
1974
+ }
1975
+ for t in moved_tasks
1976
+ ],
1977
+ }
1978
+
1979
+
1980
+ def _register_program_broadcasts(
1981
+ graph: Any,
1982
+ by_project: dict[object, list[Any]],
1983
+ moved_by_project: dict[object, list[Any]],
1984
+ crit_order: list[str],
1985
+ ) -> None:
1986
+ """Register each member project's post-commit broadcasts for a program pass.
1987
+
1988
+ Ships a ``cpm_complete`` (full program-true critical path restricted to the
1989
+ project) and ``task_dates_updated`` delta per project, plus a forecast-capture,
1990
+ all deferred to commit (#896) so clients only ever see dates that persisted; a
1991
+ rollback broadcasts nothing. MUST be called from inside the writeback's
1992
+ ``transaction.atomic()`` block. Default-arg binding (``task_dates_updated``,
1993
+ the forecast capture) and ``functools.partial`` (``cpm_complete``, via the
1994
+ shared ``_broadcast_cpm_complete()``) both pin each project's payload against
1995
+ late mutation from this loop's next iteration.
1996
+
1997
+ Two groupings, deliberately: ``by_project`` is the full write-back set and
1998
+ defines each project's ``project_finish`` (an *unmoved* late task still sets
1999
+ where finish sits), while ``moved_by_project`` is the changed subset the
2000
+ ADR-0091 delta ships (#2573). Collapsing them would either mis-report the
2001
+ finish or re-introduce the full-project delta.
2002
+ """
2003
+ from django.db import transaction
2004
+
2005
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
2006
+
2007
+ for p in graph.member_projects:
2008
+ pid = str(p.id)
2009
+ project_tasks = by_project.get(p.id, [])
2010
+ # The project's full program-true critical path — every critical task in this
2011
+ # project, NOT just the ones whose dates moved this pass (a client that
2012
+ # replaces its critical-path state on cpm_complete must get the whole set,
2013
+ # matching the single-project _run_schedule which ships the full path).
2014
+ project_crit = [
2015
+ tid
2016
+ for tid in crit_order
2017
+ if tid in graph.db_task_by_id and graph.db_task_by_id[tid].project_id == p.id
2018
+ ]
2019
+ project_finish = max(
2020
+ (t.early_finish for t in project_tasks if t.early_finish is not None),
2021
+ default=None,
2022
+ )
2023
+ delta_payload = _member_cpm_delta(moved_by_project.get(p.id, []))
2024
+
2025
+ def _dates(pid: str = pid, pay: dict[str, object] = delta_payload) -> None:
2026
+ broadcast_board_event(project_id=pid, event_type="task_dates_updated", payload=pay)
2027
+
2028
+ def _capture(pid: str = pid) -> None:
2029
+ from trueppm_api.apps.scheduling.models import ForecastSnapshotTrigger
2030
+ from trueppm_api.apps.scheduling.services import safe_capture_forecast_snapshot
2031
+
2032
+ safe_capture_forecast_snapshot(pid, ForecastSnapshotTrigger.RECOMPUTE)
2033
+
2034
+ # Routed through the shared _broadcast_cpm_complete() (#3776) so this
2035
+ # pass and the single-project write-back can never again ship
2036
+ # cpm_complete with different keys — status_date=None documents that
2037
+ # the merged program pass floors nothing (see that function's
2038
+ # docstring). functools.partial pins pid/project_finish/project_crit
2039
+ # at this loop iteration, same as the default-arg pattern _dates and
2040
+ # _capture use just above.
2041
+ transaction.on_commit(
2042
+ functools.partial(
2043
+ _broadcast_cpm_complete,
2044
+ project_id=pid,
2045
+ project_finish=project_finish.isoformat() if project_finish else None,
2046
+ critical_path=project_crit,
2047
+ status_date=None,
2048
+ )
2049
+ )
2050
+ transaction.on_commit(_dates)
2051
+ transaction.on_commit(_capture)
2052
+
2053
+
2054
+ def _run_program_schedule(program_id: str) -> None:
2055
+ """Gather, run, and persist the program-true schedule for ``program_id``.
2056
+
2057
+ Extracted from the Celery task for testability — exercised directly by the
2058
+ program-pass tests without a broker.
2059
+ """
2060
+ from django.db import transaction
2061
+ from django.utils import timezone
2062
+
2063
+ from trueppm_api.apps.projects.lifecycle import visible_projects
2064
+ from trueppm_api.apps.projects.models import (
2065
+ Dependency,
2066
+ Program,
2067
+ Project,
2068
+ Task,
2069
+ TaskActivityEvent,
2070
+ )
2071
+ from trueppm_api.apps.projects.program_schedule import gather_program_schedule
2072
+ from trueppm_api.apps.projects.slip_conflict import detect_and_upsert_slip_conflicts
2073
+ from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
2074
+ from trueppm_api.apps.scheduling.services import apply_summary_rollups
2075
+ from trueppm_api.apps.webhooks.dispatch import dispatch_webhooks
2076
+
2077
+ try:
2078
+ program = Program.objects.get(pk=program_id, is_deleted=False)
2079
+ except Program.DoesNotExist:
2080
+ logger.warning("recalculate_program_schedule: program %s not found, skipping", program_id)
2081
+ return
2082
+
2083
+ # Drafts are excluded (#2962/#3128) to stay consistent with
2084
+ # ``gather_program_schedule``, which drops them from the merged graph. If this
2085
+ # list still carried a draft, the pass would stamp ``recalculated_at`` on a
2086
+ # project it never scheduled and drain its ScheduleRequests against a result
2087
+ # that does not mention it.
2088
+ member_ids = list(
2089
+ visible_projects(
2090
+ Project.objects.filter(program_id=program_id, is_deleted=False)
2091
+ ).values_list("id", flat=True)
2092
+ )
2093
+ if not member_ids:
2094
+ return
2095
+
2096
+ # CLAIM before reading data: capture exactly the member outbox rows that are
2097
+ # already DISPATCHED. Only these row ids are marked DONE at the end, so a row
2098
+ # dispatched *after* this point (a concurrent edit while we run) is never
2099
+ # swallowed — it lands a fresh row and is serviced by the next pass.
2100
+ claimed_ids = list(
2101
+ ScheduleRequest.objects.filter(
2102
+ project_id__in=member_ids, status=ScheduleRequestStatus.DISPATCHED
2103
+ ).values_list("id", flat=True)
2104
+ )
2105
+
2106
+ graph = gather_program_schedule(program, enforce_max=False)
2107
+
2108
+ now = timezone.now()
2109
+ tasks_to_update: list[Task] = []
2110
+ # The subset of tasks_to_update whose CPM fields actually moved — what the
2111
+ # ADR-0091 delta broadcast ships (#2573).
2112
+ moved_tasks: list[Task] = []
2113
+ # Driving-link flags across every member project (#2095), persisted alongside
2114
+ # the task write-back below. Empty when the program has no schedulable result.
2115
+ program_db_deps: list[Dependency] = []
2116
+ # Snapshot CPM dates before overwrite, for the schedule-shift events (ADR-0207).
2117
+ old_cpm_dates: dict[str, tuple[Any, Any, Any, Any]] = {}
2118
+ old_day_end: dict[str, bool] = {}
2119
+ # Summary-row working-day durations (#3530); empty when nothing was scheduled.
2120
+ summary_durations: dict[str, int] = {}
2121
+ if graph.result is not None:
2122
+ result_map = graph.result_map
2123
+ # Roll summary dates up from leaves through the shared helper (parity with
2124
+ # the single-project write-back).
2125
+ summary_ids = graph.summary_ids
2126
+ apply_summary_rollups(result_map, summary_ids, graph.children_map, graph.db_task_by_id)
2127
+ # Working-day summary durations (#3530). ADR-0120 D3 holds one calendar per
2128
+ # member project, so each summary is measured on its own project's working
2129
+ # week rather than a single program-wide one.
2130
+ summary_durations = summary_working_day_durations(
2131
+ result_map, summary_ids, graph.db_task_by_id, graph.calendars
2132
+ )
2133
+ # Full write-back across every member project (the program pass is coarse;
2134
+ # incremental subgraph writes are a later optimization). Shared helper with
2135
+ # _run_schedule, so field assignment — milestone single-point normalisation
2136
+ # and summary working-day duration included — can never drift.
2137
+ tasks_to_update, old_cpm_dates, moved_tasks, old_day_end = _apply_cpm_results(
2138
+ graph.db_task_by_id.values(), result_map, summary_durations
2139
+ )
2140
+
2141
+ # Driving-link flags across the program (#2095), same shared helper as the
2142
+ # single-project path; summary-level edges stay non-driving.
2143
+ # Dependency.live: the driving-link flag is a CPM output, and the program
2144
+ # graph this result came from already excluded soft-deleted edges
2145
+ # (gather_program_schedule). Writing flags to a deleted edge would be
2146
+ # scoring a constraint that is not in the network (#3532).
2147
+ program_db_deps = list(Dependency.live.filter(predecessor__project_id__in=member_ids))
2148
+ _apply_driving_flags(program_db_deps, graph.result.driving_edges)
2149
+
2150
+ # Group the full write-back set by project — this is what defines each member
2151
+ # project's finish (an unmoved late task still sets where finish sits).
2152
+ by_project: dict[object, list[Task]] = {}
2153
+ for t in tasks_to_update:
2154
+ by_project.setdefault(t.project_id, []).append(t)
2155
+
2156
+ # And the moved subset by project — this is what the ADR-0091 delta ships, so a
2157
+ # program pass over many member projects does not force every subscriber into
2158
+ # the truncated full-refetch branch (#2573).
2159
+ moved_by_project: dict[object, list[Task]] = {}
2160
+ for t in moved_tasks:
2161
+ moved_by_project.setdefault(t.project_id, []).append(t)
2162
+
2163
+ # Program-true critical path restricted to each project (clients consuming a
2164
+ # project's cpm_complete expect that project's critical ids).
2165
+ crit_order = list(graph.result.critical_path) if graph.result is not None else []
2166
+
2167
+ # Per-task schedule-shift activity events across every member project (ADR-0207),
2168
+ # keyed off each project's own active baseline for the drift crossings.
2169
+ schedule_shift_events = _build_schedule_shift_events(
2170
+ tasks_to_update,
2171
+ old_cpm_dates,
2172
+ _active_baseline_finishes(member_ids),
2173
+ old_day_end=old_day_end,
2174
+ calendars=graph.calendars,
2175
+ )
2176
+
2177
+ with transaction.atomic():
2178
+ if tasks_to_update:
2179
+ # Same split as the single-project pass: leaf `duration` is never
2180
+ # written back, so a concurrent edit anywhere in the program survives
2181
+ # (#4371). See _CPM_WRITE_FIELDS.
2182
+ _bulk_write_cpm_results(Task, tasks_to_update, summary_durations, moved_tasks)
2183
+
2184
+ # ADR-1152 (#3578): mirror of the single-project clear. Scoped to every member
2185
+ # project, NOT to the schedulable subset — a member whose whole committed set
2186
+ # was groomed away contributes no rows to `tasks_to_update` yet is precisely
2187
+ # the project holding the most residue. This also covers the two
2188
+ # escalate-to-program returns in _run_schedule, which deliberately clear
2189
+ # nothing themselves because the program run is the sole writer while
2190
+ # escalation holds (ADR-0120 D3).
2191
+ clear_uncommitted_cpm_output(Task, project_ids=member_ids)
2192
+
2193
+ # Persist driving-link flags (#2095) — bulk_update, no server_version bump.
2194
+ if program_db_deps:
2195
+ Dependency.objects.bulk_update(
2196
+ program_db_deps, ["is_driving"], batch_size=_WRITEBACK_BATCH_SIZE
2197
+ )
2198
+
2199
+ if schedule_shift_events:
2200
+ TaskActivityEvent.objects.bulk_create(
2201
+ schedule_shift_events, batch_size=_WRITEBACK_BATCH_SIZE
2202
+ )
2203
+
2204
+ # Coalesce: mark exactly the claimed member outbox rows done (DISPATCHED →
2205
+ # DONE). Filtering on DISPATCHED avoids racing the drain's orphan recovery.
2206
+ if claimed_ids:
2207
+ ScheduleRequest.objects.filter(
2208
+ id__in=claimed_ids, status=ScheduleRequestStatus.DISPATCHED
2209
+ ).update(status=ScheduleRequestStatus.DONE)
2210
+
2211
+ # Stamp recalculated_at on every member project so each project's Schedule
2212
+ # view clears its "recalculating" badge (#1053), bulk to skip history.
2213
+ Project.objects.filter(pk__in=member_ids).update(recalculated_at=now)
2214
+
2215
+ # D4 sprint-boundary firewall: detect and upsert cross-project slip
2216
+ # conflicts in the same transaction as the write-back, so a conflict is
2217
+ # never visible against dates that did not commit.
2218
+ if graph.result is not None:
2219
+ detect_and_upsert_slip_conflicts(graph)
2220
+
2221
+ # Per-project broadcasts + forecast capture, deferred to commit (#896) so
2222
+ # clients only ever see dates that persisted. Registered from inside this
2223
+ # atomic block so the on_commit callbacks are tied to this transaction.
2224
+ _register_program_broadcasts(graph, by_project, moved_by_project, crit_order)
2225
+
2226
+ logger.info(
2227
+ "recalculate_program_schedule: program %s — %d member project(s), %d task(s) updated",
2228
+ program_id,
2229
+ len(member_ids),
2230
+ len(tasks_to_update),
2231
+ )
2232
+
2233
+ # schedule.recalculated webhook per member project, after commit.
2234
+ for p in graph.member_projects:
2235
+ pid = str(p.id)
2236
+ project_tasks = by_project.get(p.id, [])
2237
+ project_finish = max(
2238
+ (t.early_finish for t in project_tasks if t.early_finish is not None),
2239
+ default=None,
2240
+ )
2241
+ dispatch_webhooks(
2242
+ project_id=pid,
2243
+ event_type="schedule.recalculated",
2244
+ payload={
2245
+ "project": pid,
2246
+ "project_finish": project_finish.isoformat() if project_finish else None,
2247
+ },
2248
+ )