trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,2908 @@
1
+ """Views for the scheduling app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from datetime import date as _date
10
+ from datetime import datetime as _datetime
11
+ from datetime import timedelta
12
+ from typing import Any, cast
13
+
14
+ from django.conf import settings
15
+ from django.core.cache import cache
16
+ from django.db import models, transaction
17
+ from django.shortcuts import get_object_or_404
18
+ from django.utils import timezone
19
+ from django.utils.dateparse import parse_date, parse_datetime
20
+ from drf_spectacular.types import OpenApiTypes
21
+ from drf_spectacular.utils import (
22
+ OpenApiExample,
23
+ OpenApiParameter,
24
+ OpenApiResponse,
25
+ PolymorphicProxySerializer,
26
+ extend_schema,
27
+ extend_schema_view,
28
+ inline_serializer,
29
+ )
30
+ from rest_framework import serializers, status
31
+ from rest_framework import serializers as drf_serializers
32
+ from rest_framework.decorators import action, api_view, permission_classes, throttle_classes
33
+ from rest_framework.generics import ListAPIView
34
+ from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
35
+ from rest_framework.pagination import PageNumberPagination
36
+ from rest_framework.permissions import IsAuthenticated
37
+ from rest_framework.request import Request
38
+ from rest_framework.response import Response
39
+ from rest_framework.throttling import ScopedRateThrottle, SimpleRateThrottle
40
+ from rest_framework.views import APIView
41
+ from rest_framework.viewsets import GenericViewSet
42
+
43
+ from trueppm_api.apps.access.permissions import (
44
+ IsProjectAdmin,
45
+ IsProjectMember,
46
+ IsProjectNotArchived,
47
+ IsProjectScheduler,
48
+ IsWorkspaceOperator,
49
+ McpReadableViewMixin,
50
+ McpScope,
51
+ assert_project_not_archived,
52
+ declare_scope_in_body,
53
+ )
54
+ from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
55
+ from trueppm_api.apps.projects.models import (
56
+ Dependency,
57
+ EstimationMode,
58
+ Project,
59
+ Task,
60
+ three_point_estimates_ordered,
61
+ )
62
+ from trueppm_api.apps.scheduling.calendars import compose_project_calendar
63
+ from trueppm_api.apps.scheduling.finish_reading import (
64
+ finish_shift_days,
65
+ latest_finish,
66
+ lazy_finish_shift_days,
67
+ schedule_result_finish,
68
+ task_finish_at_day_start,
69
+ )
70
+ from trueppm_api.apps.scheduling.forecast_staleness import (
71
+ forecast_staleness_facts,
72
+ forecast_staleness_for_payload,
73
+ forecast_staleness_from_run,
74
+ )
75
+ from trueppm_api.apps.scheduling.mc_admission import admit_runs, busy_response, simulation_slot
76
+ from trueppm_api.apps.scheduling.models import (
77
+ FailedTask,
78
+ FailedTaskStatus,
79
+ MonteCarloRun,
80
+ ProjectForecastSnapshot,
81
+ ScheduleRequestReason,
82
+ VelocitySuggestion,
83
+ )
84
+ from trueppm_api.apps.scheduling.risk_premium import (
85
+ build_risk_premium,
86
+ delta_vs_cpm_days,
87
+ risk_premium_for_forecast_payload,
88
+ risk_premium_from_values,
89
+ )
90
+ from trueppm_api.apps.scheduling.serializers import (
91
+ FailedTaskDropSerializer,
92
+ FailedTaskRequeueSerializer,
93
+ FailedTaskSerializer,
94
+ MonteCarloForecastSerializer,
95
+ MonteCarloRunSerializer,
96
+ MonteCarloWhatIfRequestSerializer,
97
+ MonteCarloWhatIfResponseSerializer,
98
+ ProjectForecastSnapshotSerializer,
99
+ ScheduleCpmDerivationSerializer,
100
+ ScheduleMonteCarloDerivationSerializer,
101
+ VelocitySuggestionSerializer,
102
+ )
103
+ from trueppm_api.apps.scheduling.services import (
104
+ build_sched_graph,
105
+ build_sched_tasks,
106
+ enqueue_recalculate,
107
+ forecast_diagnostic,
108
+ record_monte_carlo_run,
109
+ resolve_cpm_status_date,
110
+ )
111
+ from trueppm_api.apps.scheduling.telemetry import monte_carlo_span
112
+ from trueppm_api.workflows.consumers.requeue_failed_task import WORKFLOW_NAME as REQUEUE_WORKFLOW
113
+ from trueppm_api.workflows.services import start_workflow
114
+
115
+ logger = logging.getLogger(__name__)
116
+
117
+ # Version token in the Monte Carlo read-cache key. Bump it in the same commit as
118
+ # any engine change that moves the percentiles for an unchanged project.
119
+ #
120
+ # The entry lives for 24 hours (ADR-0144, #1231) and nothing else invalidates it —
121
+ # not a task edit, not a deploy, since the cache declares no KEY_PREFIX/VERSION and
122
+ # the key carried only the project pk. So an engine correctness fix would keep
123
+ # serving its own pre-fix numbers to `GET /monte-carlo/latest/`, to the MCP
124
+ # forecast tool, and to the derivation ("why") endpoint for a full day after
125
+ # rollout, from a payload with no signal that it is stale. Bumping the token
126
+ # retires the old keyspace at deploy instead (#2833).
127
+ #
128
+ # This does NOT reach the persisted `MonteCarloRun` fallback that serves once the
129
+ # TTL expires: those rows carry no engine-version column, so a project whose last
130
+ # run predates a fix and is never re-run keeps reporting the old answer.
131
+ #
132
+ # v3 (#4204): percentiles are ordered by working-time finish and every delta is
133
+ # measured in working time; a v2 entry's `delta_vs_cpm` diffed shown days.
134
+ MC_LATEST_CACHE_VERSION = 3
135
+
136
+
137
+ def mc_latest_cache_key(pk: object) -> str:
138
+ """Read-cache key for a project's latest Monte Carlo forecast.
139
+
140
+ One definition for the write, both reads, and the tests. Three duplicated
141
+ f-strings is how a version bump gets applied to two of them.
142
+ """
143
+ return f"mc_latest:v{MC_LATEST_CACHE_VERSION}:{pk}"
144
+
145
+
146
+ def _current_plan_state(pk: uuid.UUID | str) -> tuple[int | None, _datetime | None]:
147
+ """The project's live plan version and last CPM recalc time (#3140).
148
+
149
+ Both terms the staleness discriminant compares a run against, in one narrow
150
+ ``values_list`` rather than loading the row: callers that already hold the project
151
+ read the two attributes directly, and this exists for the ones that do not.
152
+
153
+ ``(None, None)`` when the row is gone (a concurrent hard delete), which classifies
154
+ the forecast as ``unknown`` rather than inventing a comparison — see
155
+ :func:`~.forecast_staleness.classify_forecast_staleness`.
156
+ """
157
+ # `is_deleted=False` matches every sibling project fetch in this module. It changes
158
+ # nothing for the current caller, which already resolved the project through a
159
+ # filtered fetch — it stops the helper failing open on soft-delete for the next one.
160
+ row = (
161
+ Project.objects.filter(pk=pk, is_deleted=False)
162
+ .values_list("last_sync_version", "recalculated_at")
163
+ .first()
164
+ )
165
+ return row if row is not None else (None, None)
166
+
167
+
168
+ # Upper bound on how many parked tasks a single bulk requeue/drop touches, so a
169
+ # "drop all" / "requeue all" over a large filtered set cannot unbounded-load the
170
+ # DB or storm the broker (ADR-0210 §4). Oldest-first slice, with a ``capped`` flag
171
+ # in the response so the operator knows to repeat.
172
+ #
173
+ # Read straight off settings, not through `getattr(..., 500)`: the fallback made the
174
+ # knob look tunable while no settings module bound it, so the docs' claim that
175
+ # operators could raise it was false and the 500 always won (#2880). The binding now
176
+ # lives in settings/base.py (env: TRUEPPM_FAILED_TASK_BULK_ACTION_MAX) and a missing
177
+ # one is an AttributeError at import — loud, and impossible to ship silently.
178
+ FAILED_TASK_BULK_ACTION_MAX: int = settings.FAILED_TASK_BULK_ACTION_MAX
179
+
180
+ # Statuses an operator can act on: a task that is already dismissed or retried is
181
+ # terminal and is skipped by bulk actions / rejected by single actions.
182
+ _ACTIONABLE_FAILED_STATUSES = (FailedTaskStatus.DEAD, FailedTaskStatus.PENDING_RETRY)
183
+
184
+ # Shared user-facing response details.
185
+ _NOT_FOUND_DETAIL = "Not found."
186
+ _DATE_RANGE_EXCEEDED_DETAIL = "Project schedule exceeds the representable date range."
187
+
188
+
189
+ @extend_schema(
190
+ summary="Trigger a CPM recalculation for a project",
191
+ request=None,
192
+ responses={
193
+ 202: OpenApiResponse(
194
+ response=inline_serializer(
195
+ name="ScheduleTriggerResult",
196
+ fields={"queued": serializers.BooleanField()},
197
+ ),
198
+ description='Recalculation queued via the outbox; body is {"queued": true}.',
199
+ ),
200
+ 403: OpenApiResponse(
201
+ description=(
202
+ "Caller lacks the Resource Manager role on the project, or the project "
203
+ "is archived (archived plans are read-only, and a recalculation rewrites "
204
+ "every task's dates). Both causes answer the same status — branch on "
205
+ "`detail`, not on 403 alone."
206
+ )
207
+ ),
208
+ 404: OpenApiResponse(description="Project not found."),
209
+ },
210
+ )
211
+ @declare_scope_in_body(
212
+ "@api_view function: the generated WrappedAPIView carries no project_url_kwarg, so "
213
+ "has_permission cannot scope this POST. The gate is the explicit "
214
+ "IsProjectScheduler().has_object_permission(request, None, project) call in the body."
215
+ )
216
+ @api_view(["POST"])
217
+ @permission_classes([IsAuthenticated, IsProjectScheduler, IsProjectNotArchived])
218
+ def trigger_schedule(request: Request, pk: str) -> Response:
219
+ """Manually trigger a CPM recalculation for a project.
220
+
221
+ Requires the requesting user to hold at least the Resource Manager role on the
222
+ project. The request is written to the transactional outbox and dispatched
223
+ immediately if the broker is available; otherwise the Beat drain task picks
224
+ it up within 30 seconds.
225
+
226
+ Returns 404 if the project does not exist.
227
+ """
228
+ try:
229
+ project = Project.objects.get(pk=pk, is_deleted=False)
230
+ except Project.DoesNotExist:
231
+ return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
232
+
233
+ # Permission check against the project object.
234
+ if not IsProjectScheduler().has_object_permission(request, None, project): # type: ignore[arg-type]
235
+ return Response({"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN)
236
+
237
+ # ...and the archived check, in the body for the same reason the membership check
238
+ # is (#3414). `IsProjectNotArchived` IS declared above, but this is an `@api_view`
239
+ # function: the generated `WrappedAPIView` carries no `project_url_kwarg`, the route
240
+ # spells the project `pk`, so `_project_pk_from_view` returns None and
241
+ # `has_permission` returns True — and DRF never calls `has_object_permission` on a
242
+ # function view. The declaration read as the gate while a manual recalculation ran
243
+ # happily against an archived plan, rewriting every task's dates.
244
+ assert_project_not_archived(project.pk)
245
+
246
+ # Defer until any outer transaction commits so the ScheduleRequest row is
247
+ # never visible to the drain before its parent write lands.
248
+ project_id = str(project.pk)
249
+ transaction.on_commit(
250
+ lambda: enqueue_recalculate(project_id, reason=ScheduleRequestReason.MANUAL)
251
+ )
252
+ return Response({"queued": True}, status=status.HTTP_202_ACCEPTED)
253
+
254
+
255
+ # The premium derivation moved to `risk_premium` when the metric was promoted off
256
+ # this payload onto the project (#2483). Aliased rather than reimplemented so the
257
+ # forecast panel and the Overview card can never disagree about the same gap.
258
+ _delta_vs_cpm_days = delta_vs_cpm_days
259
+
260
+
261
+ def _date_delta_days(
262
+ later: _date | None,
263
+ earlier: _date | None,
264
+ *,
265
+ later_at_day_start: bool | None = None,
266
+ earlier_at_day_start: bool | None = None,
267
+ calendar_for: Callable[[], Any] | None = None,
268
+ ) -> int | None:
269
+ """Signed delta ``later - earlier`` of two shown finishes, in working time.
270
+
271
+ Positive means ``later`` lands after ``earlier`` (the forecast slipped). Shared
272
+ by the what-if and history endpoints so a run-to-run / current-to-whatif delta is
273
+ computed one way. Each date is read with its edge of the day (a Monte Carlo
274
+ percentile's ``*_at_day_start``, #4204), so a percentile whose shown day hops a
275
+ weekend with no working-time move is 0; an unknown reading (a run persisted
276
+ before #4204) follows :func:`.finish_reading.finish_shift_days`. Still reported
277
+ in calendar days. ``None`` if either date is missing.
278
+ """
279
+ if later is None or earlier is None:
280
+ return None
281
+ return lazy_finish_shift_days(
282
+ (earlier, earlier_at_day_start), (later, later_at_day_start), calendar_for
283
+ )
284
+
285
+
286
+ def _mc_percentile_deltas(
287
+ later: Any, earlier: Any, calendar_for: Callable[[], Any] | None
288
+ ) -> dict[str, int | None]:
289
+ """``{p50, p80, p95}`` working-time deltas between two Monte Carlo results or runs."""
290
+ return {
291
+ q: _date_delta_days(
292
+ getattr(later, q),
293
+ getattr(earlier, q),
294
+ later_at_day_start=getattr(later, f"{q}_at_day_start", None),
295
+ earlier_at_day_start=getattr(earlier, f"{q}_at_day_start", None),
296
+ calendar_for=calendar_for,
297
+ )
298
+ for q in ("p50", "p80", "p95")
299
+ }
300
+
301
+
302
+ def _mc_deltas_vs_cpm(
303
+ run: Any,
304
+ cpm_finish: _date | None,
305
+ cpm_finish_at_day_start: bool | None,
306
+ calendar_for: Callable[[], Any] | None,
307
+ ) -> dict[str, int | None]:
308
+ """``{p50, p80, p95}`` of ``run`` vs the CPM finish, in working time (#4204)."""
309
+ return {
310
+ q: _delta_vs_cpm_days(
311
+ getattr(run, q),
312
+ cpm_finish,
313
+ percentile_at_day_start=getattr(run, f"{q}_at_day_start", None),
314
+ cpm_finish_at_day_start=cpm_finish_at_day_start,
315
+ calendar_for=calendar_for,
316
+ )
317
+ for q in ("p50", "p80", "p95")
318
+ }
319
+
320
+
321
+ def _mc_run_readings(run: Any) -> dict[str, bool | None]:
322
+ """The four edge-of-day readings of a persisted run, for a response payload."""
323
+ return {
324
+ f: getattr(run, f)
325
+ for f in (
326
+ "p50_at_day_start",
327
+ "p80_at_day_start",
328
+ "p95_at_day_start",
329
+ "cpm_finish_at_day_start",
330
+ )
331
+ }
332
+
333
+
334
+ def _cpm_finish_delta_days(earlier: Any, later: Any, calendar: Any) -> int | None:
335
+ """Signed shift of ``ScheduleResult.project_finish`` between two CPM passes, in working time.
336
+
337
+ Unlike :func:`_date_delta_days`, this reads each finish with its edge of the
338
+ day, so the shown day hopping a weekend with no working-time move is 0
339
+ (:mod:`.finish_reading`, #4178). Still reported in calendar days.
340
+ """
341
+ before = schedule_result_finish(earlier)
342
+ after = schedule_result_finish(later)
343
+ if before is None or after is None:
344
+ return None
345
+ return finish_shift_days(before, after, calendar)
346
+
347
+
348
+ def _confidence_curve(histogram: list[dict[str, object]], total: int) -> list[dict[str, object]]:
349
+ """Cumulative P(finish ≤ date) S-curve derived from the histogram buckets.
350
+
351
+ ``histogram`` is the already-binned ``[{date, count}]`` list in ascending date
352
+ order; ``total`` is the number of simulated runs. Returns one ``{date, pct}``
353
+ point per bucket carrying the cumulative share of runs that finished on or
354
+ before that bucket — the same value ``MonteCarloDetailPanel`` previously
355
+ accumulated in the browser (#987). Bounded to the bucket count (≤30 points).
356
+ """
357
+ if total <= 0:
358
+ return []
359
+ cumulative = 0
360
+ curve: list[dict[str, object]] = []
361
+ for bucket in histogram:
362
+ cumulative += cast(int, bucket["count"])
363
+ curve.append({"date": bucket["date"], "pct": round(cumulative / total * 100, 1)})
364
+ return curve
365
+
366
+
367
+ def _distribution_for_persist(payload: dict[str, Any]) -> dict[str, Any]:
368
+ """Shrink the distribution payload to fit MC_DISTRIBUTION_MAX_BYTES for storage (#1231).
369
+
370
+ Takes the ``{histogram_buckets, confidence_curve, sensitivity}`` slice of the
371
+ full result and, if its serialized size exceeds the cap, down-samples the
372
+ histogram (keeps every Nth bucket, always retaining the first and last so the
373
+ spread endpoints survive) until it fits. The confidence curve is recomputed
374
+ from the thinned buckets so the two stay consistent; sensitivity is left intact
375
+ (it is bounded by the task count, not the bucket count).
376
+
377
+ Returns a NEW dict — the caller's full ``result_dict`` (and the cache copy it
378
+ feeds) is never mutated, so the live view still serves the full-resolution
379
+ histogram while the persisted row is bounded against a pathological run.
380
+ """
381
+ dist = {
382
+ "histogram_buckets": payload.get("histogram_buckets", []),
383
+ "confidence_curve": payload.get("confidence_curve", []),
384
+ "sensitivity": payload.get("sensitivity", []),
385
+ }
386
+ cap = settings.MC_DISTRIBUTION_MAX_BYTES
387
+ if len(json.dumps(dist).encode()) <= cap:
388
+ return dist
389
+
390
+ buckets: list[dict[str, object]] = list(dist["histogram_buckets"])
391
+ total = sum(cast(int, b.get("count", 0)) for b in buckets)
392
+ # Increase the stride until the serialized payload fits. Always keep the first
393
+ # and last bucket so the distribution's min/max dates are preserved.
394
+ stride = 2
395
+ while len(json.dumps(dist).encode()) > cap and stride <= max(len(buckets), 2):
396
+ thinned = [b for i, b in enumerate(buckets) if i % stride == 0]
397
+ if buckets and buckets[-1] not in thinned:
398
+ thinned.append(buckets[-1])
399
+ dist = {
400
+ "histogram_buckets": thinned,
401
+ "confidence_curve": _confidence_curve(thinned, total),
402
+ "sensitivity": dist["sensitivity"],
403
+ }
404
+ stride += 1
405
+ return dist
406
+
407
+
408
+ def _build_sched_deps(project_pk: str, included_ids: set[str]) -> list[Any]:
409
+ """Convert a project's DB dependencies to scheduler ``Dependency`` inputs.
410
+
411
+ Drops any edge whose predecessor or successor is absent from ``included_ids``:
412
+ cross-project dependencies and edges to non-committed (BACKLOG) tasks both dangle
413
+ and would make the engine reject the network as referencing an unknown task.
414
+ Mirrors the CPM guard in scheduling.tasks (ADR-0090) — a single-project run
415
+ simulates only this project's committed tasks.
416
+
417
+ Reads through ``Dependency.live``: a soft-deleted edge is a constraint the
418
+ user removed, and forecasting against it reports a finish the plan no longer
419
+ implies (#3532).
420
+ """
421
+ from trueppm_scheduler.models import Dependency as SchedDependency
422
+ from trueppm_scheduler.models import DependencyType
423
+
424
+ db_deps = list(
425
+ Dependency.live.filter(predecessor__project_id=project_pk).select_related(
426
+ "predecessor", "successor"
427
+ )
428
+ )
429
+ return [
430
+ SchedDependency(
431
+ predecessor_id=str(d.predecessor_id),
432
+ successor_id=str(d.successor_id),
433
+ dep_type=DependencyType(d.dep_type),
434
+ lag=timedelta(days=d.lag),
435
+ )
436
+ for d in db_deps
437
+ if str(d.predecessor_id) in included_ids and str(d.successor_id) in included_ids
438
+ ]
439
+
440
+
441
+ def _mc_histogram(dist: list[_date]) -> list[dict[str, object]]:
442
+ """Bin a Monte Carlo finish-date distribution into ≤30 ascending ``{date, count}``
443
+ buckets.
444
+
445
+ ``dist`` is the engine's sorted finish-date sample (earliest first). Returns an
446
+ empty list for an empty distribution. The midpoint date of each bucket is used as
447
+ its label so the histogram is symmetric around the sampled span.
448
+ """
449
+ if not dist:
450
+ return []
451
+ min_ord = dist[0].toordinal()
452
+ max_ord = dist[-1].toordinal()
453
+ span = max(max_ord - min_ord, 1)
454
+ n_buckets = min(30, len(dist))
455
+ bucket_size = span / n_buckets
456
+ bucket_counts: dict[int, int] = {}
457
+ for d in dist:
458
+ idx = min(int((d.toordinal() - min_ord) / bucket_size), n_buckets - 1)
459
+ bucket_counts[idx] = bucket_counts.get(idx, 0) + 1
460
+ return [
461
+ {
462
+ "date": _date.fromordinal(min_ord + int((i + 0.5) * bucket_size)).isoformat(),
463
+ "count": bucket_counts.get(i, 0),
464
+ }
465
+ for i in range(n_buckets)
466
+ ]
467
+
468
+
469
+ def _persist_mc_run_if_authorized(
470
+ request: Request,
471
+ project: Project,
472
+ *,
473
+ mc_result: Any,
474
+ n_simulations: int,
475
+ cpm_finish: _date | None,
476
+ task_count: int,
477
+ result_dict: dict[str, Any],
478
+ status_date: _date | None,
479
+ plan_version: int | None,
480
+ cpm_finish_at_day_start: bool | None = None,
481
+ ) -> None:
482
+ """Persist an author-attributed ``MonteCarloRun`` drift row for Scheduler+ callers.
483
+
484
+ The forecast *read* is intentionally open to any project member (a Viewer may pull
485
+ the band), but the persisted run is a different act: it attributes drift to its
486
+ author and grows the retained-run set (#1502). Gating the read at Member and the
487
+ write at Scheduler stops a Viewer/Member from spamming ``run_monte_carlo`` to
488
+ inflate history and pollute drift attribution over a schedule they cannot own. The
489
+ ``mc_latest`` cache is a per-project read cache, not per-user history, so it stays
490
+ refreshed for every caller — only the attributed row is gated here.
491
+
492
+ Best-effort: a write failure inside the service is logged and swallowed by
493
+ ``record_monte_carlo_run``; ``result_dict`` gains ``run_id`` only when persistence
494
+ succeeded. Mutates ``result_dict`` in place.
495
+ """
496
+ from trueppm_api.apps.access.models import Role
497
+ from trueppm_api.apps.access.permissions import _membership_role
498
+
499
+ # Pass project.pk (canonical UUID), not the raw URL string, so this reuses the
500
+ # per-request role cache the earlier object-permission check already warmed under
501
+ # the same key — no second membership query.
502
+ role = _membership_role(request, project.pk)
503
+ if role is None or role < Role.SCHEDULER:
504
+ return
505
+
506
+ # Persist the same distribution slice the cache holds, but bounded to
507
+ # MC_DISTRIBUTION_MAX_BYTES via down-sampling (#1231) — the cache copy is
508
+ # full-resolution and untouched. Stored so the histogram + tornado survive cache
509
+ # expiry and a past run stays viewable.
510
+ distribution = _distribution_for_persist(result_dict)
511
+ run = record_monte_carlo_run(
512
+ str(project.pk),
513
+ p50=mc_result.p50,
514
+ p80=mc_result.p80,
515
+ p95=mc_result.p95,
516
+ n_simulations=n_simulations,
517
+ cpm_finish=cpm_finish,
518
+ task_count=task_count,
519
+ user=request.user,
520
+ distribution=distribution,
521
+ # Store the reason the run is (or is not) flat alongside it, so a later
522
+ # read from history can still tell "no estimates anywhere" apart from
523
+ # "estimates exist, variance is low" — the distinction the added-time
524
+ # metric is built on (#2483).
525
+ diagnostic=result_dict.get("forecast_diagnostic"),
526
+ # The data date actually fed to the engine for this run (ADR-0132's
527
+ # never-recorded substitution, #2638) — same value already echoed on
528
+ # result_dict below, so the persisted row and the response agree.
529
+ status_date=status_date,
530
+ # The plan version the run was computed against (#3140) — the same value
531
+ # already on result_dict, so the persisted row and the cache entry give the
532
+ # same answer once the TTL expires and the read falls back to history.
533
+ plan_version=plan_version,
534
+ # Which edge of its shown day each finish is (#4204), so a later history or
535
+ # `latest` read measures its deltas in working time too.
536
+ readings={
537
+ "p50_at_day_start": mc_result.p50_at_day_start,
538
+ "p80_at_day_start": mc_result.p80_at_day_start,
539
+ "p95_at_day_start": mc_result.p95_at_day_start,
540
+ "cpm_finish_at_day_start": cpm_finish_at_day_start,
541
+ },
542
+ )
543
+ if run is not None:
544
+ result_dict["run_id"] = str(run.id)
545
+
546
+
547
+ def _mc_lag_delta_kwargs() -> dict[str, int]:
548
+ """Request-path ``monte_carlo()`` kwargs bounding the lag-delta table (#4129).
549
+
550
+ Empty when ``MC_LAG_DELTA_CELL_CAP`` is ``None`` so the library default applies.
551
+ """
552
+ cap = getattr(settings, "MC_LAG_DELTA_CELL_CAP", None)
553
+ return {} if cap is None else {"max_lag_delta_cells": cap}
554
+
555
+
556
+ class MonteCarloRunThrottle(ScopedRateThrottle):
557
+ """Caps synchronous Monte Carlo runs per member (#1552) to bound DoS.
558
+
559
+ ``run_monte_carlo`` executes an expensive simulation inline in the request
560
+ cycle, gated only by project membership. Scoped per-user via ``monte_carlo``
561
+ (10/min) so no single account can loop the endpoint to exhaust CPU, while a
562
+ human tuning estimates and re-running the forecast stays well under the cap.
563
+
564
+ ``ScopedRateThrottle`` normally reads its scope from ``view.throttle_scope``,
565
+ but ``run_monte_carlo`` is a function-based ``@api_view`` that cannot carry
566
+ that attribute, so the scope is bound from this class instead. The cache key
567
+ is still per-user + scope (``get_cache_key`` is inherited unchanged).
568
+ """
569
+
570
+ scope = "monte_carlo"
571
+
572
+ def allow_request(self, request: Request, view: APIView) -> bool:
573
+ # Bind the fixed scope from the class rather than off the view (an
574
+ # @api_view FBV has no throttle_scope), then run the grandparent's
575
+ # sliding-window check directly. We deliberately skip
576
+ # ScopedRateThrottle.allow_request (it re-reads view.throttle_scope,
577
+ # absent on the FBV) by calling SimpleRateThrottle explicitly instead of
578
+ # via super() — same resolution, but honest about the intentional skip
579
+ # and it doesn't trip CodeQL's "super() first arg" reliability rule.
580
+ self.rate = self.get_rate()
581
+ self.num_requests, self.duration = self.parse_rate(self.rate)
582
+ return SimpleRateThrottle.allow_request(self, request, view)
583
+
584
+
585
+ @extend_schema(
586
+ request=OpenApiTypes.OBJECT,
587
+ responses={
588
+ 200: OpenApiResponse(
589
+ response=MonteCarloForecastSerializer,
590
+ description=(
591
+ "Monte Carlo simulation result. Includes the engine result fields "
592
+ "(P50/P80/P95 finish dates, mean, std dev, etc.) plus "
593
+ "cpm_finish (deterministic CPM project finish, ISO 8601 or null), "
594
+ "p50_at_day_start/p80_at_day_start/p95_at_day_start and "
595
+ "cpm_finish_at_day_start (which edge of its shown day each finish is — true = "
596
+ "the start of the day, a start-of-day milestone finish; #4204), "
597
+ "delta_vs_cpm ({p50,p80,p95} signed calendar-day premium vs CPM, measured "
598
+ "in working time: the start of a Monday vs the end of the Friday before it "
599
+ "is 0), "
600
+ "confidence_curve ([{date, pct}] cumulative finish-by-date S-curve), "
601
+ "histogram_buckets ([{date, count}]), sensitivity ([{task_id, index}] "
602
+ "duration tornado — tasks that move the finish most, index 0..1, "
603
+ "ADR-0140), forecast_diagnostic ({deterministic, reason, tasks_total, "
604
+ "tasks_with_variance, tasks_pending_approval, agile_tasks_without_velocity, "
605
+ "tasks_estimates_below_plan} "
606
+ "— explains a flat forecast; reason is null when a real band exists), "
607
+ "last_run_at (ISO 8601), status_date (the data date this run was "
608
+ "actually computed against — project.status_date or today when unset, "
609
+ "ADR-0132/#2638), and the risk_premium_* family (ADR-0698): "
610
+ "risk_premium_state (not_run|unmeasurable|stale|zero|premium|negative — "
611
+ "the discriminant every client renders from, never null), "
612
+ "risk_premium_days, risk_premium_ratio, risk_premium_band (always null "
613
+ "until #2299), risk_premium_as_of, risk_premium_reason, "
614
+ "risk_premium_cpm_finish, risk_premium_p80. Derived at response time, "
615
+ "never cached, so the ratio never describes a remaining duration that "
616
+ "has since elapsed. Plus the forecast-staleness family (#3140): "
617
+ "forecast_staleness (current|project_changed|aged|unknown — the "
618
+ "discriminant every client renders from, never null; only `current` "
619
+ "means the forecast still describes the plan, and clients keep the "
620
+ "recompute action reachable on the other three), plan_version (the "
621
+ "project's sync version this run was computed against; null for runs "
622
+ "recorded before #3140, which classify as `unknown`, never `current`), "
623
+ "and plan_version_current (that version as of this response). "
624
+ "`project_changed` is deliberately not named `plan_changed`: the "
625
+ "underlying counter advances on ANY write in the project, so a "
626
+ "description or label edit raises it without moving a date. It is a "
627
+ "sound trigger for offering a recompute and NOT evidence that a "
628
+ "schedule value changed — do not narrate it as one."
629
+ ),
630
+ ),
631
+ 400: OpenApiResponse(
632
+ response=OpenApiTypes.OBJECT,
633
+ description=(
634
+ "Cyclic dependency, invalid input, or out-of-range project span; or "
635
+ "an explicit n_simulations whose runs x tasks product exceeds "
636
+ "MC_CELL_CAP (#2839) — body {error: simulation_cell_cap_exceeded, "
637
+ "message, max_runs, task_count}, where max_runs is the largest count "
638
+ "this project admits. An omitted n_simulations is never refused for "
639
+ "size: it runs max_runs iterations and the 200 body's `runs` reports "
640
+ "the count actually simulated."
641
+ ),
642
+ examples=[
643
+ OpenApiExample(
644
+ "simulation_cell_cap_exceeded",
645
+ value={
646
+ "error": "simulation_cell_cap_exceeded",
647
+ "message": "1000 simulation runs over 5000 tasks exceeds ...",
648
+ "max_runs": 500,
649
+ "task_count": 5000,
650
+ },
651
+ ),
652
+ ],
653
+ ),
654
+ 402: OpenApiResponse(
655
+ response=OpenApiTypes.OBJECT,
656
+ description=(
657
+ "OSS simulation cap exceeded (n_simulations above MC_SIMULATION_CAP "
658
+ "or too many tasks)."
659
+ ),
660
+ examples=[
661
+ OpenApiExample(
662
+ "simulation_cap_exceeded",
663
+ value={
664
+ "error": "simulation_cap_exceeded",
665
+ "tier": "team",
666
+ "message": "Simulation count exceeds the community-edition cap.",
667
+ },
668
+ ),
669
+ ],
670
+ ),
671
+ 403: OpenApiResponse(
672
+ response=OpenApiTypes.OBJECT,
673
+ description=(
674
+ "Caller is not a member of the project, or the project is archived — a "
675
+ "run refreshes the project's forecast cache and, for Scheduler+, "
676
+ "appends a drift-history row, so it is a write."
677
+ ),
678
+ ),
679
+ 404: OpenApiResponse(
680
+ response=OpenApiTypes.OBJECT,
681
+ description="Project does not exist.",
682
+ ),
683
+ 503: OpenApiResponse(
684
+ response=OpenApiTypes.OBJECT,
685
+ description=(
686
+ "This API process is already running MC_MAX_CONCURRENT simulations "
687
+ "and none finished within the wait window (#2839). Body {error: "
688
+ "simulation_busy, message}; retry after the Retry-After header."
689
+ ),
690
+ ),
691
+ },
692
+ )
693
+ @declare_scope_in_body(
694
+ "@api_view function: the generated WrappedAPIView carries no project_url_kwarg, so "
695
+ "has_permission cannot scope this POST. The gate is the explicit "
696
+ "IsProjectMember().has_object_permission(request, None, project) call in the body."
697
+ )
698
+ @api_view(["POST"])
699
+ @permission_classes([IsAuthenticated, IsProjectMember, IsProjectNotArchived])
700
+ @throttle_classes([MonteCarloRunThrottle])
701
+ def run_monte_carlo(request: Request, pk: str) -> Response:
702
+ """Run a Monte Carlo probabilistic schedule simulation synchronously.
703
+
704
+ Monte Carlo is fast (< 100 ms vectorised) so this runs in the request/
705
+ response cycle rather than via Celery; the result is returned directly. The
706
+ per-project ``mc_latest`` read cache is refreshed for every caller, but a
707
+ persisted, author-attributed drift-history row (MonteCarloRun) is written
708
+ only for Scheduler+ — the forecast read is Member-level, its record is not
709
+ (#1502). Requests are rate-limited per user via the ``monte_carlo`` scope.
710
+
711
+ Synchronous-by-design decision (#1203): keeping this inline is only safe
712
+ because *every* multiplicative cost factor the run depends on is now bounded
713
+ by the engine — tasks (MC_TASK_CAP), simulation runs (MC_SIMULATION_CAP),
714
+ raw dependency edges (MAX_DEPENDENCIES), expanded edges (MAX_EXPANDED_EDGES),
715
+ the distinct-lag delta arrays (MC_LAG_DELTA_CELL_CAP, 5M int32 cells = ~20 MB —
716
+ the library's 50M-cell MAX_LAG_DELTA_CELLS is a batch-caller default), and the
717
+ velocity horizon (MAX_VELOCITY_SPRINTS) — and #1201 vectorised the previously O(edges x span)
718
+ per-edge lag precompute. With the input space bounded and the hot path
719
+ vectorised, the worst-case wall time stays within the request budget, so
720
+ moving to a Celery job (extra latency, result polling, a new failure surface)
721
+ would add cost without removing a real risk. The argument is about memory as
722
+ well as wall time (#4129): the lag-delta table scales with distinct lags x
723
+ span, not with runs, and was the one factor no request cap bounded (a project
724
+ inside every other cap peaked near 630 MB), so it is now capped at
725
+ MC_LAG_DELTA_CELL_CAP and the table stays at ~20 MB per simulation.
726
+ Concurrent-request admission is bounded per process by MC_MAX_CONCURRENT
727
+ (#2839, below). A project that breaches the edge
728
+ cap is rejected as InvalidScheduleInput (a ValueError) and surfaces below as a
729
+ clean 400 rather than a stalled worker.
730
+
731
+ That argument depends on cap *order*, which #3527 had to preserve deliberately.
732
+ Summary expansion moved onto this path, and it walks the edge list — so it now
733
+ runs behind ``build_sched_graph``'s own O(1) ``MAX_DEPENDENCIES`` length check
734
+ rather than waiting for ``_validate_project``, which the engine does not reach
735
+ until ``monte_carlo()`` is already called. Without that, the expensive per-edge
736
+ step would run ahead of the cheap guard that is supposed to bound it.
737
+
738
+ The per-dimension caps still let ``runs x tasks`` reach 5M cells (~234 MB traced
739
+ per simulation), so admission also caps that product (MC_CELL_CAP) and the
740
+ simulations one process runs at once (MC_MAX_CONCURRENT) — see
741
+ ``mc_admission`` (#2839).
742
+
743
+ Request body (all optional):
744
+ n_simulations (int): Number of simulation runs. Defaults to the largest
745
+ count this project's size admits — settings.MC_SIMULATION_CAP, or
746
+ fewer under MC_CELL_CAP on a large project; the response's ``runs``
747
+ reports the count simulated. An explicit value is honored exactly or
748
+ refused; it must not exceed either cap.
749
+
750
+ Returns 200 on success, 402 if the OSS simulation cap is exceeded,
751
+ 400 if the schedule input is invalid (e.g. the dependency-edge cap is
752
+ breached) or an explicit n_simulations breaches MC_CELL_CAP, 503 if the
753
+ process has no free simulation slot, 404 if the project does not exist,
754
+ 403 if the caller lacks read access.
755
+ """
756
+ from trueppm_scheduler.engine import CyclicDependencyError, SimulationCapExceeded, monte_carlo
757
+ from trueppm_scheduler.models import Project as SchedProject
758
+
759
+ try:
760
+ project = (
761
+ Project.objects.select_related("calendar", "program__calendar")
762
+ # calendar__exceptions + calendar_layers__calendar__exceptions:
763
+ # compose_project_calendar reads the base calendar's exceptions (#1491)
764
+ # AND every applied overlay's (#906); prefetch both to avoid an N+1.
765
+ .prefetch_related(
766
+ "tasks",
767
+ "calendar__exceptions",
768
+ "calendar_layers__calendar__exceptions",
769
+ # ADR-0441: the resolver reads the program calendar's exceptions when a
770
+ # project inherits its base calendar from the program.
771
+ "program__calendar__exceptions",
772
+ )
773
+ .get(pk=pk, is_deleted=False)
774
+ )
775
+ except Project.DoesNotExist:
776
+ return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
777
+
778
+ if not IsProjectMember().has_object_permission(request, None, project): # type: ignore[arg-type]
779
+ return Response({"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN)
780
+
781
+ # Same #3414 shape as `trigger_schedule`: the declared `IsProjectNotArchived` cannot
782
+ # fire on an `@api_view` function whose route spells the project `pk`. A run is not a
783
+ # pure read — it refreshes the project's `mc_latest` cache and, for Scheduler+,
784
+ # appends an author-attributed `MonteCarloRun` row, so an archived plan's forecast
785
+ # history could be extended indefinitely after it was frozen.
786
+ assert_project_not_archived(project.pk)
787
+
788
+ cap: int | None = settings.MC_SIMULATION_CAP
789
+ # A JSON `null` (or absent) body parses to request.data == None; the body is
790
+ # documented as fully optional, so fall back to the default rather than
791
+ # letting None.get(...) raise AttributeError → 500 (#2310).
792
+ body = request.data if isinstance(request.data, dict) else {}
793
+ # Absent means "the admissible maximum for this project's size" (#2839), which
794
+ # `admit_runs` resolves once the network size is known; an explicit value is
795
+ # honored exactly or refused, never silently shrunk.
796
+ n_explicit = "n_simulations" in body
797
+ raw_n = body.get("n_simulations", cap or 1_000)
798
+ # int() coerces without raising on a JSON bool (bool is an int subtype) or a
799
+ # JSON float (silently truncated) — neither is the documented integer
800
+ # contract, so reject both before the cast rather than trusting int() to
801
+ # catch them (#4208). Digit strings ("100") stay allowed for callers that
802
+ # send n_simulations as text.
803
+ if isinstance(raw_n, bool) or not isinstance(raw_n, (int, str)):
804
+ return Response(
805
+ {"detail": "n_simulations must be an integer."},
806
+ status=status.HTTP_400_BAD_REQUEST,
807
+ )
808
+ try:
809
+ n_simulations: int = int(raw_n)
810
+ except (TypeError, ValueError):
811
+ return Response(
812
+ {"detail": "n_simulations must be an integer."},
813
+ status=status.HTTP_400_BAD_REQUEST,
814
+ )
815
+ if n_simulations < 1:
816
+ return Response(
817
+ {"detail": "n_simulations must be a positive integer."},
818
+ status=status.HTTP_400_BAD_REQUEST,
819
+ )
820
+
821
+ # The plan version this run is about to be computed against (#3140) — the
822
+ # project's ADR-0686 sync sequence, already on the instance loaded above, so
823
+ # this costs nothing. Read HERE, before the committed task set is loaded
824
+ # below, and the ordering is the correctness property: a write that lands
825
+ # between this line and the query leaves the run recorded against the older
826
+ # version and therefore reading stale. Capturing it afterwards would claim
827
+ # the run covered a write it never saw — false-fresh, the one direction that
828
+ # loses the user the Rerun action.
829
+ plan_version = project.last_sync_version
830
+
831
+ # Shared converter (#1491): includes the calendar's CalendarException
832
+ # holiday/shutdown ranges, which the previous inline construction dropped —
833
+ # so Monte Carlo silently scheduled straight through configured holidays.
834
+ sched_calendar = compose_project_calendar(project)
835
+
836
+ # Monte Carlo simulates committed delivery only — BACKLOG cards are not
837
+ # part of the forecast. ADR-0057 / Task.committed.
838
+ # select_related("sprint"): build_sched_tasks reads sprint.start_date for the
839
+ # ADR-0168 sprint-window floor; load it with the task to avoid an N+1.
840
+ db_tasks = list(Task.committed.filter(project=project).select_related("sprint"))
841
+
842
+ # Shared converter (ADR-0132): the deterministic CPM pass and Monte Carlo
843
+ # build their scheduler input through one function, so a field can never reach
844
+ # one engine and not the other — the drift that caused #1185 (MC had silently
845
+ # dropped planned_start). build_sched_tasks honors the planned_start floor for
846
+ # every task (the #1185 fix, now centralized) and withholds pending three-point
847
+ # estimates in SUGGEST_APPROVE mode.
848
+ suggest_approve = project.estimation_mode == EstimationMode.SUGGEST_APPROVE
849
+ sched_tasks = build_sched_tasks(db_tasks, suggest_approve=suggest_approve)
850
+
851
+ included_ids = {str(t.id) for t in db_tasks}
852
+ sched_deps = _build_sched_deps(pk, included_ids)
853
+
854
+ # Shared graph shaping (#3527): summary (phase) rows are grouping nodes, not
855
+ # schedulable work (ADR-0105), so they are stripped and their edges fanned down
856
+ # to leaves before the simulation — exactly as the deterministic CPM pass does.
857
+ # Sharing only build_sched_tasks was not enough: every phase used to enter the
858
+ # simulation as a never-started, unconstrained block floored at the data date,
859
+ # and the longest one became the simulated finish. That made the forecast flat,
860
+ # unrelated to the network, and pushed *further out* by recording real progress.
861
+ # Inside a try, like every other engine call here: build_sched_graph raises
862
+ # InvalidScheduleInput (a ValueError) for a Start-to-Start/Start-to-Finish link
863
+ # from a summary, a breached edge cap, or a malformed WBS. The project exception
864
+ # handler only reclassifies malformed-UUID ValueErrors, so an unguarded raise
865
+ # would be a 500 — and this endpoint promises a clean 400 for exactly these.
866
+ try:
867
+ graph = build_sched_graph(db_tasks, sched_tasks, sched_deps)
868
+ except (CyclicDependencyError, ValueError) as exc:
869
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
870
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
871
+
872
+ # Agile-aware Monte Carlo (#411, ADR-0065/0106): feed the team's completed-sprint
873
+ # throughput to the engine so SCRUM/story-point tasks sample sprints-to-completion
874
+ # from real velocity variance. Without this the engine's velocity path can never
875
+ # fire — every story falls back to its deterministic placeholder duration (1 day),
876
+ # so an all-agile project (e.g. a board-driven program project) forecasts a single
877
+ # flat date with no uncertainty. ([], None) when there is no velocity signal, which
878
+ # leaves a waterfall/PERT project's forecast unchanged.
879
+ from trueppm_api.apps.projects.services import scheduler_velocity_inputs
880
+
881
+ velocity_samples, sprint_length_days = scheduler_velocity_inputs(
882
+ project.pk, sched_calendar.working_days
883
+ )
884
+
885
+ # Data date for the forecast (ADR-0132): the project's explicit status date, or
886
+ # today when unset — so Monte Carlo never schedules remaining work in the past
887
+ # and pins completed work to its actuals. Named so the resolved value can be
888
+ # persisted on the run and echoed in the response (#2638) — previously this
889
+ # substitution happened inline and was never recorded anywhere.
890
+ mc_status_date = project.status_date or timezone.localdate()
891
+
892
+ # runs x n_tasks admission (#2839): the per-dimension caps let the product reach
893
+ # 5M cells (~234 MB traced per simulation); MC_CELL_CAP bounds the product.
894
+ admission = admit_runs(len(graph.tasks), n_simulations if n_explicit else None)
895
+ if admission.refusal is not None:
896
+ return admission.refusal
897
+ n_simulations = admission.runs
898
+
899
+ sched_project = SchedProject(
900
+ id=str(project.pk),
901
+ name=project.name,
902
+ start_date=project.start_date,
903
+ tasks=graph.tasks,
904
+ dependencies=graph.dependencies,
905
+ calendar=sched_calendar,
906
+ status_date=mc_status_date,
907
+ velocity_samples=velocity_samples or None,
908
+ sprint_length_days=sprint_length_days,
909
+ )
910
+
911
+ try:
912
+ # Manual Monte Carlo span (#709): times the simulation inline in the request
913
+ # cycle and records the run count; a no-op span unless OTel is configured.
914
+ # The slot bounds how many simulations this process runs at once (#2839).
915
+ with simulation_slot() as admitted:
916
+ if not admitted:
917
+ return busy_response()
918
+ with monte_carlo_span(pk, simulation_count=n_simulations):
919
+ mc_result = monte_carlo(
920
+ sched_project,
921
+ runs=n_simulations,
922
+ max_runs=cap,
923
+ max_tasks=settings.MC_TASK_CAP,
924
+ **_mc_lag_delta_kwargs(),
925
+ )
926
+ except SimulationCapExceeded as exc:
927
+ return Response(
928
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
929
+ {
930
+ "error": "simulation_cap_exceeded",
931
+ "tier": "team",
932
+ "message": str(exc),
933
+ },
934
+ status=status.HTTP_402_PAYMENT_REQUIRED,
935
+ )
936
+ except (CyclicDependencyError, ValueError) as exc:
937
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
938
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
939
+ except OverflowError:
940
+ # Defense in depth: the engine's MAX_PROJECT_SPAN_DAYS guard makes this
941
+ # unreachable for any realistic start date, but never let a date-range
942
+ # overflow surface as a 500 (OverflowError is not a ValueError).
943
+ return Response(
944
+ {"detail": _DATE_RANGE_EXCEEDED_DETAIL},
945
+ status=status.HTTP_400_BAD_REQUEST,
946
+ )
947
+
948
+ dist = mc_result.distribution
949
+ histogram = _mc_histogram(dist)
950
+
951
+ # The deterministic CPM spine is the max early_finish of the committed tasks
952
+ # already loaded above — no extra query. It anchors delta_vs_cpm (the risk
953
+ # premium each percentile adds over the deterministic finish) and is itself
954
+ # the project's deterministic schedule finish (#987 — single source, no
955
+ # duplicate project field). Computed before result_dict so the cached latest
956
+ # payload carries it.
957
+ cpm_finish = max(
958
+ (t.early_finish for t in db_tasks if t.early_finish is not None),
959
+ default=None,
960
+ )
961
+ # Which edge of that day the CPM finish is (#4204) — the same reading rule as
962
+ # every other project finish (#4178), from the rows already in memory. A
963
+ # percentile is compared with it in working time, so a start-of-Monday
964
+ # milestone finish is not three days of risk premium over the end of Friday.
965
+ cpm_reading = latest_finish(
966
+ (t.early_finish, task_finish_at_day_start(t), t.wbs_path) for t in db_tasks
967
+ )
968
+ cpm_finish_at_day_start = cpm_reading[1] if cpm_reading is not None else None
969
+
970
+ # `last_run_at` lets the frontend surface a "Last run: 2h ago" freshness
971
+ # signal and decide whether to nudge a rerun (#335). Captured at cache-write
972
+ # time so it always tracks the most recent successful simulation, never the
973
+ # cache read. Held as a datetime as well as an ISO string because the risk
974
+ # premium ages the run against it (ADR-0698).
975
+ run_at = timezone.now()
976
+ result_dict = {
977
+ **mc_result.to_dict(),
978
+ "cpm_finish": cpm_finish.isoformat() if cpm_finish else None,
979
+ "cpm_finish_at_day_start": cpm_finish_at_day_start,
980
+ "delta_vs_cpm": _mc_deltas_vs_cpm(
981
+ mc_result, cpm_finish, cpm_finish_at_day_start, lambda: sched_calendar
982
+ ),
983
+ "confidence_curve": _confidence_curve(histogram, len(dist)),
984
+ "histogram_buckets": histogram,
985
+ # Why the forecast has (or lacks) a band, so the UI can explain a flat
986
+ # result instead of presenting a misleadingly precise single date (#1340).
987
+ # Computed from the already-loaded committed task set + estimation mode +
988
+ # velocity signal — no extra query.
989
+ "forecast_diagnostic": forecast_diagnostic(
990
+ # The LEAF rows, not every committed row (#3527): the diagnostic explains
991
+ # what the simulation did, and a summary is not in the simulated network.
992
+ # Counting phases made `tasks_total` overstate the run and let a
993
+ # fully-complete project miss `all_complete` — a summary's stored
994
+ # percent_complete is 0, so every phase read as incomplete work.
995
+ graph.leaf_db_tasks,
996
+ suggest_approve=suggest_approve,
997
+ has_velocity_signal=bool(velocity_samples),
998
+ deterministic=(mc_result.p50 == mc_result.p80 == mc_result.p95),
999
+ ),
1000
+ "last_run_at": run_at.isoformat(),
1001
+ # The plan version this run was computed against (#3140), captured above
1002
+ # before the task set was read. Carried on the cache entry — not only on
1003
+ # the persisted row below — because a run triggered by a *Member* refreshes
1004
+ # the cache but persists no attributed row (#1502), so for those runs the
1005
+ # cache entry is the only place this observation exists. Unlike the
1006
+ # risk-premium family, this is not derivable at read time from anything.
1007
+ "plan_version": plan_version,
1008
+ # The data date this run was actually computed against (ADR-0132, #2638):
1009
+ # the project's explicit status_date, or today when unset. Carried on the
1010
+ # cache entry too (it is part of ``result_dict``) so a cached read states
1011
+ # the same provenance as a fresh run, and on the persisted MonteCarloRun
1012
+ # row below so history stays re-derivable after the fact.
1013
+ "status_date": mc_status_date.isoformat(),
1014
+ }
1015
+ cache.set(mc_latest_cache_key(pk), result_dict, timeout=86400)
1016
+
1017
+ # Persist a forecast-drift history row (ADR-0175, #961) only for Scheduler+
1018
+ # callers; the read is Member-level but the attributed write is not (#1502).
1019
+ _persist_mc_run_if_authorized(
1020
+ request,
1021
+ project,
1022
+ mc_result=mc_result,
1023
+ n_simulations=n_simulations,
1024
+ cpm_finish=cpm_finish,
1025
+ # The size of the network actually simulated (#3527) — summaries are
1026
+ # stripped before the run, so counting them here would describe an input
1027
+ # the engine never saw.
1028
+ task_count=len(graph.tasks),
1029
+ result_dict=result_dict,
1030
+ status_date=mc_status_date,
1031
+ plan_version=plan_version,
1032
+ cpm_finish_at_day_start=cpm_finish_at_day_start,
1033
+ )
1034
+ # Added time (#2531, ADR-0698) rides the response but NOT the cache entry above.
1035
+ # Two of its terms depend on when the response is built rather than when the run
1036
+ # happened: `ratio` divides by the duration remaining as of today, which collapses
1037
+ # to null once the CPM finish passes, and `state` flips to `stale` with age. A
1038
+ # premium frozen at cache-write would keep reporting a share of a remainder that
1039
+ # has since elapsed, so it is derived per response instead.
1040
+ current_plan_version, current_recalculated_at = _current_plan_state(pk)
1041
+ return Response(
1042
+ {
1043
+ **result_dict,
1044
+ **risk_premium_from_values(
1045
+ p80=mc_result.p80,
1046
+ cpm_finish=cpm_finish,
1047
+ taken_at=run_at,
1048
+ diagnostic=result_dict["forecast_diagnostic"],
1049
+ today=timezone.localdate(),
1050
+ p80_at_day_start=mc_result.p80_at_day_start,
1051
+ cpm_finish_at_day_start=cpm_finish_at_day_start,
1052
+ calendar_for=lambda: sched_calendar,
1053
+ ),
1054
+ # Staleness is re-read rather than assumed `current` (#3140). The project's
1055
+ # version is fetched again here because a synced write can land *during* the
1056
+ # simulation, and that run genuinely does not cover it — telling the user so
1057
+ # immediately is the whole point of the discriminant. One indexed pk read on
1058
+ # an endpoint that just ran a Monte Carlo simulation is not a cost worth
1059
+ # trading a false `current` for.
1060
+ **forecast_staleness_facts(
1061
+ plan_version=plan_version,
1062
+ plan_version_current=current_plan_version,
1063
+ taken_at=run_at,
1064
+ recalculated_at=current_recalculated_at,
1065
+ today=timezone.localdate(),
1066
+ ),
1067
+ }
1068
+ )
1069
+
1070
+
1071
+ class MonteCarloLatestView(McpReadableViewMixin, APIView):
1072
+ """Return the cached result of the most recent MC simulation for this project.
1073
+
1074
+ Returns 200 with the full result dict if a cached result exists, or 404 if
1075
+ no simulation has been run since the 24-hour cache TTL last expired.
1076
+
1077
+ Permission: Member (any role ≥ Viewer).
1078
+ """
1079
+
1080
+ # ADR-0678 (#2482): projects/<pk>/monte-carlo/latest/
1081
+ mcp_scope = McpScope.PATH
1082
+
1083
+ mcp_compute_heavy = True # part of the F4 compute-heavy MCP tool set (#1808)
1084
+ # Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
1085
+ # this the project-scoped classes below resolve nothing and `has_permission`
1086
+ # returns True for everyone — the gate reads as live in review and enforces
1087
+ # nothing. Declared per view because `pk` means something else on other routes.
1088
+ project_url_kwarg = "pk"
1089
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
1090
+
1091
+ @extend_schema(
1092
+ summary="Latest cached Monte Carlo forecast for the project",
1093
+ responses={
1094
+ 200: OpenApiResponse(
1095
+ response=MonteCarloForecastSerializer,
1096
+ description=(
1097
+ "The most recent Monte Carlo result for the project. Keys: p50/p80/p95 "
1098
+ "(ISO-8601 finish dates), p50/p80/p95_at_day_start and cpm_finish, "
1099
+ "cpm_finish_at_day_start (which edge of its shown day each finish is; "
1100
+ "null on a run persisted before #4204), delta_vs_cpm ({p50, p80, p95} "
1101
+ "calendar-day deltas vs the CPM finish, measured in working time), "
1102
+ "runs (n_simulations), "
1103
+ "confidence_curve, histogram_buckets, sensitivity, last_run_at, "
1104
+ "status_date (the data date this run was computed against — "
1105
+ "project.status_date or today when unset, ADR-0132/#2638; null for "
1106
+ "runs persisted before #2638), from_history (true when served from a "
1107
+ "persisted run after the 24h cache TTL expired), and the risk_premium_* "
1108
+ "family (ADR-0698): "
1109
+ "risk_premium_state (not_run|unmeasurable|stale|zero|premium|negative — "
1110
+ "the discriminant every client renders from, never null), "
1111
+ "risk_premium_days, risk_premium_ratio, risk_premium_band (always null "
1112
+ "until #2299), risk_premium_as_of, risk_premium_reason, "
1113
+ "risk_premium_cpm_finish, risk_premium_p80. The premium is derived on "
1114
+ "every response and is never stored in the cache entry. Legacy runs with "
1115
+ "no stored distribution return empty "
1116
+ "confidence_curve/histogram_buckets/sensitivity arrays. "
1117
+ "Plus the forecast-staleness family (#3140): forecast_staleness "
1118
+ "(current|project_changed|aged|unknown — never null; only `current` "
1119
+ "means the forecast still describes the plan, and a client keeps the "
1120
+ "recompute action reachable on the other three), plan_version (the "
1121
+ "project's sync version this run was computed against — unlike the "
1122
+ "risk_premium family this one IS carried on the cache entry and the "
1123
+ "persisted row, because it is a run-time observation that cannot be "
1124
+ "re-derived later; null for runs recorded before #3140, which "
1125
+ "classify as `unknown`, never `current`), and plan_version_current "
1126
+ "(that version as of this response). `project_changed` is "
1127
+ "deliberately not named `plan_changed`: the underlying counter "
1128
+ "advances on ANY write in the project, so a description or label "
1129
+ "edit raises it without moving a date. It is a sound trigger for "
1130
+ "offering a recompute and NOT evidence that a schedule value "
1131
+ "changed — do not narrate it as one."
1132
+ ),
1133
+ ),
1134
+ 404: OpenApiResponse(
1135
+ response=OpenApiTypes.OBJECT,
1136
+ description="No simulation result available for this project ({detail}).",
1137
+ ),
1138
+ },
1139
+ )
1140
+ def get(self, request: Request, pk: str) -> Response:
1141
+ """Return the latest cached Monte Carlo result for the project.
1142
+
1143
+ Falls back to the most recent persisted ``MonteCarloRun`` when the 24-hour
1144
+ cache has expired (ADR-0175): the latest forecast now survives past the
1145
+ TTL. As of #1231 the run also persists its distribution, so when present the
1146
+ fallback returns the real ``histogram_buckets``/``confidence_curve``/
1147
+ ``sensitivity`` instead of empty arrays — the histogram survives cache
1148
+ expiry. Legacy runs (no persisted distribution) still fall back to empty,
1149
+ and the frontend renders the empty-state prose for them.
1150
+ """
1151
+ project = get_object_or_404(Project, pk=pk, is_deleted=False)
1152
+ self.check_object_permissions(request, project)
1153
+ cached = cache.get(mc_latest_cache_key(pk))
1154
+ if cached is not None:
1155
+ # Added time is layered on at read time, never taken from the entry — the
1156
+ # entry has no premium keys to take (ADR-0698 §2). A cached forecast can be
1157
+ # served for 24 hours, and both `risk_premium_ratio` (a share of the
1158
+ # duration still remaining today) and `risk_premium_state` (which ages into
1159
+ # `stale`) would be wrong by the time it is read back.
1160
+ return Response(
1161
+ {
1162
+ **cached,
1163
+ **risk_premium_for_forecast_payload(
1164
+ cached,
1165
+ today=timezone.localdate(),
1166
+ calendar_for=lambda: compose_project_calendar(project),
1167
+ ),
1168
+ # Same read-time discipline, different reason (#3140): the entry
1169
+ # carries the plan version the run SAW, and the project has been free
1170
+ # to move for up to 24 hours since. `project` is already loaded, so
1171
+ # the comparison costs nothing. An entry written before #3140 has no
1172
+ # `plan_version` key and classifies as `unknown` — honest, and it
1173
+ # self-heals on the next run, which is why `MC_LATEST_CACHE_VERSION`
1174
+ # is deliberately NOT bumped here: unlike the #2833 engine fix, no
1175
+ # cached *number* is wrong, only unplaceable.
1176
+ **forecast_staleness_for_payload(
1177
+ cached,
1178
+ plan_version_current=project.last_sync_version,
1179
+ recalculated_at=project.recalculated_at,
1180
+ today=timezone.localdate(),
1181
+ ),
1182
+ }
1183
+ )
1184
+
1185
+ latest = MonteCarloRun.objects.filter(project_id=pk).order_by("-taken_at").first()
1186
+ if latest is None:
1187
+ return Response(
1188
+ {"detail": "No simulation result available."},
1189
+ status=status.HTTP_404_NOT_FOUND,
1190
+ )
1191
+ # cpm_finish + delta_vs_cpm survive the TTL because both are persisted on the
1192
+ # run (ADR-0175). The distribution (#1231) now survives too when persisted:
1193
+ # use the stored buckets/curve/sensitivity, falling back to empty arrays for
1194
+ # legacy runs that pre-date persistence (the frontend shows the empty-state
1195
+ # prose then). Response shape is unchanged (snake_case keys).
1196
+ dist = latest.distribution or {}
1197
+
1198
+ # Composed only if a start-of-day reading makes the working-time comparison
1199
+ # need it (#4204); memoized so the deltas and the premium share one.
1200
+ calendar_memo: list[Any] = []
1201
+
1202
+ def calendar_for() -> Any:
1203
+ if not calendar_memo:
1204
+ calendar_memo.append(compose_project_calendar(project))
1205
+ return calendar_memo[0]
1206
+
1207
+ return Response(
1208
+ {
1209
+ # The live-run payload has carried `project_id` since #172; the
1210
+ # from-history branch omitted it, and the client maps it straight
1211
+ # through as a non-optional field. Harmless while nothing read it —
1212
+ # but the added-time card's "Add estimates →" target is built from it
1213
+ # (#2531), so past the 24h TTL that link resolved to
1214
+ # `/projects/undefined`.
1215
+ "project_id": str(latest.project_id),
1216
+ "p50": latest.p50.isoformat() if latest.p50 else None,
1217
+ "p80": latest.p80.isoformat() if latest.p80 else None,
1218
+ "p95": latest.p95.isoformat() if latest.p95 else None,
1219
+ "cpm_finish": latest.cpm_finish.isoformat() if latest.cpm_finish else None,
1220
+ # null on a run persisted before #4204 (reading unknown).
1221
+ **_mc_run_readings(latest),
1222
+ "delta_vs_cpm": _mc_deltas_vs_cpm(
1223
+ latest, latest.cpm_finish, latest.cpm_finish_at_day_start, calendar_for
1224
+ ),
1225
+ "confidence_curve": dist.get("confidence_curve", []),
1226
+ "runs": latest.n_simulations,
1227
+ "histogram_buckets": dist.get("histogram_buckets", []),
1228
+ "sensitivity": dist.get("sensitivity", []),
1229
+ # Persisted with the run since #2483, so the "why is this flat"
1230
+ # explanation now survives cache expiry alongside the distribution.
1231
+ # Legacy rows have none; the client already treats an absent
1232
+ # diagnostic as "no reason known", never as "no estimates".
1233
+ "forecast_diagnostic": latest.diagnostic,
1234
+ "last_run_at": latest.taken_at.isoformat(),
1235
+ # Persisted with the run since #2638; legacy rows predate the field
1236
+ # and read back as null rather than a guessed value (same pattern as
1237
+ # `diagnostic`/`distribution` above).
1238
+ "status_date": latest.status_date.isoformat() if latest.status_date else None,
1239
+ "from_history": True,
1240
+ # The same call `ProjectOverviewView` makes, on the same row — so the
1241
+ # Overview card and every forecast surface read one derivation of what
1242
+ # a premium of 0 means, and cannot disagree (ADR-0698).
1243
+ **build_risk_premium(latest, today=timezone.localdate(), calendar_for=calendar_for),
1244
+ # Persisted with the run since #3140. Legacy rows carry none and classify
1245
+ # as `unknown` rather than `current` — the client then keeps Rerun
1246
+ # reachable and makes no staleness claim it cannot support.
1247
+ **forecast_staleness_from_run(
1248
+ latest,
1249
+ plan_version_current=project.last_sync_version,
1250
+ recalculated_at=project.recalculated_at,
1251
+ today=timezone.localdate(),
1252
+ ),
1253
+ }
1254
+ )
1255
+
1256
+
1257
+ # Fixed RNG seed for the what-if endpoint (#993). Both the baseline and the
1258
+ # perturbed Monte Carlo runs sample with THIS seed, so they draw the *same*
1259
+ # per-run duration samples for every unperturbed task. The delta between the two
1260
+ # forecasts therefore isolates the effect of the perturbation rather than RNG
1261
+ # noise, and the endpoint is a pure function of its inputs — the same what-if
1262
+ # query always returns the same forecast, which a reproducible "slip this task"
1263
+ # demo (and a stable test assertion) both depend on.
1264
+ WHATIF_MC_SEED = 993_993
1265
+
1266
+
1267
+ def _target_kind_refusal(task: Task) -> Response | None:
1268
+ """400 for a what-if target that cannot be perturbed, else ``None``.
1269
+
1270
+ A milestone is a zero-duration gate. A completed task (#4515) is read with the
1271
+ engine's ``_is_complete`` predicate (ADR-0136): an ``actual_finish`` or 100%.
1272
+ Completed work is pinned by its actuals, so a duration shift would hand back an
1273
+ unmoved forecast as a zero-impact what-if. The engine refuses too; this earlier
1274
+ check keeps the 400 ahead of run admission and the busy slot.
1275
+ """
1276
+ if task.is_milestone:
1277
+ return Response(
1278
+ {"detail": "A milestone is a zero-duration gate and cannot be perturbed."},
1279
+ status=status.HTTP_400_BAD_REQUEST,
1280
+ )
1281
+ if task.actual_finish is None and (task.percent_complete or 0) < 100:
1282
+ return None
1283
+ return Response(
1284
+ {
1285
+ "detail": (
1286
+ "A completed task's dates are actuals, so a duration change "
1287
+ "cannot move the forecast; pick a task that still has work left."
1288
+ )
1289
+ },
1290
+ status=status.HTTP_400_BAD_REQUEST,
1291
+ )
1292
+
1293
+
1294
+ class MonteCarloWhatIfView(McpReadableViewMixin, APIView):
1295
+ """Non-mutating Monte Carlo what-if: perturb one task's duration, recompute (#993).
1296
+
1297
+ Backs the MCP ``whatif`` read tool (#504/#603): accept a ``task_id`` plus a
1298
+ duration perturbation, run CPM + Monte Carlo **in memory without persisting
1299
+ anything**, and return the resulting P50/P80/P95, the deterministic CPM finish,
1300
+ whether the critical path changed, and the signed deltas versus the current
1301
+ (unperturbed) forecast.
1302
+
1303
+ Why GET, not POST: the endpoint has no side effects — it writes no rows, sets no
1304
+ cache, and enqueues no recompute — so it is a pure read/compute, correctly
1305
+ modeled as GET. That is also what makes it reachable by an ``mcp:read`` API
1306
+ token: ``McpReadableViewMixin`` confines token callers to safe methods, so a
1307
+ POST could never back a *read* MCP tool. Query params carry the perturbation
1308
+ (``?task_id=&duration_delta=`` or ``&new_duration=``).
1309
+
1310
+ Non-persistence guarantee: this view never calls ``.save()``,
1311
+ ``record_monte_carlo_run``, ``cache.set``, or ``enqueue_recalculate``. The
1312
+ perturbation is applied to freshly-built in-memory scheduler dataclasses; the
1313
+ Django rows are only read. ``schedule()`` and ``monte_carlo()`` both operate on
1314
+ copies and do not mutate their input project.
1315
+
1316
+ Permission: Member (any role >= Viewer), the same project-read gate as the
1317
+ ``run_monte_carlo`` / latest / history forecast endpoints.
1318
+ """
1319
+
1320
+ # ADR-0678 (#2482): projects/<pk>/monte-carlo/whatif/
1321
+ mcp_scope = McpScope.PATH
1322
+
1323
+ mcp_compute_heavy = True # two CPM passes + two Monte Carlo runs per call (#1808 F4)
1324
+ # Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
1325
+ # this the project-scoped classes below resolve nothing and `has_permission`
1326
+ # returns True for everyone — the gate reads as live in review and enforces
1327
+ # nothing. Declared per view because `pk` means something else on other routes.
1328
+ project_url_kwarg = "pk"
1329
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
1330
+ throttle_classes = [ScopedRateThrottle]
1331
+ throttle_scope = "monte_carlo_whatif"
1332
+
1333
+ @extend_schema(
1334
+ summary="Non-mutating Monte Carlo what-if for a single task duration change",
1335
+ parameters=[
1336
+ OpenApiParameter(
1337
+ "task_id",
1338
+ OpenApiTypes.UUID,
1339
+ required=True,
1340
+ description="The committed task whose duration to perturb.",
1341
+ ),
1342
+ OpenApiParameter(
1343
+ "duration_delta",
1344
+ OpenApiTypes.INT,
1345
+ description=(
1346
+ "Signed day offset applied to the task's current duration "
1347
+ "(e.g. 5 to slip it a working week later, -2 to pull it in). "
1348
+ "Supply exactly one of duration_delta or new_duration."
1349
+ ),
1350
+ ),
1351
+ OpenApiParameter(
1352
+ "new_duration",
1353
+ OpenApiTypes.INT,
1354
+ description=(
1355
+ "Absolute day count to set the task's duration to (>= 0). "
1356
+ "Supply exactly one of duration_delta or new_duration."
1357
+ ),
1358
+ ),
1359
+ OpenApiParameter(
1360
+ "n_simulations",
1361
+ OpenApiTypes.INT,
1362
+ description=(
1363
+ "Monte Carlo iterations. Defaults to the largest count this "
1364
+ "project's size admits: MC_SIMULATION_CAP, or fewer under "
1365
+ "MC_CELL_CAP (runs x tasks) on a large project — `runs` in the "
1366
+ "response reports the count simulated. An explicit value is "
1367
+ "honored exactly or refused (402 above MC_SIMULATION_CAP, 400 "
1368
+ "above the size-derived maximum)."
1369
+ ),
1370
+ ),
1371
+ ],
1372
+ responses={
1373
+ 200: OpenApiResponse(
1374
+ response=MonteCarloWhatIfResponseSerializer,
1375
+ description=(
1376
+ "What-if forecast. Keys: task_id; applied ({base_duration_days, "
1377
+ "duration_delta_days, new_duration_days}); current and whatif (each "
1378
+ "{p50, p80, p95, their *_at_day_start readings, cpm_finish (ISO-8601 or "
1379
+ "null), cpm_finish_at_day_start, critical_path ([task_id])}); "
1380
+ "critical_path_changed (bool — did the set of critical tasks change); "
1381
+ "delta_vs_current ({p50, p80, p95, cpm_finish} signed calendar-day shifts, "
1382
+ "positive = later/worse, null when a date is missing; every field is measured "
1383
+ "in working time (#4178, #4204), so a finish that only moves from the end of "
1384
+ "a Friday to the start of the next Monday is 0); runs (the iteration "
1385
+ "count actually simulated per leg — may be below MC_SIMULATION_CAP when "
1386
+ "n_simulations is omitted on a large project, #2839); "
1387
+ "seed (fixed RNG seed shared by both runs so the delta isolates the change); "
1388
+ "cpm_status_date and mc_status_date (ADR-0132/#2638, floor armed on the "
1389
+ "CPM path by ADR-0752 §4) — the resolved data dates (project.status_date, "
1390
+ "or today when unset) fed to the deterministic and Monte Carlo passes "
1391
+ "respectively, shared by both current and whatif since this view runs both "
1392
+ "in one call; this view never persists, so these are the only provenance a "
1393
+ "caller gets for how the forecast was computed."
1394
+ ),
1395
+ ),
1396
+ 400: OpenApiResponse(
1397
+ response=OpenApiTypes.OBJECT,
1398
+ description=(
1399
+ "Invalid input: malformed/absent params, both or neither of "
1400
+ "duration_delta/new_duration, task_id not a committed task in this "
1401
+ "project, a milestone (zero-duration) target, a completed task (its "
1402
+ "dates are actuals), a summary (phase) target "
1403
+ "whose span is rolled up from its children and so cannot be perturbed, "
1404
+ "a cyclic dependency, or an out-of-range project span; or an explicit "
1405
+ "n_simulations above the size-derived maximum (MC_CELL_CAP, #2839) — "
1406
+ "body {error: simulation_cell_cap_exceeded, message, max_runs, "
1407
+ "task_count}."
1408
+ ),
1409
+ ),
1410
+ 402: OpenApiResponse(
1411
+ response=OpenApiTypes.OBJECT,
1412
+ description="OSS simulation cap exceeded (n_simulations or task count too high).",
1413
+ ),
1414
+ 404: OpenApiResponse(
1415
+ response=OpenApiTypes.OBJECT,
1416
+ description="Project does not exist.",
1417
+ ),
1418
+ 503: OpenApiResponse(
1419
+ response=OpenApiTypes.OBJECT,
1420
+ description=(
1421
+ "No free Monte Carlo slot in this API process (MC_MAX_CONCURRENT, "
1422
+ "#2839). Body {error: simulation_busy, message}; retry after "
1423
+ "Retry-After."
1424
+ ),
1425
+ ),
1426
+ },
1427
+ )
1428
+ def get(self, request: Request, pk: str) -> Response:
1429
+ """Run the perturbed forecast in memory and return the deltas (see class doc)."""
1430
+ from trueppm_scheduler.engine import (
1431
+ CyclicDependencyError,
1432
+ SimulationCapExceeded,
1433
+ monte_carlo,
1434
+ perturb_task_duration,
1435
+ schedule,
1436
+ )
1437
+ from trueppm_scheduler.models import Project as SchedProject
1438
+
1439
+ from trueppm_api.apps.projects.services import scheduler_velocity_inputs
1440
+
1441
+ req = MonteCarloWhatIfRequestSerializer(data=request.query_params)
1442
+ req.is_valid(raise_exception=True)
1443
+ params = req.validated_data
1444
+ task_id = str(params["task_id"])
1445
+
1446
+ try:
1447
+ project = (
1448
+ Project.objects.select_related("calendar", "program__calendar")
1449
+ .prefetch_related(
1450
+ "tasks",
1451
+ "calendar__exceptions",
1452
+ "calendar_layers__calendar__exceptions",
1453
+ # ADR-0441: the resolver reads the program calendar's exceptions when a
1454
+ # project inherits its base calendar from the program.
1455
+ "program__calendar__exceptions",
1456
+ )
1457
+ .get(pk=pk, is_deleted=False)
1458
+ )
1459
+ except Project.DoesNotExist:
1460
+ return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
1461
+
1462
+ # Object-level membership + not-archived gate (same read level as the
1463
+ # forecast endpoints). APIView.get has no get_object(), so enforce explicitly.
1464
+ self.check_object_permissions(request, project)
1465
+
1466
+ cap: int | None = settings.MC_SIMULATION_CAP
1467
+ # None = "the admissible maximum for this project's size" (#2839), resolved by
1468
+ # `admit_runs` once the leaf network is built below.
1469
+ requested_runs: int | None = (
1470
+ int(params["n_simulations"]) if "n_simulations" in params else None
1471
+ )
1472
+
1473
+ # Build the baseline scheduler input exactly as run_monte_carlo does, through
1474
+ # the shared converters (ADR-0132/#1491) so the what-if forecast is computed on
1475
+ # the identical graph the real forecast uses — same calendar exceptions, same
1476
+ # planned_start floors, same velocity signal.
1477
+ sched_calendar = compose_project_calendar(project)
1478
+ db_tasks = list(Task.committed.filter(project=project).select_related("sprint"))
1479
+ if not db_tasks:
1480
+ return Response(
1481
+ {"detail": "Project has no schedulable (committed) tasks to forecast."},
1482
+ status=status.HTTP_400_BAD_REQUEST,
1483
+ )
1484
+
1485
+ # The perturbation target must be one of THIS project's committed tasks. A
1486
+ # BACKLOG card, a task in another project, or an unknown id is rejected as a
1487
+ # clean 400 (not 404 — the project exists and the caller can read it; only the
1488
+ # task reference is invalid) and never leaks whether the id exists elsewhere.
1489
+ target_db = next((t for t in db_tasks if str(t.id) == task_id), None)
1490
+ if target_db is None:
1491
+ return Response(
1492
+ {"detail": "task_id is not a committed task in this project."},
1493
+ status=status.HTTP_400_BAD_REQUEST,
1494
+ )
1495
+
1496
+ # Milestone and completed refusals; checked before admission so the 400 wins over run
1497
+ # admission (402/413) and a busy slot (503); see `_target_kind_refusal`.
1498
+ if (refusal := _target_kind_refusal(target_db)) is not None:
1499
+ return refusal
1500
+
1501
+ base_duration_days = int(target_db.duration)
1502
+ if "duration_delta" in params:
1503
+ delta_days = int(params["duration_delta"])
1504
+ else:
1505
+ # new_duration is absolute; convert to the equivalent signed shift so both
1506
+ # inputs flow through one perturbation path.
1507
+ delta_days = int(params["new_duration"]) - base_duration_days
1508
+ new_duration_days = max(0, base_duration_days + delta_days)
1509
+
1510
+ suggest_approve = project.estimation_mode == EstimationMode.SUGGEST_APPROVE
1511
+ baseline_tasks = build_sched_tasks(db_tasks, suggest_approve=suggest_approve)
1512
+
1513
+ included_ids = {str(t.id) for t in db_tasks}
1514
+ sched_deps = _build_sched_deps(pk, included_ids)
1515
+
1516
+ # Same leaf-only shaping the real forecast uses (#3527, ADR-0105) — a what-if
1517
+ # answered against a network containing phantom phases is not a what-if about
1518
+ # this project's schedule.
1519
+ try:
1520
+ graph = build_sched_graph(db_tasks, baseline_tasks, sched_deps)
1521
+ except (CyclicDependencyError, ValueError) as exc:
1522
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
1523
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
1524
+
1525
+ # A summary has no duration of its own — its span is rolled up from its leaves
1526
+ # — so it is absent from the network built above, and perturbing it would
1527
+ # silently change nothing. Refuse rather than hand back an unmoved forecast as
1528
+ # though the delta had been applied. Checked after the shaping because that is
1529
+ # what defines "is a summary" here, and before any simulation runs.
1530
+ if str(target_db.id) in graph.summary_ids:
1531
+ return Response(
1532
+ {
1533
+ "detail": (
1534
+ "A summary task's duration is rolled up from its children and "
1535
+ "cannot be perturbed; pick one of its leaf tasks instead."
1536
+ )
1537
+ },
1538
+ status=status.HTTP_400_BAD_REQUEST,
1539
+ )
1540
+
1541
+ leaf_tasks = graph.tasks
1542
+ leaf_deps = graph.dependencies
1543
+
1544
+ # runs x n_tasks admission (#2839). Both simulations below share one network
1545
+ # size (a duration perturbation adds no task), so one admission covers both.
1546
+ admission = admit_runs(len(leaf_tasks), requested_runs)
1547
+ if admission.refusal is not None:
1548
+ return admission.refusal
1549
+ n_simulations = admission.runs
1550
+
1551
+ velocity_samples, sprint_length_days = scheduler_velocity_inputs(
1552
+ project.pk, sched_calendar.working_days
1553
+ )
1554
+
1555
+ # The deterministic CPM and Monte Carlo both floor a null status_date at
1556
+ # today (ADR-0752 §4, correcting ADR-0132 §1 — the CPM path used to pass
1557
+ # a null status_date raw, meaning "no floor at all"). Both are resolved
1558
+ # through the same shared helper so this read-time `current` block can
1559
+ # never drift from the persisted deterministic run
1560
+ # (`scheduling/tasks.py::_run_schedule`) on which data date it used —
1561
+ # keeping current.cpm_finish identical to the persisted deterministic
1562
+ # finish and current P50/P80/P95 identical to run_monte_carlo.
1563
+ cpm_status_date = resolve_cpm_status_date(project.status_date)
1564
+ mc_status_date = resolve_cpm_status_date(project.status_date)
1565
+
1566
+ def _make_project(tasks: list[Any], status_date: _date | None) -> Any:
1567
+ return SchedProject(
1568
+ id=str(project.pk),
1569
+ name=project.name,
1570
+ start_date=project.start_date,
1571
+ tasks=tasks,
1572
+ dependencies=leaf_deps,
1573
+ calendar=sched_calendar,
1574
+ status_date=status_date,
1575
+ velocity_samples=velocity_samples or None,
1576
+ sprint_length_days=sprint_length_days,
1577
+ )
1578
+
1579
+ try:
1580
+ # One shared Monte Carlo span (#709) over the full what-if computation —
1581
+ # baseline + perturbed CPM and simulation — through the same helper the
1582
+ # real forecast uses, so the span name and attributes cannot drift.
1583
+ # One process slot (#2839) covers both simulations: they run in sequence.
1584
+ with simulation_slot() as admitted:
1585
+ if not admitted:
1586
+ return busy_response()
1587
+ with monte_carlo_span(pk, simulation_count=n_simulations):
1588
+ # The ENGINE owns the perturbation (#3533). Which input a task's
1589
+ # sampled duration comes from is the engine's own priority rule
1590
+ # (velocity → PERT triple → deterministic); an API-side shift of
1591
+ # the fields this view happens to know about moved the CPM finish
1592
+ # while leaving a velocity-sampled task's bands exactly where they
1593
+ # were, and reported both. Passing the fully-built project — with
1594
+ # the velocity signal on it — is what lets the engine reach the
1595
+ # input it will actually sample.
1596
+ perturbed_tasks = perturb_task_duration(
1597
+ _make_project(leaf_tasks, cpm_status_date), task_id, delta_days
1598
+ ).tasks
1599
+ baseline_cpm = schedule(_make_project(leaf_tasks, cpm_status_date))
1600
+ perturbed_cpm = schedule(_make_project(perturbed_tasks, cpm_status_date))
1601
+ baseline_mc = monte_carlo(
1602
+ _make_project(leaf_tasks, mc_status_date),
1603
+ runs=n_simulations,
1604
+ seed=WHATIF_MC_SEED,
1605
+ max_runs=cap,
1606
+ max_tasks=settings.MC_TASK_CAP,
1607
+ **_mc_lag_delta_kwargs(),
1608
+ )
1609
+ perturbed_mc = monte_carlo(
1610
+ _make_project(perturbed_tasks, mc_status_date),
1611
+ runs=n_simulations,
1612
+ seed=WHATIF_MC_SEED,
1613
+ max_runs=cap,
1614
+ max_tasks=settings.MC_TASK_CAP,
1615
+ **_mc_lag_delta_kwargs(),
1616
+ )
1617
+ except SimulationCapExceeded as exc:
1618
+ return Response(
1619
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
1620
+ {"error": "simulation_cap_exceeded", "tier": "team", "message": str(exc)},
1621
+ status=status.HTTP_402_PAYMENT_REQUIRED,
1622
+ )
1623
+ except (CyclicDependencyError, ValueError) as exc:
1624
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
1625
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
1626
+ except OverflowError:
1627
+ return Response(
1628
+ {"detail": _DATE_RANGE_EXCEEDED_DETAIL},
1629
+ status=status.HTTP_400_BAD_REQUEST,
1630
+ )
1631
+
1632
+ # A membership change in the critical set is the meaningful "the shape of the
1633
+ # plan moved" signal — compared as sets so a pure reordering along an unchanged
1634
+ # path does not read as a change.
1635
+ critical_path_changed = set(baseline_cpm.critical_path) != set(perturbed_cpm.critical_path)
1636
+
1637
+ def _leg(mc: Any, cpm: Any) -> dict[str, Any]:
1638
+ cpm_reading = schedule_result_finish(cpm)
1639
+ return {
1640
+ "p50": mc.p50.isoformat(),
1641
+ "p80": mc.p80.isoformat(),
1642
+ "p95": mc.p95.isoformat(),
1643
+ # Which edge of its shown day each finish is (#4204).
1644
+ "p50_at_day_start": mc.p50_at_day_start,
1645
+ "p80_at_day_start": mc.p80_at_day_start,
1646
+ "p95_at_day_start": mc.p95_at_day_start,
1647
+ "cpm_finish": cpm.project_finish.isoformat(),
1648
+ "cpm_finish_at_day_start": cpm_reading[1] if cpm_reading is not None else None,
1649
+ "critical_path": list(cpm.critical_path),
1650
+ }
1651
+
1652
+ current = _leg(baseline_mc, baseline_cpm)
1653
+ whatif = _leg(perturbed_mc, perturbed_cpm)
1654
+ return Response(
1655
+ {
1656
+ "task_id": task_id,
1657
+ "applied": {
1658
+ "base_duration_days": base_duration_days,
1659
+ "duration_delta_days": delta_days,
1660
+ "new_duration_days": new_duration_days,
1661
+ },
1662
+ "current": current,
1663
+ "whatif": whatif,
1664
+ "critical_path_changed": critical_path_changed,
1665
+ "delta_vs_current": {
1666
+ # In working time (#4204), like `cpm_finish` below.
1667
+ **_mc_percentile_deltas(perturbed_mc, baseline_mc, lambda: sched_calendar),
1668
+ # Measured in working time (#4178): a what-if that only moves
1669
+ # a milestone finish between the end of a Friday and the start
1670
+ # of the next Monday is no shift, and one that moves it from the
1671
+ # start of Monday to the end of Monday is a real one.
1672
+ "cpm_finish": _cpm_finish_delta_days(
1673
+ baseline_cpm, perturbed_cpm, sched_calendar
1674
+ ),
1675
+ },
1676
+ "runs": n_simulations,
1677
+ "seed": WHATIF_MC_SEED,
1678
+ # The data dates fed to each engine for both the current and whatif
1679
+ # passes (ADR-0132, floor armed on the CPM path by ADR-0752 §4) —
1680
+ # this view never persists, so these are the only provenance an
1681
+ # AI/MCP or human caller gets for how "current" was computed. Both
1682
+ # baseline and perturbed share one resolved value per engine within
1683
+ # a single call, so one top-level pair covers both `current` and
1684
+ # `whatif` rather than duplicating it under each (#2638). Both are
1685
+ # always a resolved date now (never null) — a null status_date no
1686
+ # longer means "no floor at all" on the CPM path.
1687
+ "cpm_status_date": cpm_status_date.isoformat(),
1688
+ "mc_status_date": mc_status_date.isoformat(),
1689
+ }
1690
+ )
1691
+
1692
+
1693
+ # Hard ceiling on a single forecast-history response, independent of the
1694
+ # retention cap. OSS retention (MC_HISTORY_CAP=100) is already below this; the
1695
+ # ceiling only bites the Enterprise unlimited-retention case so the endpoint can
1696
+ # never stream an unbounded payload (ADR-0175).
1697
+ MC_HISTORY_RESPONSE_MAX = 500
1698
+
1699
+
1700
+ def _annotate_run_deltas(
1701
+ runs: list[MonteCarloRun], calendar_for: Callable[[], Any] | None = None
1702
+ ) -> None:
1703
+ """Attach a computed-on-read ``_delta`` to each run vs the next-older run (ADR-0108).
1704
+
1705
+ ``runs`` is newest-first; positive days = the forecast slipped later (worse). The
1706
+ oldest row has no predecessor, so its ``_delta`` stays ``None`` (baseline). Mutates
1707
+ each run in place for the serializer to read. Measured in working time from each
1708
+ run's persisted readings (#4204); ``calendar_for`` is called only when a reading
1709
+ is a start-of-day one.
1710
+ """
1711
+ for i, run in enumerate(runs):
1712
+ older = runs[i + 1] if i + 1 < len(runs) else None
1713
+ if older is None:
1714
+ run._delta = None # type: ignore[attr-defined]
1715
+ else:
1716
+ run._delta = _mc_percentile_deltas(run, older, calendar_for) # type: ignore[attr-defined]
1717
+
1718
+
1719
+ def _mc_attribution_visible(request: Request, project: Project, pk: str) -> bool:
1720
+ """Whether the caller may see run-author attribution, per the effective audience.
1721
+
1722
+ Resolved per-workspace (ADR-0144): ADMIN_OWNER → role ≥ ADMIN; SCHEDULER_PLUS →
1723
+ role ≥ SCHEDULER; NONE → never. Default ADMIN_OWNER reproduces the prior hardcoded
1724
+ gate exactly. Drift must not become a named-individual signal below the configured
1725
+ audience (VoC Morgan).
1726
+ """
1727
+ from trueppm_api.apps.access.models import Role
1728
+ from trueppm_api.apps.access.permissions import _membership_role
1729
+ from trueppm_api.apps.scheduling.forecast_history_settings import (
1730
+ resolve_effective_mc_history,
1731
+ )
1732
+ from trueppm_api.apps.scheduling.models import MCAttributionAudience
1733
+
1734
+ role = _membership_role(request, pk)
1735
+ audience = resolve_effective_mc_history(project, "mc_history_attribution_audience")
1736
+ if audience == MCAttributionAudience.NONE:
1737
+ return False
1738
+ if audience == MCAttributionAudience.SCHEDULER_PLUS:
1739
+ return role is not None and role >= Role.SCHEDULER
1740
+ return role is not None and role >= Role.ADMIN # ADMIN_OWNER (default)
1741
+
1742
+
1743
+ @extend_schema(
1744
+ parameters=[
1745
+ OpenApiParameter(
1746
+ "expand",
1747
+ str,
1748
+ description=(
1749
+ "Set to 'distribution' to include each run's persisted distribution "
1750
+ "({histogram_buckets, confidence_curve, sensitivity}) payload (#1231). "
1751
+ "Omitted by default to keep the list lightweight; distribution is null "
1752
+ "unless expanded or for legacy runs with no stored distribution."
1753
+ ),
1754
+ ),
1755
+ ],
1756
+ responses={
1757
+ 200: OpenApiResponse(
1758
+ response=inline_serializer(
1759
+ name="MonteCarloHistoryResponse",
1760
+ fields={
1761
+ "results": MonteCarloRunSerializer(many=True),
1762
+ "cap": drf_serializers.IntegerField(allow_null=True),
1763
+ "enabled": drf_serializers.BooleanField(),
1764
+ },
1765
+ ),
1766
+ description=(
1767
+ "Project Monte Carlo run history (ADR-0175/0143). When the "
1768
+ "per-workspace config disables history, enabled is false and results "
1769
+ "is empty. Each run carries P50/P80/P95, cpm_finish, n_simulations, "
1770
+ "task_count, status_date (the data date the run was computed against "
1771
+ "— null for runs persisted before #2638), a per-percentile delta vs "
1772
+ "the previous run (null on the oldest row), triggered_by_name "
1773
+ "(non-null only for the resolved attribution audience), and "
1774
+ "distribution (only when ?expand=distribution)."
1775
+ ),
1776
+ ),
1777
+ 404: OpenApiResponse(
1778
+ response=OpenApiTypes.OBJECT,
1779
+ description="Project does not exist.",
1780
+ ),
1781
+ },
1782
+ )
1783
+ class MonteCarloHistoryView(APIView):
1784
+ """Project Monte Carlo run history — forecast drift over time (ADR-0175, #961).
1785
+
1786
+ Returns persisted runs newest-first, capped at ``settings.MC_HISTORY_CAP``,
1787
+ each with a computed-on-read per-percentile delta versus the immediately
1788
+ previous (older) run. The run-author attribution (``triggered_by_name``) is
1789
+ serialized only for Admin/Owner (role ≥ ADMIN); every other member sees the
1790
+ drift values without attribution, so forecast drift cannot become a
1791
+ named-individual performance signal (VoC Morgan).
1792
+
1793
+ Permission: Member (any role ≥ Viewer), consistent with the forecast read.
1794
+ """
1795
+
1796
+ # Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
1797
+ # this the project-scoped classes below resolve nothing and `has_permission`
1798
+ # returns True for everyone — the gate reads as live in review and enforces
1799
+ # nothing. Declared per view because `pk` means something else on other routes.
1800
+ project_url_kwarg = "pk"
1801
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
1802
+
1803
+ def get(self, request: Request, pk: str) -> Response:
1804
+ """Return the capped, newest-first run history with per-run deltas."""
1805
+ from trueppm_api.apps.scheduling.forecast_history_settings import (
1806
+ resolve_effective_mc_history,
1807
+ )
1808
+
1809
+ project = get_object_or_404(Project, pk=pk, is_deleted=False)
1810
+ self.check_object_permissions(request, project)
1811
+
1812
+ # Per-workspace forecast-history config (ADR-0144, #1232), inheritable
1813
+ # Workspace → Program → Project. When the effective config disables history,
1814
+ # return 200 with an empty list + enabled:false rather than 403/404 — the
1815
+ # feature is off, not access-denied, so the FE renders a "history off" state.
1816
+ if not resolve_effective_mc_history(project, "mc_history_enabled"):
1817
+ return Response({"results": [], "cap": None, "enabled": False})
1818
+
1819
+ # The retention cap is now per-workspace-effective (clamped to
1820
+ # MC_HISTORY_HARD_CAP in the resolver) rather than the global constant.
1821
+ cap: int | None = resolve_effective_mc_history(project, "mc_history_retention_cap")
1822
+ # Hard response ceiling: even when the cap is None (Enterprise unlimited
1823
+ # *retention*), a single API response must stay bounded — keep the newest
1824
+ # MC_HISTORY_RESPONSE_MAX rows. select_related avoids an N+1 on triggered_by
1825
+ # when attribution is serialized.
1826
+ limit = cap if cap is not None else MC_HISTORY_RESPONSE_MAX
1827
+ # ?expand=distribution opts a single detail fetch into the heavier per-run
1828
+ # distribution payload (#1231); the default list stays lightweight.
1829
+ expand_distribution = request.query_params.get("expand") == "distribution"
1830
+ qs = (
1831
+ MonteCarloRun.objects.filter(project_id=pk)
1832
+ .select_related("triggered_by")
1833
+ .order_by("-taken_at")
1834
+ )
1835
+ # The distribution column is up to MC_DISTRIBUTION_MAX_BYTES per row; the
1836
+ # serializer never reads it on the default list path, so defer it there to
1837
+ # avoid fetching + JSON-parsing ~limit×32KB only to discard it.
1838
+ if not expand_distribution:
1839
+ qs = qs.defer("distribution")
1840
+ runs = list(qs[:limit])
1841
+ calendar_memo: list[Any] = []
1842
+
1843
+ def calendar_for() -> Any:
1844
+ # Composed at most once, and only if some run's reading needs it (#4204).
1845
+ if not calendar_memo:
1846
+ calendar_memo.append(compose_project_calendar(project))
1847
+ return calendar_memo[0]
1848
+
1849
+ _annotate_run_deltas(runs, calendar_for)
1850
+ can_see_attribution = _mc_attribution_visible(request, project, pk)
1851
+
1852
+ data = MonteCarloRunSerializer(
1853
+ runs,
1854
+ many=True,
1855
+ context={
1856
+ "request": request,
1857
+ "can_see_attribution": can_see_attribution,
1858
+ "expand_distribution": expand_distribution,
1859
+ },
1860
+ ).data
1861
+ return Response({"results": data, "cap": cap, "enabled": True})
1862
+
1863
+
1864
+ @extend_schema(
1865
+ parameters=[
1866
+ # Day-grained window, declared as `date` to match the burn-series
1867
+ # contract (#1378) — the new 0.3 computed analytics endpoints expose
1868
+ # since/until with one consistent type. The runtime stays lenient: a
1869
+ # bare ISO date is the documented format, and a full ISO datetime is
1870
+ # still accepted as a backward-compatible superset (see _parse_bound).
1871
+ OpenApiParameter(
1872
+ "since",
1873
+ OpenApiTypes.DATE,
1874
+ description="Only snapshots captured at or after this ISO 8601 date (YYYY-MM-DD).",
1875
+ ),
1876
+ OpenApiParameter(
1877
+ "until",
1878
+ OpenApiTypes.DATE,
1879
+ description="Only snapshots captured at or before this ISO 8601 date (YYYY-MM-DD).",
1880
+ ),
1881
+ ],
1882
+ responses={200: ProjectForecastSnapshotSerializer(many=True)},
1883
+ )
1884
+ class ForecastSnapshotListView(ListAPIView[ProjectForecastSnapshot]):
1885
+ """Project-grain forecast snapshot history (ADR-0154, #388).
1886
+
1887
+ Returns the project's forecast snapshots newest-first (paginated), the
1888
+ persisted record of how the CPM finish and Monte Carlo percentiles drifted
1889
+ over time. Read-only — rows are server-generated on recompute and by the daily
1890
+ floor; there is no write surface. ``?since=``/``?until=`` bound the window by
1891
+ ``captured_at`` (ISO 8601 date YYYY-MM-DD, the documented format; a full ISO
1892
+ datetime is also accepted and interpreted literally, a bare date as midnight UTC).
1893
+
1894
+ Permission: Member (any role ≥ Viewer), the project-read gate — consistent
1895
+ with the Monte Carlo history read.
1896
+ """
1897
+
1898
+ serializer_class = ProjectForecastSnapshotSerializer
1899
+ # Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
1900
+ # this the project-scoped classes below resolve nothing and `has_permission`
1901
+ # returns True for everyone — the gate reads as live in review and enforces
1902
+ # nothing. Declared per view because `pk` means something else on other routes.
1903
+ project_url_kwarg = "pk"
1904
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
1905
+
1906
+ def get_queryset(self) -> models.QuerySet[ProjectForecastSnapshot]:
1907
+ """Project-scoped, optionally date-bounded, newest-first."""
1908
+ project = get_object_or_404(Project, pk=self.kwargs["pk"], is_deleted=False)
1909
+ # IsProjectMember/IsProjectNotArchived are object-level — this list view has
1910
+ # no get_object(), so enforce them explicitly against the resolved project.
1911
+ self.check_object_permissions(self.request, project)
1912
+
1913
+ qs = ProjectForecastSnapshot.objects.filter(project_id=project.pk).order_by("-captured_at")
1914
+ since = self._parse_bound(self.request.query_params.get("since"))
1915
+ until = self._parse_bound(self.request.query_params.get("until"))
1916
+ if since is not None:
1917
+ qs = qs.filter(captured_at__gte=since)
1918
+ if until is not None:
1919
+ qs = qs.filter(captured_at__lte=until)
1920
+ return qs
1921
+
1922
+ @staticmethod
1923
+ def _parse_bound(raw: str | None) -> Any:
1924
+ """Parse an ISO date (documented) or full datetime to a timezone-aware bound.
1925
+
1926
+ A bare ``YYYY-MM-DD`` is the documented since/until format (#1378),
1927
+ interpreted as midnight in the default timezone; a full ISO datetime is
1928
+ still accepted. ``parse_datetime`` parses a date-only string into a *naive*
1929
+ midnight, so any naive result is made aware here — otherwise the bound
1930
+ would be compared naive against the aware ``captured_at`` column (a Django
1931
+ RuntimeWarning and a silent offset bug).
1932
+ """
1933
+ if not raw:
1934
+ return None
1935
+ dt = parse_datetime(raw)
1936
+ if dt is None:
1937
+ d = parse_date(raw)
1938
+ if d is None:
1939
+ return None
1940
+ dt = _datetime(d.year, d.month, d.day)
1941
+ if timezone.is_naive(dt):
1942
+ dt = timezone.make_aware(dt)
1943
+ return dt
1944
+
1945
+
1946
+ class FailedTaskPagination(PageNumberPagination):
1947
+ """Bounded page-number pagination for the dead-letter queue (#1317).
1948
+
1949
+ The list mixin already paged through the project default, but the bound was
1950
+ implicit; the dead-letter table can grow to thousands, so the cap is made
1951
+ explicit here. Page-number (not cursor) is retained deliberately — the
1952
+ inspector header shows a total ``count``, which CursorPagination omits.
1953
+ """
1954
+
1955
+ page_size = 50
1956
+ page_size_query_param = "page_size"
1957
+ max_page_size = 200
1958
+
1959
+
1960
+ @extend_schema_view(
1961
+ list=extend_schema(
1962
+ parameters=[
1963
+ OpenApiParameter(
1964
+ "status",
1965
+ str,
1966
+ description=(
1967
+ "Filter by status: dead, pending_retry, dismissed, retried. "
1968
+ "Invalid values are ignored (no filter)."
1969
+ ),
1970
+ ),
1971
+ OpenApiParameter(
1972
+ "task_name", str, description="Case-insensitive substring match on task_name."
1973
+ ),
1974
+ OpenApiParameter(
1975
+ "failed_after",
1976
+ str,
1977
+ description="ISO-8601; keep tasks with last_failed_at at or after this time.",
1978
+ ),
1979
+ OpenApiParameter(
1980
+ "failed_before",
1981
+ str,
1982
+ description="ISO-8601; keep tasks with last_failed_at at or before this time.",
1983
+ ),
1984
+ ],
1985
+ ),
1986
+ )
1987
+ class FailedTaskViewSet(IdempotencyMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet): # type: ignore[type-arg]
1988
+ """Workspace-operator endpoint for dead-lettered Celery tasks.
1989
+
1990
+ List/detail/requeue/drop and the bulk ``requeue_all``/``drop_all``: workspace
1991
+ operators (Django superusers, :class:`IsWorkspaceOperator`) only (#4009). The
1992
+ serializer exposes tracebacks, args, and kwargs which may contain internal
1993
+ paths or partial secrets and must not be visible to unprivileged members.
1994
+
1995
+ **Not gated on Django ``is_staff`` (#4009).** Before #4009 this viewset used
1996
+ ``IsAdminUser``, an undocumented second admin axis that is not governed by
1997
+ ``WorkspaceRole`` and is not self-grantable — but also not visible anywhere in
1998
+ the RBAC docs. Moving to ``IsWorkspaceOperator`` folds dead-letter access into
1999
+ the one stored-principal story the resource catalog and mail transport already
2000
+ use (ADR-0034, ADR-0213 C1) and narrows the population: an ``is_staff=True``
2001
+ account that is not also a superuser now gets 403 where it previously passed.
2002
+
2003
+ The list endpoint backs the dead-letter inspector (#694, ADR-0172) and
2004
+ accepts read-only filters: ``?status=`` (one of the FailedTaskStatus
2005
+ values), ``?task_name=`` (case-insensitive substring), and
2006
+ ``?failed_after=`` / ``?failed_before=`` (ISO-8601, filtered on
2007
+ ``last_failed_at``). Results keep the model default ordering
2008
+ (``-last_failed_at``, newest failure first).
2009
+
2010
+ Write actions (#695, ADR-0210):
2011
+ - ``requeue`` re-enqueues a parked task with an operator-chosen backoff,
2012
+ round-tripping through the durable workflow backend (ADR-0080) rather than
2013
+ the old raw ``send_task`` side channel.
2014
+ - ``drop`` soft-removes a parked task (→ DISMISSED) with an optional audit
2015
+ note; the row is retained so the audit survives (ADR-0084 "no silent
2016
+ discards").
2017
+ - ``requeue_all`` / ``drop_all`` apply the same action across the *current
2018
+ filter set*, bounded to ``FAILED_TASK_BULK_ACTION_MAX``.
2019
+ """
2020
+
2021
+ serializer_class = FailedTaskSerializer
2022
+ permission_classes = [IsWorkspaceOperator]
2023
+ pagination_class = FailedTaskPagination
2024
+ queryset = FailedTask.objects.all()
2025
+
2026
+ def get_queryset(self) -> models.QuerySet[FailedTask]:
2027
+ """Apply the inspector's read-only filters to the dead-letter list.
2028
+
2029
+ Invalid filter values are ignored rather than raising, so a malformed
2030
+ bookmarked URL degrades to "no filter" instead of a 400 — an operator
2031
+ diagnosing an incident should never be blocked by a bad query string.
2032
+
2033
+ ``select_related('resolved_by')`` avoids an N+1 on the resolved-status list
2034
+ views (``?status=retried``/``dismissed``): the ``resolved_by_display``
2035
+ serializer field reads the operator FK, which would otherwise lazy-load
2036
+ once per row (ADR-0210 audit fields).
2037
+ """
2038
+ qs = FailedTask.objects.select_related("resolved_by")
2039
+
2040
+ status_filter = self.request.query_params.get("status")
2041
+ if status_filter in FailedTaskStatus.values:
2042
+ qs = qs.filter(status=status_filter)
2043
+
2044
+ task_name = self.request.query_params.get("task_name")
2045
+ if task_name:
2046
+ qs = qs.filter(task_name__icontains=task_name)
2047
+
2048
+ failed_after = parse_datetime(self.request.query_params.get("failed_after", ""))
2049
+ if failed_after is not None:
2050
+ qs = qs.filter(last_failed_at__gte=failed_after)
2051
+
2052
+ failed_before = parse_datetime(self.request.query_params.get("failed_before", ""))
2053
+ if failed_before is not None:
2054
+ qs = qs.filter(last_failed_at__lte=failed_before)
2055
+
2056
+ return qs
2057
+
2058
+ def _start_requeue_workflow(self, failed: FailedTask, backoff_seconds: int) -> str:
2059
+ """Start the durable requeue workflow for one task (ADR-0210 §1).
2060
+
2061
+ Routes the re-enqueue through the #652 outbox-composing workflow backend
2062
+ (``start_workflow``) rather than a raw ``send_task`` side channel, so a
2063
+ broker outage cannot silently drop the requeue. The idempotency key ties one
2064
+ requeue to one *observed failure* (``id`` + ``failure_count``): a
2065
+ double-clicked or replayed requeue collides on the key and returns the
2066
+ existing workflow instead of double-enqueuing.
2067
+ """
2068
+ return start_workflow(
2069
+ REQUEUE_WORKFLOW,
2070
+ {
2071
+ "failed_task_id": str(failed.id),
2072
+ "task_name": failed.task_name,
2073
+ "args": failed.args,
2074
+ "kwargs": failed.kwargs,
2075
+ "backoff_seconds": backoff_seconds,
2076
+ },
2077
+ idempotency_key=f"requeue:{failed.id}:{failed.failure_count}",
2078
+ )
2079
+
2080
+ @extend_schema(
2081
+ request=FailedTaskRequeueSerializer,
2082
+ responses={200: FailedTaskSerializer},
2083
+ summary="Requeue a dead-lettered task with an operator-chosen backoff",
2084
+ )
2085
+ @action(detail=True, methods=["post"], permission_classes=[IsWorkspaceOperator])
2086
+ def requeue(self, request: Request, pk: str | None = None) -> Response:
2087
+ """Re-enqueue a parked task via the durable workflow backend (ADR-0210).
2088
+
2089
+ Only ``dead``/``pending_retry`` tasks are requeueable; a terminal task is a
2090
+ 400. The re-enqueue is durable (workflow outbox); the ``backoff_seconds`` is
2091
+ a best-effort Celery countdown on the re-dispatched task.
2092
+ """
2093
+ failed = self.get_object()
2094
+ if failed.status not in _ACTIONABLE_FAILED_STATUSES:
2095
+ return Response(
2096
+ {"detail": f"Cannot requeue a task with status '{failed.status}'."},
2097
+ status=status.HTTP_400_BAD_REQUEST,
2098
+ )
2099
+ payload = FailedTaskRequeueSerializer(data=request.data)
2100
+ payload.is_valid(raise_exception=True)
2101
+ backoff_seconds = payload.validated_data["backoff_seconds"]
2102
+
2103
+ # Start the durable workflow first, then stamp the row: the side effect is the
2104
+ # durable part, so if the status update failed the workflow still ran and a
2105
+ # re-run dedupes on the idempotency key. Both commit together under
2106
+ # ATOMIC_REQUESTS, so a mid-action error rolls the whole thing back.
2107
+ workflow_id = self._start_requeue_workflow(failed, backoff_seconds)
2108
+ FailedTask.objects.filter(pk=failed.pk).update(
2109
+ status=FailedTaskStatus.RETRIED,
2110
+ resolved_by=request.user,
2111
+ resolved_at=timezone.now(),
2112
+ )
2113
+ failed.refresh_from_db()
2114
+ logger.info(
2115
+ "dead-letter requeue: task %s (%s) requeued by user %s, backoff=%ss, workflow=%s",
2116
+ failed.id,
2117
+ failed.task_name,
2118
+ getattr(request.user, "pk", None),
2119
+ backoff_seconds,
2120
+ workflow_id,
2121
+ )
2122
+ data = dict(FailedTaskSerializer(failed).data)
2123
+ data["workflow_id"] = workflow_id
2124
+ return Response(data)
2125
+
2126
+ @extend_schema(
2127
+ request=FailedTaskDropSerializer,
2128
+ responses={200: FailedTaskSerializer},
2129
+ summary="Drop (dismiss) a dead-lettered task with an optional audit note",
2130
+ )
2131
+ @action(detail=True, methods=["post"], permission_classes=[IsWorkspaceOperator])
2132
+ def drop(self, request: Request, pk: str | None = None) -> Response:
2133
+ """Soft-remove a parked task (→ DISMISSED) with an optional audit note.
2134
+
2135
+ The row is retained (not hard-deleted) so the note/operator/timestamp audit
2136
+ survives; it drops out of the operator's default ``dead``/``pending_retry``
2137
+ view. Re-dropping an already-dismissed task is an idempotent no-op.
2138
+ """
2139
+ failed = self.get_object()
2140
+ payload = FailedTaskDropSerializer(data=request.data)
2141
+ payload.is_valid(raise_exception=True)
2142
+ note = payload.validated_data["note"]
2143
+
2144
+ if failed.status == FailedTaskStatus.DISMISSED:
2145
+ return Response(FailedTaskSerializer(failed).data)
2146
+
2147
+ FailedTask.objects.filter(pk=failed.pk).update(
2148
+ status=FailedTaskStatus.DISMISSED,
2149
+ resolution_note=note,
2150
+ resolved_by=request.user,
2151
+ resolved_at=timezone.now(),
2152
+ )
2153
+ failed.refresh_from_db()
2154
+ logger.info(
2155
+ "dead-letter drop: task %s (%s) dropped by user %s%s",
2156
+ failed.id,
2157
+ failed.task_name,
2158
+ getattr(request.user, "pk", None),
2159
+ " with note" if note else "",
2160
+ )
2161
+ return Response(FailedTaskSerializer(failed).data)
2162
+
2163
+ @extend_schema(
2164
+ request=FailedTaskRequeueSerializer,
2165
+ responses={200: OpenApiResponse(description="{processed, matched, capped}")},
2166
+ summary="Requeue every actionable task in the current filter set (bounded)",
2167
+ )
2168
+ @action(
2169
+ detail=False,
2170
+ methods=["post"],
2171
+ url_path="requeue-all",
2172
+ permission_classes=[IsWorkspaceOperator],
2173
+ )
2174
+ def requeue_all(self, request: Request) -> Response:
2175
+ """Bulk-requeue the current filter set, bounded (ADR-0210 §4).
2176
+
2177
+ Applies the inspector's ``get_queryset`` filters, restricts to actionable
2178
+ (``dead``/``pending_retry``) tasks, and processes at most
2179
+ ``FAILED_TASK_BULK_ACTION_MAX`` (oldest-first). ``capped`` in the response
2180
+ signals that more remain and the operator should repeat.
2181
+ """
2182
+ payload = FailedTaskRequeueSerializer(data=request.data)
2183
+ payload.is_valid(raise_exception=True)
2184
+ backoff_seconds = payload.validated_data["backoff_seconds"]
2185
+
2186
+ qs = self.get_queryset().filter(status__in=_ACTIONABLE_FAILED_STATUSES)
2187
+ matched = qs.count()
2188
+ batch = list(qs.order_by("last_failed_at")[:FAILED_TASK_BULK_ACTION_MAX])
2189
+ for failed in batch:
2190
+ self._start_requeue_workflow(failed, backoff_seconds)
2191
+ if batch:
2192
+ FailedTask.objects.filter(pk__in=[f.pk for f in batch]).update(
2193
+ status=FailedTaskStatus.RETRIED,
2194
+ resolved_by=request.user,
2195
+ resolved_at=timezone.now(),
2196
+ )
2197
+ logger.info(
2198
+ "dead-letter requeue_all: %d of %d task(s) requeued by user %s, backoff=%ss",
2199
+ len(batch),
2200
+ matched,
2201
+ getattr(request.user, "pk", None),
2202
+ backoff_seconds,
2203
+ )
2204
+ return Response(
2205
+ {
2206
+ "processed": len(batch),
2207
+ "matched": matched,
2208
+ "capped": matched > FAILED_TASK_BULK_ACTION_MAX,
2209
+ }
2210
+ )
2211
+
2212
+ @extend_schema(
2213
+ request=FailedTaskDropSerializer,
2214
+ responses={200: OpenApiResponse(description="{processed, matched, capped}")},
2215
+ summary="Drop every task in the current filter set (bounded)",
2216
+ )
2217
+ @action(
2218
+ detail=False,
2219
+ methods=["post"],
2220
+ url_path="drop-all",
2221
+ permission_classes=[IsWorkspaceOperator],
2222
+ )
2223
+ def drop_all(self, request: Request) -> Response:
2224
+ """Bulk-drop the current filter set, bounded (ADR-0210 §4).
2225
+
2226
+ Applies the inspector's ``get_queryset`` filters, excludes already-dismissed
2227
+ rows, and dismisses at most ``FAILED_TASK_BULK_ACTION_MAX`` (oldest-first) in
2228
+ a single bounded ``UPDATE`` — no per-row loop. ``capped`` signals more remain.
2229
+ """
2230
+ payload = FailedTaskDropSerializer(data=request.data)
2231
+ payload.is_valid(raise_exception=True)
2232
+ note = payload.validated_data["note"]
2233
+
2234
+ qs = self.get_queryset().exclude(status=FailedTaskStatus.DISMISSED)
2235
+ matched = qs.count()
2236
+ ids = list(
2237
+ qs.order_by("last_failed_at").values_list("pk", flat=True)[:FAILED_TASK_BULK_ACTION_MAX]
2238
+ )
2239
+ processed = 0
2240
+ if ids:
2241
+ processed = FailedTask.objects.filter(pk__in=ids).update(
2242
+ status=FailedTaskStatus.DISMISSED,
2243
+ resolution_note=note,
2244
+ resolved_by=request.user,
2245
+ resolved_at=timezone.now(),
2246
+ )
2247
+ logger.info(
2248
+ "dead-letter drop_all: %d of %d task(s) dropped by user %s",
2249
+ processed,
2250
+ matched,
2251
+ getattr(request.user, "pk", None),
2252
+ )
2253
+ return Response(
2254
+ {
2255
+ "processed": processed,
2256
+ "matched": matched,
2257
+ "capped": matched > FAILED_TASK_BULK_ACTION_MAX,
2258
+ }
2259
+ )
2260
+
2261
+
2262
+ # ---------------------------------------------------------------------------
2263
+ # Velocity suggestions (ADR-0065)
2264
+ # ---------------------------------------------------------------------------
2265
+
2266
+
2267
+ @extend_schema_view(
2268
+ list=extend_schema(
2269
+ parameters=[
2270
+ OpenApiParameter(
2271
+ "task",
2272
+ OpenApiTypes.UUID,
2273
+ location=OpenApiParameter.QUERY,
2274
+ required=False,
2275
+ description="Filter to velocity suggestions for this task.",
2276
+ ),
2277
+ OpenApiParameter(
2278
+ "pending",
2279
+ OpenApiTypes.BOOL,
2280
+ location=OpenApiParameter.QUERY,
2281
+ required=False,
2282
+ description=(
2283
+ "When true, restrict to suggestions awaiting a decision "
2284
+ "(neither accepted nor dismissed)."
2285
+ ),
2286
+ ),
2287
+ ],
2288
+ ),
2289
+ )
2290
+ class VelocitySuggestionViewSet(
2291
+ IdempotencyMixin,
2292
+ ListModelMixin,
2293
+ RetrieveModelMixin,
2294
+ GenericViewSet[VelocitySuggestion],
2295
+ ):
2296
+ """Velocity-calibration suggestions surfaced to the PM (ADR-0065).
2297
+
2298
+ Lifecycle:
2299
+ - sprint close generates a row per task in the closing sprint via
2300
+ ``compute_velocity_suggestions``
2301
+ - the PM accepts (writes ``most_likely_duration`` + enqueues
2302
+ ScheduleRequest) or dismisses (audit-only stamp) from the Task Detail
2303
+ Drawer
2304
+ - both terminal decisions are non-destructive: the row is preserved for
2305
+ audit; subsequent sprint closes may add new rows (one per task per
2306
+ sprint) but cannot overwrite a settled decision.
2307
+
2308
+ Auth model:
2309
+ - list / retrieve: any project member (Viewer+) — read access is fine
2310
+ because the surface is informational
2311
+ - accept / dismiss: PM or above (the action either writes a CPM input or
2312
+ records a governance decision; either way it's PM-scope)
2313
+
2314
+ Filtering: ``?task=<task_id>`` is the primary filter the drawer uses.
2315
+ ``?pending=true`` restricts to suggestions awaiting a decision.
2316
+ """
2317
+
2318
+ serializer_class = VelocitySuggestionSerializer
2319
+ permission_classes = [IsAuthenticated]
2320
+ queryset = VelocitySuggestion.objects.all()
2321
+
2322
+ def get_queryset(self) -> models.QuerySet[VelocitySuggestion]:
2323
+ qs = (
2324
+ VelocitySuggestion.objects.select_related("task", "sprint")
2325
+ .filter(task__is_deleted=False)
2326
+ .order_by("-created_at")
2327
+ )
2328
+
2329
+ task_id = self.request.query_params.get("task")
2330
+ if task_id:
2331
+ qs = qs.filter(task_id=task_id)
2332
+
2333
+ if self.request.query_params.get("pending", "").lower() == "true":
2334
+ qs = qs.filter(accepted_at__isnull=True, dismissed_at__isnull=True)
2335
+
2336
+ # Membership gate: only return rows whose task belongs to a project the
2337
+ # caller is a member of. We rely on the access app's project-membership
2338
+ # subquery rather than per-row checks so the list endpoint stays fast.
2339
+ # IsAuthenticated excludes AnonymousUser before this runs; the isinstance
2340
+ # guard is there to narrow the type for mypy.
2341
+ from django.contrib.auth.models import AbstractBaseUser
2342
+
2343
+ from trueppm_api.apps.access.models import ProjectMembership
2344
+
2345
+ user = self.request.user
2346
+ if not isinstance(user, AbstractBaseUser):
2347
+ return qs.none()
2348
+ # is_deleted=False (#819): without this, a user whose membership was
2349
+ # soft-deleted still sees velocity suggestions for that project via the
2350
+ # list endpoint. The accept/dismiss actions go through IsProjectAdmin
2351
+ # which already filters soft-deleted, so the write path is safe — only
2352
+ # the list path was leaking.
2353
+ member_project_ids = ProjectMembership.objects.filter(
2354
+ user=user, is_deleted=False, project__is_deleted=False
2355
+ ).values_list("project_id", flat=True)
2356
+ qs = qs.filter(task__project_id__in=member_project_ids)
2357
+ return qs
2358
+
2359
+ @staticmethod
2360
+ def _broadcast_settled(project_id: Any, suggestion_pk: Any, event_type: str) -> None:
2361
+ """Announce that a velocity suggestion was accepted or dismissed (#2845).
2362
+
2363
+ Both decisions mutate shared state a collaborator has on screen — the
2364
+ suggestion disappears from the pending list for everyone — and neither
2365
+ emitted anything before. Deferred to commit and built from plain scalars so
2366
+ the closure never re-reads an ORM instance (broadcast-check H-1). The payload
2367
+ is ID-only: a client re-reads through the serializer, which re-applies the
2368
+ ADR-0104 velocity gate the response itself goes through.
2369
+ """
2370
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
2371
+
2372
+ project_id_str = str(project_id)
2373
+ suggestion_id_str = str(suggestion_pk)
2374
+ transaction.on_commit(
2375
+ lambda: broadcast_board_event(project_id_str, event_type, {"id": suggestion_id_str})
2376
+ )
2377
+
2378
+ # ``request=None`` asserted rather than inferred (#2840): both decisions are
2379
+ # carried by the URL alone. The pre-fix schema published no requestBody here
2380
+ # only because VelocitySuggestionSerializer exposes no writable field — an
2381
+ # accident of the fallback that any future writable field would have reversed.
2382
+ # ``responses`` names the 409 the idempotency guards return so a client is not
2383
+ # left inferring it from the prose.
2384
+ @extend_schema(
2385
+ summary="Accept a velocity suggestion",
2386
+ request=None,
2387
+ responses={
2388
+ 200: VelocitySuggestionSerializer,
2389
+ 409: OpenApiResponse(description="Suggestion has already been dismissed."),
2390
+ 422: OpenApiResponse(
2391
+ description="Accepting would violate the task's three-point estimate ordering."
2392
+ ),
2393
+ },
2394
+ )
2395
+ @action(
2396
+ detail=True,
2397
+ methods=["post"],
2398
+ permission_classes=[IsAuthenticated, IsProjectAdmin, IsProjectNotArchived],
2399
+ )
2400
+ def accept(self, request: Request, pk: str | None = None) -> Response:
2401
+ """Apply the suggested duration to the task and enqueue a CPM recompute.
2402
+
2403
+ Idempotent: a suggestion that is already accepted returns 200 with the
2404
+ existing row. Re-applying after dismiss is rejected with 409 so the
2405
+ audit trail of the original decision is preserved.
2406
+ """
2407
+ suggestion = self.get_object()
2408
+ # Object-level permission keys off the task's project.
2409
+ self.check_object_permissions(request, suggestion.task)
2410
+
2411
+ if suggestion.accepted_at is not None:
2412
+ # get_serializer injects request context so the serializer's ADR-0104
2413
+ # velocity gate fires on these action responses too (#949).
2414
+ return Response(self.get_serializer(suggestion).data)
2415
+ if suggestion.dismissed_at is not None:
2416
+ return Response(
2417
+ {"detail": "Suggestion has already been dismissed."},
2418
+ status=status.HTTP_409_CONFLICT,
2419
+ )
2420
+
2421
+ task = suggestion.task
2422
+ project_id = task.project_id
2423
+
2424
+ # A velocity suggestion writes most_likely_duration alone. When the task
2425
+ # already carries a complete three-point estimate, overwriting the middle
2426
+ # value can break the optimistic <= most_likely <= pessimistic invariant
2427
+ # the scheduler enforces — the invalid triple then detonates the next
2428
+ # CPM/program recompute instead of failing here (#2002). Refuse so the PM
2429
+ # adjusts the optimistic/pessimistic estimates rather than corrupting them
2430
+ # silently. No-op for the common case where opt/pess are unset.
2431
+ if not three_point_estimates_ordered(
2432
+ task.optimistic_duration,
2433
+ suggestion.suggested_duration,
2434
+ task.pessimistic_duration,
2435
+ ):
2436
+ return Response(
2437
+ {
2438
+ "detail": (
2439
+ "Accepting this suggestion would set most likely duration to "
2440
+ f"{suggestion.suggested_duration} days, which violates this "
2441
+ "task's three-point estimate ordering (optimistic ≤ most "
2442
+ f"likely ≤ pessimistic; optimistic is {task.optimistic_duration}, "
2443
+ f"pessimistic is {task.pessimistic_duration}). Adjust the "
2444
+ "optimistic/pessimistic estimates first."
2445
+ )
2446
+ },
2447
+ status=status.HTTP_422_UNPROCESSABLE_ENTITY,
2448
+ )
2449
+
2450
+ with transaction.atomic():
2451
+ task.most_likely_duration = suggestion.suggested_duration
2452
+ task.save(update_fields=["most_likely_duration"])
2453
+ VelocitySuggestion.objects.filter(pk=suggestion.pk).update(
2454
+ accepted_at=timezone.now(),
2455
+ accepted_by=request.user,
2456
+ )
2457
+ # CPM cascade: the new most_likely_duration shifts the Monte Carlo
2458
+ # forecast on the next run. Enqueue inside the transaction so the
2459
+ # request is visible to the drain only after the task save commits.
2460
+ transaction.on_commit(
2461
+ lambda: enqueue_recalculate(
2462
+ str(project_id),
2463
+ reason=ScheduleRequestReason.TASK_CHANGE,
2464
+ )
2465
+ )
2466
+ # #2845: the recalc above does NOT cover this write. most_likely_duration
2467
+ # is a Monte Carlo input and is absent from _CPM_DELTA_FIELDS, so the
2468
+ # recalc's task_dates_updated delta — built from the subset of tasks whose
2469
+ # _CPM_DELTA_FIELDS actually moved — will not carry this task unless its
2470
+ # deterministic CPM output happens to shift for an unrelated reason. And
2471
+ # cpm_complete, which does fire unconditionally, is explicitly documented
2472
+ # on the client as pill-and-stats only ("the tasks cache is maintained by
2473
+ # task_dates_updated", ADR-0091). So there was no live-update path of any
2474
+ # kind for a field a collaborator can see change.
2475
+ #
2476
+ # Two events, because two things changed: the task's own field (the
2477
+ # canonical ADR-0152 delta, which the existing frontend handler already
2478
+ # knows how to apply) and the suggestion row settling.
2479
+ #
2480
+ # Scalars snapshotted before the closure, and both deferred to commit —
2481
+ # broadcast_task_updated is best-effort and its docstring requires the
2482
+ # caller to wrap it (broadcast-check H-1).
2483
+ from trueppm_api.apps.sync.broadcast import broadcast_task_updated
2484
+
2485
+ project_id_str = str(project_id)
2486
+ task_id_str = str(task.pk)
2487
+ task_version = task.server_version
2488
+ actor_id_str = str(request.user.pk)
2489
+ transaction.on_commit(
2490
+ lambda: broadcast_task_updated(
2491
+ project_id_str,
2492
+ task_id=task_id_str,
2493
+ changed_fields=["most_likely_duration"],
2494
+ version=task_version,
2495
+ actor_id=actor_id_str,
2496
+ )
2497
+ )
2498
+ self._broadcast_settled(project_id, suggestion.pk, "velocity_suggestion_accepted")
2499
+
2500
+ suggestion.refresh_from_db()
2501
+ # get_serializer injects request context so the serializer's ADR-0104
2502
+ # velocity gate fires on these action responses too (#949).
2503
+ return Response(self.get_serializer(suggestion).data)
2504
+
2505
+ @extend_schema(
2506
+ summary="Dismiss a velocity suggestion",
2507
+ request=None,
2508
+ responses={
2509
+ 200: VelocitySuggestionSerializer,
2510
+ 409: OpenApiResponse(description="Suggestion has already been accepted."),
2511
+ },
2512
+ )
2513
+ @action(
2514
+ detail=True,
2515
+ methods=["post"],
2516
+ permission_classes=[IsAuthenticated, IsProjectAdmin, IsProjectNotArchived],
2517
+ )
2518
+ def dismiss(self, request: Request, pk: str | None = None) -> Response:
2519
+ """Mark a suggestion as dismissed without touching the task.
2520
+
2521
+ Idempotent: a suggestion that is already dismissed returns 200 with the
2522
+ existing row. Dismissing after accept is rejected with 409 to preserve
2523
+ the original decision.
2524
+ """
2525
+ suggestion = self.get_object()
2526
+ self.check_object_permissions(request, suggestion.task)
2527
+
2528
+ if suggestion.dismissed_at is not None:
2529
+ # get_serializer injects request context so the serializer's ADR-0104
2530
+ # velocity gate fires on these action responses too (#949).
2531
+ return Response(self.get_serializer(suggestion).data)
2532
+ if suggestion.accepted_at is not None:
2533
+ return Response(
2534
+ {"detail": "Suggestion has already been accepted."},
2535
+ status=status.HTTP_409_CONFLICT,
2536
+ )
2537
+
2538
+ with transaction.atomic():
2539
+ VelocitySuggestion.objects.filter(pk=suggestion.pk).update(
2540
+ dismissed_at=timezone.now(),
2541
+ dismissed_by=request.user,
2542
+ )
2543
+ # #2845: dismissing removes the row from every other member's pending
2544
+ # list. Wrapped in atomic() so the on_commit registration inside
2545
+ # _broadcast_settled has a transaction to hang off — outside one it would
2546
+ # fire immediately, before the UPDATE is durable.
2547
+ self._broadcast_settled(
2548
+ suggestion.task.project_id, suggestion.pk, "velocity_suggestion_dismissed"
2549
+ )
2550
+ suggestion.refresh_from_db()
2551
+ # get_serializer injects request context so the serializer's ADR-0104
2552
+ # velocity gate fires on these action responses too (#949).
2553
+ return Response(self.get_serializer(suggestion).data)
2554
+
2555
+
2556
+ # ---------------------------------------------------------------------------
2557
+ # Schedule value derivation (ADR-0218, #1058)
2558
+ # ---------------------------------------------------------------------------
2559
+
2560
+ # The CPM quantities the derivation endpoint can explain. Mirrors
2561
+ # trueppm_scheduler.Quantity so the API contract and the engine cannot drift.
2562
+ # scheduled_start (ADR-0752) is included so a consumer — human or MCP client —
2563
+ # can ask "why does this task's SPAN start here", not only its remaining-work
2564
+ # window; scheduled_finish is deliberately NOT a member (it is always
2565
+ # identical to early_finish, so asking for it twice would invite two answers).
2566
+ _CPM_DERIVATION_QUANTITIES = frozenset(
2567
+ {
2568
+ "early_start",
2569
+ "early_finish",
2570
+ "late_start",
2571
+ "late_finish",
2572
+ "total_float",
2573
+ "free_float",
2574
+ "scheduled_start",
2575
+ }
2576
+ )
2577
+ # The Monte Carlo percentile quantities, whose derivation is the risk premium over
2578
+ # the deterministic CPM finish plus the ADR-0140 sensitivity drivers (#987).
2579
+ _MC_DERIVATION_QUANTITIES = frozenset({"p50", "p80", "p95"})
2580
+
2581
+
2582
+ def _build_cpm_sched_project(project: Project, pk: str) -> Any:
2583
+ """Assemble the scheduler ``Project`` for a deterministic CPM derivation run.
2584
+
2585
+ Mirrors the input construction of ``scheduling.tasks._run_schedule`` — the
2586
+ **deterministic pass that computes the stored schedule** — through the same shared
2587
+ converters (``compose_project_calendar`` / ``build_sched_tasks`` /
2588
+ ``build_sched_graph``), the same committed-only task set, the same
2589
+ cross-project/non-committed edge drop, and the same data-date and velocity inputs.
2590
+ A derivation explains a value a user is reading off a task row, and that value came
2591
+ from ``_run_schedule``; matching anything else explains a network nobody was shown.
2592
+
2593
+ This docstring used to name :func:`run_monte_carlo` as the thing to mirror, which
2594
+ is how it inherited that endpoint's defect wholesale: before #3527 neither applied
2595
+ the ADR-0105 summary strip, so the mirror was faithful and both were wrong. Naming
2596
+ the deterministic pass instead makes the next drift detectable, because
2597
+ ``_run_schedule`` is the only one of the three whose output is persisted and
2598
+ therefore independently checkable.
2599
+
2600
+ A summary row is consequently absent from the returned graph, and a derivation
2601
+ request naming one now gets the ``UnknownTaskError`` 404 — correct, because a
2602
+ summary's dates are rolled up from its leaves and were never produced by a CPM pass
2603
+ that could be explained.
2604
+ """
2605
+ from trueppm_scheduler.models import Project as SchedProject
2606
+
2607
+ from trueppm_api.apps.projects.services import scheduler_velocity_inputs
2608
+
2609
+ sched_calendar = compose_project_calendar(project)
2610
+ db_tasks = list(Task.committed.filter(project=project).select_related("sprint"))
2611
+ suggest_approve = project.estimation_mode == EstimationMode.SUGGEST_APPROVE
2612
+ sched_tasks = build_sched_tasks(db_tasks, suggest_approve=suggest_approve)
2613
+
2614
+ included_ids = {str(t.id) for t in db_tasks}
2615
+ # Was a second, byte-identical copy of _build_sched_deps' body (#3527). Behaviour
2616
+ # was the same, so nothing was wrong today — but a duplicated edge filter is the
2617
+ # very drift class this issue is an instance of, and the next change to the edge
2618
+ # rules (soft-deleted dependencies, #3532) would have had to find both.
2619
+ #
2620
+ # #3532 is that next change, and it landed on the deduplicated shape: the
2621
+ # soft-delete filter lives once, in _build_sched_deps' Dependency.live read, and
2622
+ # the derivation inherits it here rather than carrying its own copy.
2623
+ sched_deps = _build_sched_deps(pk, included_ids)
2624
+
2625
+ velocity_samples, sprint_length_days = scheduler_velocity_inputs(
2626
+ project.pk, sched_calendar.working_days
2627
+ )
2628
+
2629
+ graph = build_sched_graph(db_tasks, sched_tasks, sched_deps)
2630
+
2631
+ return SchedProject(
2632
+ id=str(project.pk),
2633
+ name=project.name,
2634
+ start_date=project.start_date,
2635
+ tasks=graph.tasks,
2636
+ dependencies=graph.dependencies,
2637
+ calendar=sched_calendar,
2638
+ status_date=resolve_cpm_status_date(project.status_date),
2639
+ velocity_samples=velocity_samples or None,
2640
+ sprint_length_days=sprint_length_days,
2641
+ )
2642
+
2643
+
2644
+ class ScheduleDerivationView(McpReadableViewMixin, APIView):
2645
+ """The server-computed *why* behind a computed schedule value (ADR-0218, #1058).
2646
+
2647
+ ``GET /projects/<pk>/schedule/derivation/?task_id=<id>&quantity=<quantity>``
2648
+
2649
+ Returns the derivation of a single computed value — the driving predecessor/
2650
+ successor chain, the binding constraint, each term's lag and calendar-snap
2651
+ contribution, and which CPM pass (forward/backward/float) set it. The
2652
+ derivation is computed server-side from the engine's own pass data by
2653
+ :func:`trueppm_scheduler.derive_value`; it is never recomputed in the browser
2654
+ and nothing is fabricated (rule 120) — a contribution is emitted only for a
2655
+ constraint the engine actually evaluates.
2656
+
2657
+ For a Monte Carlo percentile quantity (``p50``/``p80``/``p95``) the "why" is
2658
+ the deterministic ``cpm_finish``, the signed ``delta_vs_cpm`` risk premium, and
2659
+ the ADR-0140 sensitivity drivers of the latest persisted run (#987) — the
2660
+ existing engine output surfaced honestly, not a second recomputation.
2661
+
2662
+ RBAC: any project member (Viewer+) may read a derivation; ``McpReadableViewMixin``
2663
+ additionally exposes it to an ``mcp:read`` API token confined to safe methods,
2664
+ so an AI/MCP agent can cite the *why* alongside the value.
2665
+ """
2666
+
2667
+ # ADR-0678 (#2482): projects/<pk>/schedule/derivation/
2668
+ mcp_scope = McpScope.PATH
2669
+
2670
+ # Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
2671
+ # this the project-scoped classes below resolve nothing and `has_permission`
2672
+ # returns True for everyone — the gate reads as live in review and enforces
2673
+ # nothing. Declared per view because `pk` means something else on other routes.
2674
+ project_url_kwarg = "pk"
2675
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
2676
+
2677
+ # A CPM quantity runs a full ``schedule()`` pass inside ``derive_value`` on every
2678
+ # call (no precomputed result is passed), so this is as compute-heavy as its
2679
+ # Monte Carlo siblings and was missed by #1808's enumeration (#2839). The scope
2680
+ # bounds human callers; ``mcp_compute_heavy`` stacks the tighter per-token bucket.
2681
+ mcp_compute_heavy = True
2682
+ throttle_classes = [ScopedRateThrottle]
2683
+ throttle_scope = "cpm_on_read"
2684
+
2685
+ @extend_schema(
2686
+ operation_id="project_schedule_derivation",
2687
+ summary="Derivation (the why) of a computed schedule value",
2688
+ parameters=[
2689
+ OpenApiParameter(
2690
+ name="task_id",
2691
+ type=OpenApiTypes.STR,
2692
+ location=OpenApiParameter.QUERY,
2693
+ required=True,
2694
+ description="The task whose computed value is being explained.",
2695
+ ),
2696
+ OpenApiParameter(
2697
+ name="quantity",
2698
+ type=OpenApiTypes.STR,
2699
+ location=OpenApiParameter.QUERY,
2700
+ required=True,
2701
+ description=(
2702
+ "Which computed value to explain: a CPM quantity "
2703
+ "(early_start, early_finish, late_start, late_finish, "
2704
+ "total_float, free_float, scheduled_start) or a Monte Carlo "
2705
+ "percentile (p50, p80, p95). CPM quantities return {task_id, task_name, "
2706
+ "quantity, value, pass, is_critical, binding, contributions[]}, "
2707
+ "each contribution carrying its source task, dep_type, lag_days, "
2708
+ "imposed_date, calendar_days_added, slack_days, and is_binding. "
2709
+ "Percentiles return {quantity, value, value_at_day_start, cpm_finish, "
2710
+ "cpm_finish_at_day_start, delta_vs_cpm_days (measured in working time, "
2711
+ "#4204), drivers[]} from the latest simulation."
2712
+ ),
2713
+ ),
2714
+ ],
2715
+ responses={
2716
+ 200: OpenApiResponse(
2717
+ response=PolymorphicProxySerializer(
2718
+ component_name="ScheduleDerivationResponse",
2719
+ serializers=[
2720
+ ScheduleCpmDerivationSerializer,
2721
+ ScheduleMonteCarloDerivationSerializer,
2722
+ ],
2723
+ # Neither shape carries a field naming which one it is — the
2724
+ # caller already knows, from the `quantity` query param it sent,
2725
+ # which shape comes back. A discriminator field would have to be
2726
+ # fabricated onto the wire payload, which ADR-0218's "nothing is
2727
+ # fabricated" rule (rule 120) forbids just to satisfy
2728
+ # client-generator tooling.
2729
+ resource_type_field_name=None,
2730
+ ),
2731
+ description=(
2732
+ "The derivation of the requested value. A CPM quantity returns "
2733
+ "ScheduleCpmDerivationResponse; a Monte Carlo percentile "
2734
+ "(p50/p80/p95) returns ScheduleMonteCarloDerivationResponse, which "
2735
+ "additionally carries forecast_staleness "
2736
+ "(current|project_changed|aged|unknown), plan_version and "
2737
+ "plan_version_current (#3140), so a cited percentile states whether "
2738
+ "the run it comes from still describes the current plan rather than "
2739
+ "presenting a superseded value with full derivation confidence. "
2740
+ "`project_changed` means the project was written to (or its CPM "
2741
+ "output recomputed) since the run — grounds for rerunning, NOT "
2742
+ "evidence that a schedule date changed."
2743
+ ),
2744
+ ),
2745
+ 400: OpenApiResponse(
2746
+ description="Missing/unknown quantity, or invalid schedule input."
2747
+ ),
2748
+ 404: OpenApiResponse(
2749
+ description="Unknown project or task, or no simulation available."
2750
+ ),
2751
+ },
2752
+ )
2753
+ def get(self, request: Request, pk: str) -> Response:
2754
+ from trueppm_scheduler.derive import UnknownTaskError, derive_value
2755
+ from trueppm_scheduler.engine import CyclicDependencyError
2756
+
2757
+ quantity = request.query_params.get("quantity")
2758
+ if not quantity:
2759
+ return Response(
2760
+ {"detail": "The 'quantity' query parameter is required."},
2761
+ status=status.HTTP_400_BAD_REQUEST,
2762
+ )
2763
+ if quantity not in _CPM_DERIVATION_QUANTITIES and quantity not in _MC_DERIVATION_QUANTITIES:
2764
+ allowed = ", ".join(sorted(_CPM_DERIVATION_QUANTITIES | _MC_DERIVATION_QUANTITIES))
2765
+ return Response(
2766
+ {"detail": f"Unknown quantity {quantity!r}; must be one of: {allowed}."},
2767
+ status=status.HTTP_400_BAD_REQUEST,
2768
+ )
2769
+
2770
+ project = get_object_or_404(
2771
+ Project.objects.select_related("calendar", "program__calendar").prefetch_related(
2772
+ "tasks",
2773
+ "calendar__exceptions",
2774
+ "calendar_layers__calendar__exceptions",
2775
+ # ADR-0441: the resolver reads the program calendar's exceptions when a
2776
+ # project inherits its base calendar from the program.
2777
+ "program__calendar__exceptions",
2778
+ ),
2779
+ pk=pk,
2780
+ is_deleted=False,
2781
+ )
2782
+ # 404 (not 403) for a non-member: the object-scope oracle keeps a project's
2783
+ # existence from leaking to a non-member (mirrors MonteCarloLatestView).
2784
+ self.check_object_permissions(request, project)
2785
+
2786
+ if quantity in _MC_DERIVATION_QUANTITIES:
2787
+ return self._monte_carlo_derivation(project, quantity)
2788
+
2789
+ task_id = request.query_params.get("task_id")
2790
+ if not task_id:
2791
+ return Response(
2792
+ {"detail": "The 'task_id' query parameter is required for a CPM quantity."},
2793
+ status=status.HTTP_400_BAD_REQUEST,
2794
+ )
2795
+
2796
+ # _build_cpm_sched_project now shapes the graph (#3527), so it can raise the
2797
+ # engine's InvalidScheduleInput. It is inside the try for the same reason
2798
+ # derive_value is: an unguarded ValueError here becomes a 500, and this view
2799
+ # already documents a 400 for "invalid schedule input".
2800
+ try:
2801
+ sched_project = _build_cpm_sched_project(project, pk)
2802
+ if not sched_project.tasks:
2803
+ return Response(
2804
+ {"detail": "Project has no committed tasks to schedule."},
2805
+ status=status.HTTP_404_NOT_FOUND,
2806
+ )
2807
+ derivation = derive_value(sched_project, task_id, quantity)
2808
+ except UnknownTaskError:
2809
+ return Response(
2810
+ {"detail": f"Task {task_id!r} is not in the scheduled network."},
2811
+ status=status.HTTP_404_NOT_FOUND,
2812
+ )
2813
+ except (CyclicDependencyError, ValueError) as exc:
2814
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
2815
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
2816
+ except OverflowError:
2817
+ return Response(
2818
+ {"detail": _DATE_RANGE_EXCEEDED_DETAIL},
2819
+ status=status.HTTP_400_BAD_REQUEST,
2820
+ )
2821
+ return Response(derivation.to_dict())
2822
+
2823
+ def _monte_carlo_derivation(self, project: Project, quantity: str) -> Response:
2824
+ """Derive a Monte Carlo percentile from the latest run (#987).
2825
+
2826
+ The percentile's "why" is the deterministic ``cpm_finish`` it is measured
2827
+ against, the signed ``delta_vs_cpm`` days of risk premium, and the ADR-0140
2828
+ sensitivity drivers (the tasks whose duration most moves the finish). All
2829
+ three are already computed on the run — this surfaces them, it does not
2830
+ recompute the forecast.
2831
+
2832
+ Reads the shared ``mc_latest`` cache first, falling back to the most recent
2833
+ persisted ``MonteCarloRun`` — the same order as ``MonteCarloLatestView``.
2834
+ The cache-first read matters since #1502: a Member/Viewer-triggered run
2835
+ refreshes the cache but persists no attributed row, and the derivation is
2836
+ part of the open read surface, so it must see cache-only forecasts.
2837
+ """
2838
+ # This endpoint exists so an agent can cite the *why* beside the value
2839
+ # (ADR-0218). A percentile whose run predates the current plan is a stale
2840
+ # citation delivered with full derivation confidence, so the staleness
2841
+ # discriminant travels with the derivation too (#3140) — otherwise
2842
+ # `get_monte_carlo_forecast` and `get_schedule_derivation` report opposite
2843
+ # trustworthiness for one run.
2844
+ # Read off the project `get()` already loaded — it is fetched without
2845
+ # `.only()`/`.defer()`, so both columns are in memory and this costs nothing.
2846
+ # `_current_plan_state` exists for the caller that does NOT hold the row.
2847
+ pk = project.pk
2848
+ plan_version_current = project.last_sync_version
2849
+ recalculated_at = project.recalculated_at
2850
+ today = timezone.localdate()
2851
+ cached = cache.get(mc_latest_cache_key(pk))
2852
+ if cached is not None:
2853
+ return Response(
2854
+ {
2855
+ "quantity": quantity,
2856
+ "value": cached.get(quantity),
2857
+ "pass": "monte_carlo",
2858
+ "cpm_finish": cached.get("cpm_finish"),
2859
+ "value_at_day_start": cached.get(f"{quantity}_at_day_start"),
2860
+ "cpm_finish_at_day_start": cached.get("cpm_finish_at_day_start"),
2861
+ "delta_vs_cpm_days": (cached.get("delta_vs_cpm") or {}).get(quantity),
2862
+ "drivers": cached.get("sensitivity", []),
2863
+ "runs": cached.get("runs"),
2864
+ "last_run_at": cached.get("last_run_at"),
2865
+ **forecast_staleness_for_payload(
2866
+ cached,
2867
+ plan_version_current=plan_version_current,
2868
+ recalculated_at=recalculated_at,
2869
+ today=today,
2870
+ ),
2871
+ }
2872
+ )
2873
+ latest = MonteCarloRun.objects.filter(project_id=pk).order_by("-taken_at").first()
2874
+ if latest is None:
2875
+ return Response(
2876
+ {"detail": "No simulation result available; run a Monte Carlo forecast first."},
2877
+ status=status.HTTP_404_NOT_FOUND,
2878
+ )
2879
+ percentile_date = {"p50": latest.p50, "p80": latest.p80, "p95": latest.p95}[quantity]
2880
+ dist = latest.distribution or {}
2881
+ return Response(
2882
+ {
2883
+ "quantity": quantity,
2884
+ "value": percentile_date.isoformat() if percentile_date else None,
2885
+ "pass": "monte_carlo",
2886
+ "cpm_finish": latest.cpm_finish.isoformat() if latest.cpm_finish else None,
2887
+ "value_at_day_start": getattr(latest, f"{quantity}_at_day_start"),
2888
+ "cpm_finish_at_day_start": latest.cpm_finish_at_day_start,
2889
+ "delta_vs_cpm_days": _delta_vs_cpm_days(
2890
+ percentile_date,
2891
+ latest.cpm_finish,
2892
+ percentile_at_day_start=getattr(latest, f"{quantity}_at_day_start"),
2893
+ cpm_finish_at_day_start=latest.cpm_finish_at_day_start,
2894
+ calendar_for=lambda: compose_project_calendar(project),
2895
+ ),
2896
+ # ADR-0140 tornado: the tasks whose duration variance drives the
2897
+ # finish — the honest "why" behind a probabilistic date.
2898
+ "drivers": dist.get("sensitivity", []),
2899
+ "runs": latest.n_simulations,
2900
+ "last_run_at": latest.taken_at.isoformat(),
2901
+ **forecast_staleness_from_run(
2902
+ latest,
2903
+ plan_version_current=plan_version_current,
2904
+ recalculated_at=recalculated_at,
2905
+ today=today,
2906
+ ),
2907
+ }
2908
+ )