trueppm-api 0.4.0b2__py3-none-any.whl

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