trueppm-api 0.4.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (759) hide show
  1. trueppm_api/__init__.py +14 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +164 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  24. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  25. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  26. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  27. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
  28. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  29. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  30. trueppm_api/apps/access/migrations/__init__.py +0 -0
  31. trueppm_api/apps/access/models.py +588 -0
  32. trueppm_api/apps/access/permissions.py +2837 -0
  33. trueppm_api/apps/access/serializers.py +946 -0
  34. trueppm_api/apps/access/services.py +667 -0
  35. trueppm_api/apps/access/signals.py +103 -0
  36. trueppm_api/apps/access/tasks.py +50 -0
  37. trueppm_api/apps/access/throttles.py +95 -0
  38. trueppm_api/apps/access/urls.py +191 -0
  39. trueppm_api/apps/access/views.py +1931 -0
  40. trueppm_api/apps/agents/__init__.py +6 -0
  41. trueppm_api/apps/agents/apps.py +11 -0
  42. trueppm_api/apps/agents/canonical.py +73 -0
  43. trueppm_api/apps/agents/deferred.py +108 -0
  44. trueppm_api/apps/agents/management/__init__.py +0 -0
  45. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  46. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  47. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  48. trueppm_api/apps/agents/middleware.py +44 -0
  49. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  50. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  51. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  52. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  53. trueppm_api/apps/agents/models.py +371 -0
  54. trueppm_api/apps/agents/refusal.py +103 -0
  55. trueppm_api/apps/agents/serializers.py +85 -0
  56. trueppm_api/apps/agents/services.py +397 -0
  57. trueppm_api/apps/agents/signals.py +30 -0
  58. trueppm_api/apps/agents/urls.py +15 -0
  59. trueppm_api/apps/agents/views.py +108 -0
  60. trueppm_api/apps/csvimport/__init__.py +12 -0
  61. trueppm_api/apps/csvimport/apps.py +11 -0
  62. trueppm_api/apps/csvimport/mapping.py +363 -0
  63. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  64. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  65. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  66. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  67. trueppm_api/apps/csvimport/models.py +111 -0
  68. trueppm_api/apps/csvimport/parser.py +1825 -0
  69. trueppm_api/apps/csvimport/services.py +48 -0
  70. trueppm_api/apps/csvimport/tasks.py +418 -0
  71. trueppm_api/apps/csvimport/template.py +37 -0
  72. trueppm_api/apps/csvimport/urls.py +50 -0
  73. trueppm_api/apps/csvimport/views.py +793 -0
  74. trueppm_api/apps/history/__init__.py +0 -0
  75. trueppm_api/apps/history/apps.py +12 -0
  76. trueppm_api/apps/history/changelog.py +359 -0
  77. trueppm_api/apps/history/diff_policy.py +177 -0
  78. trueppm_api/apps/history/serializers.py +104 -0
  79. trueppm_api/apps/history/signals.py +82 -0
  80. trueppm_api/apps/history/tasks.py +105 -0
  81. trueppm_api/apps/history/urls.py +38 -0
  82. trueppm_api/apps/history/views.py +443 -0
  83. trueppm_api/apps/idempotency/__init__.py +0 -0
  84. trueppm_api/apps/idempotency/apps.py +11 -0
  85. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  86. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  87. trueppm_api/apps/idempotency/mixins.py +226 -0
  88. trueppm_api/apps/idempotency/models.py +75 -0
  89. trueppm_api/apps/idempotency/tasks.py +61 -0
  90. trueppm_api/apps/integrations/__init__.py +13 -0
  91. trueppm_api/apps/integrations/apps.py +59 -0
  92. trueppm_api/apps/integrations/connections.py +806 -0
  93. trueppm_api/apps/integrations/encryption.py +92 -0
  94. trueppm_api/apps/integrations/external_sources.py +837 -0
  95. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  96. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  97. trueppm_api/apps/integrations/http.py +403 -0
  98. trueppm_api/apps/integrations/me_work.py +218 -0
  99. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  100. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  101. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  102. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  103. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  104. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  105. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  106. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  107. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  108. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  109. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  110. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  111. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  112. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  113. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  114. trueppm_api/apps/integrations/models.py +622 -0
  115. trueppm_api/apps/integrations/notification_channels.py +57 -0
  116. trueppm_api/apps/integrations/opengraph.py +229 -0
  117. trueppm_api/apps/integrations/outgoing.py +201 -0
  118. trueppm_api/apps/integrations/providers.py +519 -0
  119. trueppm_api/apps/integrations/registry.py +345 -0
  120. trueppm_api/apps/integrations/serializers.py +453 -0
  121. trueppm_api/apps/integrations/services.py +171 -0
  122. trueppm_api/apps/integrations/tasks.py +731 -0
  123. trueppm_api/apps/integrations/throttles.py +165 -0
  124. trueppm_api/apps/integrations/urls.py +77 -0
  125. trueppm_api/apps/integrations/views.py +1106 -0
  126. trueppm_api/apps/jiraimport/__init__.py +8 -0
  127. trueppm_api/apps/jiraimport/apps.py +11 -0
  128. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  129. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  130. trueppm_api/apps/jiraimport/models.py +70 -0
  131. trueppm_api/apps/jiraimport/parser.py +350 -0
  132. trueppm_api/apps/jiraimport/services.py +46 -0
  133. trueppm_api/apps/jiraimport/tasks.py +277 -0
  134. trueppm_api/apps/jiraimport/urls.py +15 -0
  135. trueppm_api/apps/jiraimport/views.py +153 -0
  136. trueppm_api/apps/msproject/__init__.py +0 -0
  137. trueppm_api/apps/msproject/apps.py +11 -0
  138. trueppm_api/apps/msproject/dataclasses.py +153 -0
  139. trueppm_api/apps/msproject/exporter.py +500 -0
  140. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  141. trueppm_api/apps/msproject/importer.py +1095 -0
  142. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  143. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  144. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  145. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  146. trueppm_api/apps/msproject/models.py +81 -0
  147. trueppm_api/apps/msproject/parser.py +1231 -0
  148. trueppm_api/apps/msproject/serializers.py +136 -0
  149. trueppm_api/apps/msproject/services.py +58 -0
  150. trueppm_api/apps/msproject/tasks.py +436 -0
  151. trueppm_api/apps/msproject/urls.py +39 -0
  152. trueppm_api/apps/msproject/views.py +531 -0
  153. trueppm_api/apps/notifications/__init__.py +7 -0
  154. trueppm_api/apps/notifications/apps.py +15 -0
  155. trueppm_api/apps/notifications/backfill.py +79 -0
  156. trueppm_api/apps/notifications/categories.py +112 -0
  157. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  158. trueppm_api/apps/notifications/digests.py +447 -0
  159. trueppm_api/apps/notifications/email_backend.py +330 -0
  160. trueppm_api/apps/notifications/email_health.py +128 -0
  161. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  162. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
  163. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  164. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  165. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  166. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  167. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  168. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  169. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  170. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  171. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  172. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  173. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  174. trueppm_api/apps/notifications/models.py +1197 -0
  175. trueppm_api/apps/notifications/receivers.py +155 -0
  176. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  177. trueppm_api/apps/notifications/serializers.py +961 -0
  178. trueppm_api/apps/notifications/services.py +1451 -0
  179. trueppm_api/apps/notifications/tasks.py +711 -0
  180. trueppm_api/apps/notifications/throttles.py +98 -0
  181. trueppm_api/apps/notifications/urls.py +97 -0
  182. trueppm_api/apps/notifications/views.py +904 -0
  183. trueppm_api/apps/observability/__init__.py +0 -0
  184. trueppm_api/apps/observability/apps.py +31 -0
  185. trueppm_api/apps/observability/logging.py +213 -0
  186. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  187. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  188. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  189. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  190. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  191. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  192. trueppm_api/apps/observability/models.py +141 -0
  193. trueppm_api/apps/observability/otel/__init__.py +45 -0
  194. trueppm_api/apps/observability/otel/attributes.py +247 -0
  195. trueppm_api/apps/observability/otel/export_health.py +533 -0
  196. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  197. trueppm_api/apps/observability/otel/metrics.py +551 -0
  198. trueppm_api/apps/observability/otel/provider.py +511 -0
  199. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  200. trueppm_api/apps/observability/purge_registry.py +124 -0
  201. trueppm_api/apps/observability/retention.py +139 -0
  202. trueppm_api/apps/observability/selectors.py +969 -0
  203. trueppm_api/apps/observability/serializers.py +142 -0
  204. trueppm_api/apps/observability/services.py +407 -0
  205. trueppm_api/apps/observability/tasks.py +266 -0
  206. trueppm_api/apps/observability/urls.py +27 -0
  207. trueppm_api/apps/observability/views.py +523 -0
  208. trueppm_api/apps/profiles/__init__.py +0 -0
  209. trueppm_api/apps/profiles/apps.py +15 -0
  210. trueppm_api/apps/profiles/constants.py +54 -0
  211. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  212. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  213. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  214. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  215. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  216. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  217. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  218. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  219. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  220. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  221. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  222. trueppm_api/apps/profiles/models.py +276 -0
  223. trueppm_api/apps/profiles/serializers.py +199 -0
  224. trueppm_api/apps/profiles/services.py +466 -0
  225. trueppm_api/apps/profiles/views.py +124 -0
  226. trueppm_api/apps/projects/__init__.py +0 -0
  227. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  228. trueppm_api/apps/projects/amend.py +133 -0
  229. trueppm_api/apps/projects/apps.py +38 -0
  230. trueppm_api/apps/projects/asset_feed.py +459 -0
  231. trueppm_api/apps/projects/asset_views.py +317 -0
  232. trueppm_api/apps/projects/attachment_policy.py +278 -0
  233. trueppm_api/apps/projects/authentication.py +449 -0
  234. trueppm_api/apps/projects/backfill.py +287 -0
  235. trueppm_api/apps/projects/backlog_services.py +324 -0
  236. trueppm_api/apps/projects/backlog_views.py +302 -0
  237. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  238. trueppm_api/apps/projects/blocker_services.py +510 -0
  239. trueppm_api/apps/projects/board_activity.py +462 -0
  240. trueppm_api/apps/projects/board_activity_views.py +186 -0
  241. trueppm_api/apps/projects/board_lanes.py +134 -0
  242. trueppm_api/apps/projects/bulk_settings.py +194 -0
  243. trueppm_api/apps/projects/bundled_templates.py +215 -0
  244. trueppm_api/apps/projects/calendar_settings.py +180 -0
  245. trueppm_api/apps/projects/ceremony_views.py +244 -0
  246. trueppm_api/apps/projects/commit_moment.py +204 -0
  247. trueppm_api/apps/projects/config_notice.py +1325 -0
  248. trueppm_api/apps/projects/custom_field_values.py +266 -0
  249. trueppm_api/apps/projects/decisions_services.py +93 -0
  250. trueppm_api/apps/projects/decisions_views.py +94 -0
  251. trueppm_api/apps/projects/estimation_scale.py +126 -0
  252. trueppm_api/apps/projects/export_bundle.py +353 -0
  253. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
  254. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
  255. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
  256. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
  257. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
  258. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  259. trueppm_api/apps/projects/inbound_sync.py +555 -0
  260. trueppm_api/apps/projects/iteration_label.py +85 -0
  261. trueppm_api/apps/projects/lifecycle.py +191 -0
  262. trueppm_api/apps/projects/management/__init__.py +0 -0
  263. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  264. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  265. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  266. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  267. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  268. trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
  269. trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
  270. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
  271. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  272. trueppm_api/apps/projects/mcp_settings.py +305 -0
  273. trueppm_api/apps/projects/methodology.py +178 -0
  274. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  275. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
  276. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  277. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  278. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  279. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  280. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  281. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  282. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  283. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  284. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  285. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  286. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  287. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  288. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  289. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  290. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  291. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  292. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  293. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  294. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  295. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  296. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  297. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  298. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  299. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  300. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  301. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  302. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  303. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  304. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  305. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  306. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  307. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  308. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  309. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  310. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  311. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  312. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  313. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  314. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  315. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  316. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  317. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  318. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  319. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  320. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  321. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  322. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  323. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  324. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  325. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  326. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  327. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  328. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  329. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  330. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  331. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  332. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  333. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  334. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  335. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  336. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  337. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  338. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  339. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  340. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  341. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  342. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  343. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  344. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  345. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  346. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  347. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  348. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  349. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  350. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  351. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  352. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  353. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  354. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  355. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  356. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  357. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  358. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  359. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  360. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  361. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  362. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  363. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  364. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  365. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  366. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  367. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  368. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
  369. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  370. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  371. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  372. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  373. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  374. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  375. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  376. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  377. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  378. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  379. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
  380. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  381. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  382. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  383. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  384. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  385. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
  386. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  387. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  388. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  389. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  390. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  391. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  392. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  393. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  394. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  395. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  396. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  397. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  398. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  399. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  400. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  401. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  402. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  403. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  404. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  405. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  406. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  407. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  408. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  409. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  410. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  411. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  412. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  413. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  414. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  415. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  416. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  417. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  418. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  419. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  420. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  421. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  422. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  423. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  424. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  425. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  426. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  427. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  428. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  429. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  430. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  431. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  432. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  433. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  434. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  435. trueppm_api/apps/projects/models.py +8946 -0
  436. trueppm_api/apps/projects/poker_services.py +154 -0
  437. trueppm_api/apps/projects/poker_views.py +398 -0
  438. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  439. trueppm_api/apps/projects/program_label_services.py +94 -0
  440. trueppm_api/apps/projects/program_label_views.py +189 -0
  441. trueppm_api/apps/projects/program_rollup.py +575 -0
  442. trueppm_api/apps/projects/program_schedule.py +745 -0
  443. trueppm_api/apps/projects/program_views.py +3220 -0
  444. trueppm_api/apps/projects/project_templates.py +422 -0
  445. trueppm_api/apps/projects/receivers.py +234 -0
  446. trueppm_api/apps/projects/refusal_codes.py +140 -0
  447. trueppm_api/apps/projects/reorder_services.py +145 -0
  448. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  449. trueppm_api/apps/projects/retro_board_services.py +368 -0
  450. trueppm_api/apps/projects/retro_services.py +285 -0
  451. trueppm_api/apps/projects/risk_import.py +396 -0
  452. trueppm_api/apps/projects/row_identity.py +113 -0
  453. trueppm_api/apps/projects/schema_migrations.py +228 -0
  454. trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
  455. trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
  456. trueppm_api/apps/projects/seed/__init__.py +61 -0
  457. trueppm_api/apps/projects/seed/exporter.py +1644 -0
  458. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  459. trueppm_api/apps/projects/seed/importer.py +2390 -0
  460. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  461. trueppm_api/apps/projects/seed/reldates.py +126 -0
  462. trueppm_api/apps/projects/seed/replace.py +126 -0
  463. trueppm_api/apps/projects/seed/replay.py +1691 -0
  464. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  465. trueppm_api/apps/projects/seed/samples.py +427 -0
  466. trueppm_api/apps/projects/seed/validation.py +1149 -0
  467. trueppm_api/apps/projects/serializers.py +12310 -0
  468. trueppm_api/apps/projects/services.py +6966 -0
  469. trueppm_api/apps/projects/share_serializers.py +132 -0
  470. trueppm_api/apps/projects/share_services.py +325 -0
  471. trueppm_api/apps/projects/share_views.py +374 -0
  472. trueppm_api/apps/projects/sharing_settings.py +187 -0
  473. trueppm_api/apps/projects/signal_privacy_services.py +909 -0
  474. trueppm_api/apps/projects/signal_privacy_views.py +476 -0
  475. trueppm_api/apps/projects/signals.py +132 -0
  476. trueppm_api/apps/projects/slip_conflict.py +266 -0
  477. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  478. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  479. trueppm_api/apps/projects/standup.py +304 -0
  480. trueppm_api/apps/projects/standup_views.py +99 -0
  481. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  482. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  483. trueppm_api/apps/projects/surface_visibility.py +126 -0
  484. trueppm_api/apps/projects/task_batch_services.py +379 -0
  485. trueppm_api/apps/projects/task_bulk.py +1152 -0
  486. trueppm_api/apps/projects/task_classification.py +561 -0
  487. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  488. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  489. trueppm_api/apps/projects/task_grouping.py +754 -0
  490. trueppm_api/apps/projects/tasks.py +1917 -0
  491. trueppm_api/apps/projects/template_divergence.py +168 -0
  492. trueppm_api/apps/projects/template_services.py +160 -0
  493. trueppm_api/apps/projects/template_tasks.py +258 -0
  494. trueppm_api/apps/projects/template_views.py +799 -0
  495. trueppm_api/apps/projects/throttles.py +311 -0
  496. trueppm_api/apps/projects/urls.py +1011 -0
  497. trueppm_api/apps/projects/utilization.py +885 -0
  498. trueppm_api/apps/projects/views.py +20572 -0
  499. trueppm_api/apps/projects/wbs_paths.py +119 -0
  500. trueppm_api/apps/resources/__init__.py +0 -0
  501. trueppm_api/apps/resources/apps.py +8 -0
  502. trueppm_api/apps/resources/capacity.py +121 -0
  503. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  504. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  505. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  506. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  507. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  508. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  509. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  510. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  511. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  512. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  513. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  514. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  515. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  516. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  517. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  518. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  519. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  520. trueppm_api/apps/resources/models.py +298 -0
  521. trueppm_api/apps/resources/serializers.py +426 -0
  522. trueppm_api/apps/resources/services.py +465 -0
  523. trueppm_api/apps/resources/urls.py +28 -0
  524. trueppm_api/apps/resources/views.py +1749 -0
  525. trueppm_api/apps/scheduling/__init__.py +0 -0
  526. trueppm_api/apps/scheduling/apps.py +153 -0
  527. trueppm_api/apps/scheduling/calendars.py +119 -0
  528. trueppm_api/apps/scheduling/deadletter.py +91 -0
  529. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  530. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  531. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  532. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  533. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  534. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  535. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  536. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  537. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  538. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  539. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  540. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  541. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  542. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  543. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  544. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  545. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  546. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  547. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  548. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  549. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  550. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  551. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  552. trueppm_api/apps/scheduling/models.py +516 -0
  553. trueppm_api/apps/scheduling/receivers.py +160 -0
  554. trueppm_api/apps/scheduling/risk_premium.py +283 -0
  555. trueppm_api/apps/scheduling/serializers.py +564 -0
  556. trueppm_api/apps/scheduling/services.py +1182 -0
  557. trueppm_api/apps/scheduling/signals.py +56 -0
  558. trueppm_api/apps/scheduling/tasks.py +1984 -0
  559. trueppm_api/apps/scheduling/telemetry.py +80 -0
  560. trueppm_api/apps/scheduling/units.py +35 -0
  561. trueppm_api/apps/scheduling/urls.py +57 -0
  562. trueppm_api/apps/scheduling/views.py +2564 -0
  563. trueppm_api/apps/sso/__init__.py +0 -0
  564. trueppm_api/apps/sso/apps.py +19 -0
  565. trueppm_api/apps/sso/extensions.py +130 -0
  566. trueppm_api/apps/sso/management/__init__.py +0 -0
  567. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  568. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  569. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  570. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  571. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  572. trueppm_api/apps/sso/models.py +148 -0
  573. trueppm_api/apps/sso/serializers.py +437 -0
  574. trueppm_api/apps/sso/services.py +1385 -0
  575. trueppm_api/apps/sso/urls.py +47 -0
  576. trueppm_api/apps/sso/views.py +935 -0
  577. trueppm_api/apps/sync/__init__.py +0 -0
  578. trueppm_api/apps/sync/apps.py +15 -0
  579. trueppm_api/apps/sync/backfill.py +208 -0
  580. trueppm_api/apps/sync/broadcast.py +320 -0
  581. trueppm_api/apps/sync/conflict.py +262 -0
  582. trueppm_api/apps/sync/consumers.py +471 -0
  583. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  584. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  585. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  586. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  587. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  588. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  589. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  590. trueppm_api/apps/sync/models.py +225 -0
  591. trueppm_api/apps/sync/pagination.py +231 -0
  592. trueppm_api/apps/sync/sequence.py +371 -0
  593. trueppm_api/apps/sync/serializers.py +687 -0
  594. trueppm_api/apps/sync/tasks.py +260 -0
  595. trueppm_api/apps/sync/throttles.py +175 -0
  596. trueppm_api/apps/sync/upload.py +501 -0
  597. trueppm_api/apps/sync/urls.py +17 -0
  598. trueppm_api/apps/sync/views.py +930 -0
  599. trueppm_api/apps/sync/ws_auth.py +193 -0
  600. trueppm_api/apps/taskruns/__init__.py +0 -0
  601. trueppm_api/apps/taskruns/apps.py +11 -0
  602. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  603. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  604. trueppm_api/apps/taskruns/models.py +68 -0
  605. trueppm_api/apps/taskruns/serializers.py +92 -0
  606. trueppm_api/apps/taskruns/tasks.py +60 -0
  607. trueppm_api/apps/taskruns/tracker.py +286 -0
  608. trueppm_api/apps/taskruns/urls.py +46 -0
  609. trueppm_api/apps/taskruns/views.py +195 -0
  610. trueppm_api/apps/teams/__init__.py +8 -0
  611. trueppm_api/apps/teams/apps.py +16 -0
  612. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  613. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
  614. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  615. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  616. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  617. trueppm_api/apps/teams/models.py +145 -0
  618. trueppm_api/apps/teams/permissions.py +149 -0
  619. trueppm_api/apps/teams/serializers.py +95 -0
  620. trueppm_api/apps/teams/services.py +434 -0
  621. trueppm_api/apps/teams/signals.py +61 -0
  622. trueppm_api/apps/teams/urls.py +34 -0
  623. trueppm_api/apps/teams/views.py +201 -0
  624. trueppm_api/apps/timetracking/__init__.py +0 -0
  625. trueppm_api/apps/timetracking/apps.py +9 -0
  626. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  627. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  628. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  629. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  630. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  631. trueppm_api/apps/timetracking/models.py +178 -0
  632. trueppm_api/apps/timetracking/serializers.py +175 -0
  633. trueppm_api/apps/timetracking/services.py +117 -0
  634. trueppm_api/apps/timetracking/urls.py +47 -0
  635. trueppm_api/apps/timetracking/views.py +526 -0
  636. trueppm_api/apps/webhooks/__init__.py +0 -0
  637. trueppm_api/apps/webhooks/apps.py +8 -0
  638. trueppm_api/apps/webhooks/backfill.py +90 -0
  639. trueppm_api/apps/webhooks/dispatch.py +144 -0
  640. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  641. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
  642. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  643. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  644. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  645. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  646. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  647. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  648. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  649. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
  650. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  651. trueppm_api/apps/webhooks/models.py +372 -0
  652. trueppm_api/apps/webhooks/serializers.py +318 -0
  653. trueppm_api/apps/webhooks/tasks.py +663 -0
  654. trueppm_api/apps/webhooks/urls.py +22 -0
  655. trueppm_api/apps/webhooks/views.py +383 -0
  656. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  657. trueppm_api/apps/workflow_engine/apps.py +23 -0
  658. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  659. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  660. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  661. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  662. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  663. trueppm_api/apps/workflow_engine/models.py +265 -0
  664. trueppm_api/apps/workflow_engine/tasks.py +368 -0
  665. trueppm_api/apps/workspace/__init__.py +0 -0
  666. trueppm_api/apps/workspace/apps.py +10 -0
  667. trueppm_api/apps/workspace/backfill.py +111 -0
  668. trueppm_api/apps/workspace/export.py +242 -0
  669. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  670. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  671. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  672. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  673. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  674. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  675. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  676. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  677. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  678. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  679. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  680. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  681. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  682. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  683. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  684. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  685. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  686. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  687. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  688. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  689. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  690. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  691. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  692. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  693. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  694. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  695. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  696. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  697. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  698. trueppm_api/apps/workspace/models.py +857 -0
  699. trueppm_api/apps/workspace/permissions.py +187 -0
  700. trueppm_api/apps/workspace/serializers.py +590 -0
  701. trueppm_api/apps/workspace/services.py +919 -0
  702. trueppm_api/apps/workspace/signals.py +37 -0
  703. trueppm_api/apps/workspace/tasks.py +555 -0
  704. trueppm_api/apps/workspace/urls.py +113 -0
  705. trueppm_api/apps/workspace/views.py +1502 -0
  706. trueppm_api/asgi.py +42 -0
  707. trueppm_api/celery.py +31 -0
  708. trueppm_api/core/__init__.py +0 -0
  709. trueppm_api/core/admin_site.py +206 -0
  710. trueppm_api/core/auth_views.py +916 -0
  711. trueppm_api/core/constant_time.py +51 -0
  712. trueppm_api/core/csp.py +54 -0
  713. trueppm_api/core/db.py +87 -0
  714. trueppm_api/core/db_session.py +37 -0
  715. trueppm_api/core/exception_handlers.py +157 -0
  716. trueppm_api/core/export_downloads.py +56 -0
  717. trueppm_api/core/extension_providers.py +56 -0
  718. trueppm_api/core/extension_signals.py +147 -0
  719. trueppm_api/core/idempotent.py +260 -0
  720. trueppm_api/core/metadata.py +42 -0
  721. trueppm_api/core/middleware.py +51 -0
  722. trueppm_api/core/openapi.py +949 -0
  723. trueppm_api/core/password_reset.py +500 -0
  724. trueppm_api/core/protect_conflict.py +100 -0
  725. trueppm_api/core/ratelimit.py +148 -0
  726. trueppm_api/core/redis_throttle.py +82 -0
  727. trueppm_api/core/refresh_cookie_policy.py +156 -0
  728. trueppm_api/core/request_body.py +86 -0
  729. trueppm_api/core/security_checks.py +884 -0
  730. trueppm_api/core/storage_config.py +105 -0
  731. trueppm_api/core/text_decode.py +168 -0
  732. trueppm_api/core/throttling.py +344 -0
  733. trueppm_api/core/valkey.py +214 -0
  734. trueppm_api/core/valkey_cache.py +66 -0
  735. trueppm_api/core/valkey_checks.py +104 -0
  736. trueppm_api/core/valkey_config.py +39 -0
  737. trueppm_api/core/worker_broker_wait.py +149 -0
  738. trueppm_api/core/worker_heartbeat.py +134 -0
  739. trueppm_api/fields.py +36 -0
  740. trueppm_api/permissions.py +30 -0
  741. trueppm_api/py.typed +0 -0
  742. trueppm_api/routing.py +11 -0
  743. trueppm_api/settings/__init__.py +0 -0
  744. trueppm_api/settings/base.py +2455 -0
  745. trueppm_api/settings/dev.py +273 -0
  746. trueppm_api/settings/prod.py +228 -0
  747. trueppm_api/urls.py +153 -0
  748. trueppm_api/workflows/__init__.py +38 -0
  749. trueppm_api/workflows/backends/__init__.py +6 -0
  750. trueppm_api/workflows/backends/default.py +297 -0
  751. trueppm_api/workflows/consumers/__init__.py +11 -0
  752. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  753. trueppm_api/workflows/interface.py +148 -0
  754. trueppm_api/workflows/registry.py +106 -0
  755. trueppm_api/workflows/services.py +90 -0
  756. trueppm_api/wsgi.py +11 -0
  757. trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
  758. trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
  759. trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
@@ -0,0 +1,1182 @@
1
+ """Service layer for scheduling outbox operations.
2
+
3
+ This module is the canonical home for ``enqueue_recalculate`` — the function
4
+ that writes a ``ScheduleRequest`` outbox row and attempts an immediate
5
+ best-effort Celery dispatch. It lives in the ``scheduling`` app because it
6
+ directly manages ``ScheduleRequest``, which is owned by that app.
7
+
8
+ It also owns ``compute_velocity_suggestions`` — the sprint-close hook that
9
+ generates ``VelocitySuggestion`` rows from rolling team velocity (ADR-0065).
10
+
11
+ Call sites:
12
+ - ``projects.views`` — task/dependency mutations (via ``transaction.on_commit``)
13
+ - ``scheduling.views`` — manual trigger endpoint (direct call, inside
14
+ ``ATOMIC_REQUESTS`` transaction)
15
+ - ``msproject.tasks`` — post-import recalculation (direct call, Celery task
16
+ has already committed its writes)
17
+ - ``projects.tasks.close_sprint`` — calls ``compute_velocity_suggestions``
18
+ inside the same atomic block as the SPRINT_CLOSED ScheduleRequest.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import statistics
25
+ import uuid
26
+ from dataclasses import dataclass
27
+ from datetime import date
28
+ from decimal import Decimal
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from django.db import IntegrityError, transaction
32
+ from django.utils import timezone
33
+
34
+ from trueppm_api.apps.scheduling.models import ScheduleRequestReason
35
+
36
+ if TYPE_CHECKING:
37
+ from trueppm_api.apps.projects.models import Calendar, Sprint
38
+ from trueppm_api.apps.scheduling.models import MonteCarloRun
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Minimum number of prior closed sprints required before any velocity
43
+ # suggestion is generated. Three sprints is the smallest sample that yields a
44
+ # stable rolling average; below that the suggestion is noise and would erode
45
+ # PM trust in the surface.
46
+ MIN_CLOSED_SPRINTS_FOR_SUGGESTION = 3
47
+
48
+ # Rolling window for team_velocity_per_day. ADR-0065 picks six sprints as the
49
+ # balance between responsiveness to a real velocity shift and smoothing of
50
+ # one-off scope churn.
51
+ VELOCITY_ROLLING_WINDOW = 6
52
+
53
+
54
+ def resolve_cpm_status_date(status_date: date | None) -> date:
55
+ """Resolve a project's CPM data date, flooring a null value at today.
56
+
57
+ Mirrors the Monte Carlo resolution that already lived at each call site
58
+ (``mc_status_date = project.status_date or timezone.localdate()``) so the
59
+ deterministic CPM pass and the read-time ``current`` block use the exact
60
+ same convention (ADR-0752 §4, correcting ADR-0132 §1). Before this, a null
61
+ ``status_date`` passed raw to the CPM path meant "no floor at all" — this
62
+ function is the single place that resolution happens, so the persisted
63
+ recalculation (``scheduling/tasks.py::_run_schedule``) and every read-time
64
+ caller (the Monte Carlo what-if ``current`` block, the schedule-derivation
65
+ builder) cannot drift on which data date they used.
66
+ """
67
+ return status_date or timezone.localdate()
68
+
69
+
70
+ def enqueue_recalculate(
71
+ project_id: str,
72
+ reason: ScheduleRequestReason = ScheduleRequestReason.TASK_CHANGE,
73
+ ) -> None:
74
+ """Insert a ScheduleRequest outbox row and attempt immediate dispatch.
75
+
76
+ Safe to call from:
77
+ - ``transaction.on_commit()`` callbacks (HTTP request context)
78
+ - Celery task bodies (no ambient transaction; ``atomic()`` opens its own)
79
+
80
+ The ``reason`` is recorded on the outbox row purely for forensics — it does
81
+ not change dispatch behavior. When the same project already has a PENDING
82
+ row, the existing row's reason is preserved (whatever triggered the queue
83
+ first wins) so debugging "why did this recalc fire?" still points at the
84
+ initial cause rather than the last edit to pile on.
85
+
86
+ If a pending row already exists for the project we adopt it — coalescing
87
+ every edit that happened while it was waiting into a single CPM run — and
88
+ still attempt the immediate dispatch. Without this, a stranded pending
89
+ row (e.g. from an earlier broker outage) would silently swallow every
90
+ subsequent edit until ``drain_schedule_queue`` ran, which is the failure
91
+ mode that produced #314.
92
+
93
+ If the broker is unavailable the row is left PENDING and
94
+ ``drain_schedule_queue`` picks it up within 30 seconds.
95
+ """
96
+ from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
97
+
98
+ req: ScheduleRequest
99
+ try:
100
+ with transaction.atomic():
101
+ req = ScheduleRequest.objects.create(project_id=project_id, reason=reason)
102
+ except IntegrityError:
103
+ # A pending row already exists. Adopt it instead of returning — the
104
+ # existing row may be stranded (the request that created it failed to
105
+ # dispatch, e.g. broker outage) and every subsequent edit will pile up
106
+ # on it. Re-dispatching it now coalesces all those edits into a single
107
+ # CPM run with the latest data.
108
+ existing = ScheduleRequest.objects.filter(
109
+ project_id=project_id,
110
+ status=ScheduleRequestStatus.PENDING,
111
+ ).first()
112
+ if existing is None:
113
+ # Race: row transitioned out of PENDING between insert + lookup.
114
+ # The other writer already dispatched it; nothing to do.
115
+ return
116
+ req = existing
117
+
118
+ # Best-effort immediate dispatch — reduces recalculation latency when the
119
+ # broker is healthy. Failure here is not fatal; the row stays PENDING.
120
+ from trueppm_api.apps.scheduling.tasks import recalculate_schedule
121
+
122
+ try:
123
+ result = recalculate_schedule.delay(project_id)
124
+ except Exception:
125
+ # Use logger.exception so the broker error and stack trace are visible.
126
+ # A previous regression (#314) silently swallowed `OperationalError:
127
+ # Connection refused` here for weeks because logger.warning hid the
128
+ # cause — the cascade quietly stopped firing for every dependency edit.
129
+ logger.exception(
130
+ "enqueue_recalculate: could not immediately dispatch for project %s "
131
+ "— drain task will pick it up within 30 s",
132
+ project_id,
133
+ )
134
+ return
135
+
136
+ from django.utils import timezone
137
+
138
+ # Guard the PENDING→DISPATCHED transition with a savepoint: another row for
139
+ # this project may already be DISPATCHED (stranded by a missing worker — the
140
+ # CI integration env, or a Celery outage in prod) and the partial unique
141
+ # index `schedule_request_one_dispatched_per_project` will reject the
142
+ # update. Treat that as the same situation as a broker outage: leave the
143
+ # row PENDING and let `drain_schedule_queue` coalesce on the next tick.
144
+ try:
145
+ with transaction.atomic():
146
+ ScheduleRequest.objects.filter(id=req.id, status=ScheduleRequestStatus.PENDING).update(
147
+ status=ScheduleRequestStatus.DISPATCHED,
148
+ celery_task_id=result.id,
149
+ dispatched_at=timezone.now(),
150
+ )
151
+ except IntegrityError:
152
+ logger.warning(
153
+ "enqueue_recalculate: project %s already has a DISPATCHED outbox row "
154
+ "— leaving new request PENDING for drain_schedule_queue to coalesce",
155
+ project_id,
156
+ )
157
+
158
+
159
+ def redispatch_dead_lettered_task(
160
+ task_name: str,
161
+ args: list[Any] | tuple[Any, ...],
162
+ kwargs: dict[str, Any],
163
+ countdown: int = 0,
164
+ ) -> str:
165
+ """Re-enqueue a dead-lettered Celery task, optionally after a backoff.
166
+
167
+ The engine boundary for the requeue workflow (ADR-0210): the
168
+ ``scheduling.requeue_failed_task`` consumer activity calls this rather than
169
+ importing ``celery`` itself (ADR-0080 §E consumer import discipline). By the
170
+ time execution reaches here the requeue has already round-tripped through the
171
+ durable workflow outbox, so this is the final best-effort hop that puts the
172
+ original task back on the broker.
173
+
174
+ ``countdown`` is the operator-chosen backoff in seconds, applied as a Celery
175
+ ``countdown`` (best-effort, broker-side — ADR-0210 §2). ``0`` means dispatch
176
+ immediately. Returns the Celery ``AsyncResult`` id of the re-dispatched task so
177
+ the workflow can record it in history.
178
+ """
179
+ from celery import current_app
180
+
181
+ async_result = current_app.send_task(
182
+ task_name,
183
+ args=list(args) if args else [],
184
+ kwargs=dict(kwargs) if kwargs else {},
185
+ countdown=countdown or None,
186
+ )
187
+ logger.info(
188
+ "redispatch_dead_lettered_task: re-enqueued %s (countdown=%ss) as %s",
189
+ task_name,
190
+ countdown,
191
+ async_result.id,
192
+ )
193
+ return str(async_result.id)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Monte Carlo run history — ADR-0175 (#961)
198
+ # ---------------------------------------------------------------------------
199
+
200
+
201
+ def record_monte_carlo_run(
202
+ project_id: str | uuid.UUID,
203
+ *,
204
+ p50: date | None,
205
+ p80: date | None,
206
+ p95: date | None,
207
+ n_simulations: int,
208
+ cpm_finish: date | None = None,
209
+ task_count: int | None = None,
210
+ user: Any = None,
211
+ distribution: dict[str, Any] | None = None,
212
+ diagnostic: dict[str, Any] | None = None,
213
+ status_date: date | None = None,
214
+ plan_version: int | None = None,
215
+ ) -> MonteCarloRun | None:
216
+ """Persist one project-level Monte Carlo run for the forecast history (ADR-0175).
217
+
218
+ Called synchronously from ``run_monte_carlo`` after the simulation returns.
219
+ Persistence is **best-effort**: the simulation result is the primary
220
+ deliverable, so a write failure is logged and swallowed (the caller still
221
+ returns the computed result; the history simply misses that row). Returns the
222
+ created ``MonteCarloRun`` or ``None`` on failure.
223
+
224
+ ``user`` is stored as ``triggered_by`` and is serialized only to the resolved
225
+ attribution audience (ADR-0144 / VoC): forecast drift must not become a
226
+ named-individual signal at the team level. An ``AnonymousUser`` (never expected
227
+ here behind IsAuthenticated) is normalized to ``None``.
228
+
229
+ ``distribution`` is the size-bounded ``{histogram_buckets, confidence_curve,
230
+ sensitivity}`` payload (#1231) persisted so the histogram survives cache expiry;
231
+ ``None`` (legacy / no distribution) renders the empty-state prose on read.
232
+
233
+ ``diagnostic`` is the :func:`forecast_diagnostic` payload (#1340) telling a later
234
+ reader *why* the run is flat. It is what keeps a structural zero (no estimates
235
+ anywhere) distinguishable from a measured zero (estimates exist, variance is
236
+ genuinely low) once the run is read back from history rather than the cache —
237
+ see the field docstring on :class:`MonteCarloRun` (#2483).
238
+
239
+ ``status_date`` is the data date the simulation was actually computed against —
240
+ the caller's already-resolved ``project.status_date or timezone.localdate()``
241
+ (ADR-0132) — so a persisted run states which "today" produced its percentiles
242
+ instead of leaving that substitution unrecorded (#2638).
243
+
244
+ ``plan_version`` is the project's ``last_sync_version`` (ADR-0686) as the caller read
245
+ it *before* loading the task set, so a later reader can tell whether the plan has been
246
+ written to since — the server-owned answer to "is this forecast still current?" that
247
+ replaced a session-local counter in the web client (#3140). ``None`` for callers that
248
+ do not supply one; such a run classifies as ``unknown``, never ``current``.
249
+ """
250
+ from trueppm_api.apps.scheduling.models import MonteCarloRun
251
+
252
+ triggered_by = user if getattr(user, "is_authenticated", False) else None
253
+ try:
254
+ return MonteCarloRun.objects.create(
255
+ project_id=project_id,
256
+ p50=p50,
257
+ p80=p80,
258
+ p95=p95,
259
+ cpm_finish=cpm_finish,
260
+ n_simulations=n_simulations,
261
+ task_count=task_count,
262
+ triggered_by=triggered_by,
263
+ distribution=distribution,
264
+ diagnostic=diagnostic,
265
+ status_date=status_date,
266
+ plan_version=plan_version,
267
+ )
268
+ except Exception:
269
+ logger.exception(
270
+ "record_monte_carlo_run: failed to persist run for project %s "
271
+ "— returning computed result without history row",
272
+ project_id,
273
+ )
274
+ return None
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Velocity calibration — ADR-0065
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ def _sprint_working_days(sprint: Sprint) -> int:
283
+ """Count Mon–Fri days in the sprint window, inclusive of both endpoints."""
284
+ from trueppm_api.apps.projects.services import _working_days
285
+
286
+ return _working_days(sprint.start_date, sprint.finish_date)
287
+
288
+
289
+ def compute_team_velocity_per_day(
290
+ project_id: str | uuid.UUID,
291
+ *,
292
+ exclude_sprint_id: str | uuid.UUID | None = None,
293
+ ) -> Decimal | None:
294
+ """Return rolling team_velocity_per_day for the project, or None if undefined.
295
+
296
+ Uses the last ``VELOCITY_ROLLING_WINDOW`` completed sprints excluding the
297
+ optional ``exclude_sprint_id`` (typically the sprint that just closed —
298
+ its velocity is what we're calibrating *toward*, so feeding it back into
299
+ the rolling window would double-weight it on the very first run).
300
+
301
+ Draws from ``velocity_eligible_sprints`` (ADR-0113) so a sprint flagged
302
+ ``exclude_from_velocity`` — a setup/ramp-up "Sprint 0" — never contaminates
303
+ the calibration the CPM duration suggestions are derived from. This is the
304
+ single source of truth for "counts toward velocity"; do not re-filter here.
305
+
306
+ Returns ``None`` when fewer than ``MIN_CLOSED_SPRINTS_FOR_SUGGESTION``
307
+ prior sprints have completed; callers must treat this as "not enough
308
+ history to suggest" rather than "velocity is zero".
309
+ """
310
+ from trueppm_api.apps.projects.services import velocity_eligible_sprints
311
+
312
+ qs = velocity_eligible_sprints(project_id)
313
+ if exclude_sprint_id is not None:
314
+ qs = qs.exclude(pk=exclude_sprint_id)
315
+ closed = list(qs[:VELOCITY_ROLLING_WINDOW])
316
+
317
+ if len(closed) < MIN_CLOSED_SPRINTS_FOR_SUGGESTION:
318
+ return None
319
+
320
+ per_day_samples: list[float] = []
321
+ for s in closed:
322
+ if s.completed_points is None:
323
+ continue
324
+ working_days = _sprint_working_days(s)
325
+ if working_days <= 0:
326
+ continue
327
+ per_day_samples.append(s.completed_points / working_days)
328
+
329
+ if len(per_day_samples) < MIN_CLOSED_SPRINTS_FOR_SUGGESTION:
330
+ return None
331
+ avg = statistics.fmean(per_day_samples)
332
+ if avg <= 0:
333
+ # Zero-velocity team produces an infinite suggested_duration — refuse.
334
+ return None
335
+ # Quantize to three decimal places to match the model's DecimalField scale.
336
+ return Decimal(f"{avg:.3f}")
337
+
338
+
339
+ def compute_velocity_suggestions(sprint_id: str | uuid.UUID) -> int:
340
+ """Generate VelocitySuggestion rows for tasks in a just-closed sprint.
341
+
342
+ Called synchronously from ``projects.tasks.close_sprint`` after the
343
+ SPRINT_CLOSED ``ScheduleRequest`` has been inserted, inside the same
344
+ atomic block so the suggestions commit (or roll back) together with the
345
+ sprint close itself.
346
+
347
+ Behavior:
348
+
349
+ - Requires the sprint to be COMPLETED. A non-completed sprint is treated
350
+ as a no-op (defensive — the drain only calls this after the state
351
+ transition succeeds).
352
+ - Skips when team_velocity_per_day is undefined (fewer than three prior
353
+ completed sprints, or zero/negative average). The PM will simply not
354
+ see a prompt until enough history accumulates.
355
+ - For each non-deleted task in the closing sprint with ``story_points``
356
+ set, computes ``suggested_duration = round(story_points / velocity)``,
357
+ clamped to at least 1 working day.
358
+ - Skips tasks whose current ``most_likely_duration`` already equals the
359
+ suggestion (no point prompting the PM to accept a value already in
360
+ place).
361
+ - Idempotent: a duplicate run upserts the suggestion row via the unique
362
+ constraint on (task, sprint); the PM's accept/dismiss decision on a
363
+ prior row is preserved (we only refresh truly pending rows).
364
+
365
+ Returns the number of suggestion rows touched (created or refreshed).
366
+ """
367
+ from trueppm_api.apps.projects.models import (
368
+ EstimationMode,
369
+ Sprint,
370
+ SprintState,
371
+ Task,
372
+ )
373
+ from trueppm_api.apps.scheduling.models import VelocitySuggestion
374
+
375
+ try:
376
+ sprint = Sprint.objects.select_related("project").get(pk=sprint_id)
377
+ except Sprint.DoesNotExist:
378
+ logger.warning("compute_velocity_suggestions: sprint %s not found", sprint_id)
379
+ return 0
380
+
381
+ if sprint.state != SprintState.COMPLETED:
382
+ return 0
383
+
384
+ velocity = compute_team_velocity_per_day(
385
+ sprint.project_id,
386
+ exclude_sprint_id=sprint.pk,
387
+ )
388
+ if velocity is None:
389
+ logger.info(
390
+ "compute_velocity_suggestions: project %s lacks enough closed sprints "
391
+ "for a stable rolling average — skipping",
392
+ sprint.project_id,
393
+ )
394
+ return 0
395
+
396
+ flag_for_review = sprint.project.estimation_mode == EstimationMode.SUGGEST_APPROVE
397
+
398
+ tasks = list(
399
+ Task.objects.filter(
400
+ sprint_id=sprint.pk,
401
+ is_deleted=False,
402
+ story_points__isnull=False,
403
+ story_points__gt=0,
404
+ )
405
+ )
406
+
407
+ # Pre-load existing suggestions for the (sprint, task ∈ tasks) set in a
408
+ # single query rather than issuing one .filter().first() per task — for a
409
+ # sprint with many tasks the drain is the hot path and N+1 would slow
410
+ # sprint close noticeably.
411
+ existing_by_task = {
412
+ sugg.task_id: sugg
413
+ for sugg in VelocitySuggestion.objects.filter(
414
+ sprint=sprint,
415
+ task_id__in=[t.pk for t in tasks],
416
+ )
417
+ }
418
+
419
+ touched = 0
420
+ for task in tasks:
421
+ # story_points is narrowed by the filter above (isnull=False, gt=0);
422
+ # the explicit cast satisfies mypy without an assert in a hot loop.
423
+ story_points = task.story_points or 0
424
+ suggested = max(1, round(float(story_points) / float(velocity)))
425
+ if task.most_likely_duration == suggested:
426
+ # Already in place — no point creating a suggestion the PM would
427
+ # have to dismiss.
428
+ continue
429
+ # Preserve a prior accept/dismiss decision: only the pending row is
430
+ # refreshed. update_or_create with a where-pending filter expressed as
431
+ # a get-then-update keeps the audit trail correct.
432
+ existing = existing_by_task.get(task.pk)
433
+ if existing is None:
434
+ VelocitySuggestion.objects.create(
435
+ task=task,
436
+ sprint=sprint,
437
+ suggested_duration=suggested,
438
+ team_velocity_per_day=velocity,
439
+ flag_for_review=flag_for_review,
440
+ )
441
+ touched += 1
442
+ continue
443
+ if existing.is_pending:
444
+ VelocitySuggestion.objects.filter(pk=existing.pk).update(
445
+ suggested_duration=suggested,
446
+ team_velocity_per_day=velocity,
447
+ flag_for_review=flag_for_review,
448
+ )
449
+ touched += 1
450
+ if touched:
451
+ logger.info(
452
+ "compute_velocity_suggestions: wrote %d suggestion(s) for sprint %s",
453
+ touched,
454
+ sprint_id,
455
+ )
456
+ return touched
457
+
458
+
459
+ def build_sched_tasks(db_tasks: list[Any], *, suggest_approve: bool) -> list[Any]:
460
+ """Convert Django ``Task`` rows into scheduler ``Task`` dataclasses.
461
+
462
+ Single source of truth for the API → engine task mapping, shared by the
463
+ deterministic CPM pass (``scheduling.tasks``) and the Monte Carlo endpoint
464
+ (``scheduling.views``). Keeping one converter is what stops the CPM and MC
465
+ inputs from drifting — the exact failure mode of #1185, where Monte Carlo
466
+ silently dropped ``planned_start``. Every progress/actual field (ADR-0132) is
467
+ mapped here, so it reaches both engines or neither.
468
+
469
+ Args:
470
+ db_tasks: Django ``Task`` rows, already filtered to the committed set.
471
+ suggest_approve: When True (``project.estimation_mode`` is
472
+ ``SUGGEST_APPROVE``) a task's three-point estimate is withheld unless
473
+ its ``estimate_status`` is ACCEPTED. The scheduler's all-or-none rule
474
+ means a single ``None`` falls back to the deterministic duration.
475
+ (No-op for the deterministic CPM pass, which ignores PERT, but applied
476
+ uniformly so the two paths share one code path.)
477
+ """
478
+ from datetime import timedelta
479
+
480
+ from trueppm_scheduler.models import DeliveryMode as SchedDeliveryMode
481
+ from trueppm_scheduler.models import Task as SchedTask
482
+
483
+ from trueppm_api.apps.projects.models import DeliveryMode, EstimateStatus
484
+
485
+ def _pert(value: int | None, estimate_status: str | None) -> timedelta | None:
486
+ if value is None:
487
+ return None
488
+ if suggest_approve and estimate_status != EstimateStatus.ACCEPTED:
489
+ return None
490
+ return timedelta(days=value)
491
+
492
+ return [
493
+ SchedTask(
494
+ id=str(t.id),
495
+ name=t.name,
496
+ # Milestones are zero-duration gates regardless of any stored duration
497
+ # (MS Project allows non-zero milestone durations); the engine operates
498
+ # on duration only, so normalise at the boundary.
499
+ duration=timedelta(days=0) if t.is_milestone else timedelta(days=t.duration),
500
+ # planned_start is the engine's SNET floor (ADR-0014). An explicit value
501
+ # always wins; otherwise a sprint-assigned, schedulable task inherits its
502
+ # sprint's start_date as a *synthetic* floor (ADR-0168) so agile work
503
+ # positions in its sprint window instead of the project origin. Engine
504
+ # input only — never written back, so the stored row stays null; applying
505
+ # it in this one converter keeps CPM and Monte Carlo in sync (#1185).
506
+ # Milestones are excluded — a sprint review/demo gate sits at the sprint
507
+ # end, not its start (ADR-0106).
508
+ planned_start=(
509
+ t.planned_start
510
+ if t.planned_start is not None
511
+ else (t.sprint.start_date if (t.sprint_id and not t.is_milestone) else None)
512
+ ),
513
+ percent_complete=t.percent_complete,
514
+ actual_start=t.actual_start,
515
+ actual_finish=t.actual_finish,
516
+ optimistic_duration=_pert(t.optimistic_duration, t.estimate_status),
517
+ most_likely_duration=_pert(t.most_likely_duration, t.estimate_status),
518
+ pessimistic_duration=_pert(t.pessimistic_duration, t.estimate_status),
519
+ # Agile-aware Monte Carlo (#411): only SCRUM tasks sample from team
520
+ # velocity, so map just that mode (the scheduler enum has no KANBAN /
521
+ # MILESTONE — those stay None == WATERFALL, deterministic/PERT, exactly
522
+ # as before). story_points is the velocity path's burn-down target. Both
523
+ # are inert for the deterministic CPM pass, which reads neither — the
524
+ # shared converter (ADR-0132) simply carries them for the MC path.
525
+ delivery_mode=(
526
+ SchedDeliveryMode.SCRUM if t.delivery_mode == DeliveryMode.SCRUM else None
527
+ ),
528
+ story_points=(float(t.story_points) if t.story_points is not None else None),
529
+ )
530
+ for t in db_tasks
531
+ ]
532
+
533
+
534
+ def build_children_map(db_tasks: list[Any]) -> dict[str, list[str]]:
535
+ """Map each summary task's id to its direct children's ids via the WBS hierarchy.
536
+
537
+ A task is a *summary* when another task's ``wbs_path`` is a direct child of its
538
+ own (e.g. ``1.2`` is a direct child of ``1``). Tasks are indexed by ``wbs_path``
539
+ in a single pass so each task resolves its parent with one dict lookup — O(N)
540
+ total. The former inline construction scanned ``db_tasks`` for every task to find
541
+ its parent by string equality (O(N^2)), the one superlinear step left in recalc;
542
+ at 5k+ tasks it began to dominate the "Building schedule model" phase (#1011).
543
+
544
+ ``setdefault`` makes the first task in ``db_tasks`` order win a duplicate
545
+ ``wbs_path``, exactly mirroring the prior loop's first-match-and-break semantics,
546
+ and children are appended in ``db_tasks`` order — so the result is byte-for-byte
547
+ identical to the O(N^2) version it replaces.
548
+
549
+ Lives here rather than in ``scheduling.tasks`` (#3527) because summary shaping is
550
+ part of the shared API→engine graph construction, not a detail of the Celery
551
+ recalc: see :func:`build_sched_graph`.
552
+ """
553
+ id_by_wbs_path: dict[str, str] = {}
554
+ for t in db_tasks:
555
+ if t.wbs_path:
556
+ id_by_wbs_path.setdefault(str(t.wbs_path), str(t.id))
557
+
558
+ children_map: dict[str, list[str]] = {}
559
+ for t in db_tasks:
560
+ if not t.wbs_path:
561
+ continue
562
+ parts = str(t.wbs_path).rsplit(".", 1)
563
+ if len(parts) < 2:
564
+ continue
565
+ parent_id = id_by_wbs_path.get(parts[0])
566
+ if parent_id is not None:
567
+ children_map.setdefault(parent_id, []).append(str(t.id))
568
+ return children_map
569
+
570
+
571
+ @dataclass(frozen=True)
572
+ class SchedGraph:
573
+ """The leaf-only scheduler graph for a project, with the summary shape that made it.
574
+
575
+ ``tasks``/``dependencies`` are what goes into ``SchedProject``; ``summary_ids``
576
+ and ``children_map`` are what the deterministic pass needs afterwards to roll the
577
+ summaries back up (ADR-0105), and ``leaf_db_tasks`` is the Django-row subset that
578
+ corresponds to ``tasks`` — for any per-task reporting that must describe the set
579
+ actually simulated rather than the set loaded.
580
+ """
581
+
582
+ tasks: list[Any]
583
+ dependencies: list[Any]
584
+ summary_ids: set[str]
585
+ children_map: dict[str, list[str]]
586
+ leaf_db_tasks: list[Any]
587
+
588
+
589
+ def build_sched_graph(
590
+ db_tasks: list[Any], sched_tasks: list[Any], sched_deps: list[Any]
591
+ ) -> SchedGraph:
592
+ """Strip summary rows out of a scheduler graph and fan their edges down to leaves.
593
+
594
+ Summary (phase) rows are grouping nodes, not schedulable work: ADR-0105 excludes
595
+ them from the CPM pass and derives their dates by rolling up their leaves. This is
596
+ the shaping step that enforces that, and it is shared for the same reason
597
+ :func:`build_sched_tasks` is (#1185): the deterministic CPM pass and every Monte
598
+ Carlo path must schedule *the same network*.
599
+
600
+ Sharing only the converter was not enough (#3527). ``_run_schedule`` did this
601
+ shaping inline while the Monte Carlo endpoint, the what-if endpoint and the
602
+ derivation builder all called ``build_sched_tasks`` and fed the result straight to
603
+ the engine, so every phase entered those simulations as an ordinary task: a large,
604
+ never-started, unconstrained block of work. A summary's stored ``percent_complete``
605
+ is 0 (the 100% the UI shows is the read-time ``percent_complete_rollup``
606
+ annotation, never a stored value) and it carries no dependencies of its own, so
607
+ each phase floored at the data date and the longest one became the simulated
608
+ project finish. The forecast was flat, unrelated to the network, and *worsened* by
609
+ recording real progress — closing a task stretched the phase span the phantom's
610
+ duration was read from. Every project with phases was affected; a flat-WBS project
611
+ was not, which is why no unit suite caught it.
612
+
613
+ Removing summaries from the network also removes the only reader that turned
614
+ #3530 into a wrong forecast: ``_apply_cpm_results`` writes a summary's ``duration``
615
+ as a **calendar-day** span while the engine reads ``duration`` as **working days**,
616
+ which inflated the phantom a further ~1.4x. That field is still wrong for every
617
+ other consumer — #3530 owns it, and this function is not a fix for it.
618
+
619
+ Args:
620
+ db_tasks: the Django ``Task`` rows the graph was built from, in the same order.
621
+ sched_tasks: scheduler tasks from :func:`build_sched_tasks`, summaries included.
622
+ sched_deps: scheduler dependencies, which may reference summary endpoints.
623
+
624
+ Returns:
625
+ A :class:`SchedGraph`. When the project has no summaries the task and
626
+ dependency lists are the inputs unchanged.
627
+ """
628
+ from trueppm_scheduler.engine import (
629
+ MAX_DEPENDENCIES,
630
+ InvalidScheduleInput,
631
+ expand_summary_dependencies,
632
+ )
633
+
634
+ # The O(1) raw-edge cap, enforced HERE rather than left to the engine (#3527).
635
+ # `_validate_project` checks it too, but only once `schedule()`/`monte_carlo()`
636
+ # is called — which is *after* this function has already walked the edge list.
637
+ # `run_monte_carlo`'s synchronous-by-design argument (#1203) rests on the cheap
638
+ # cap running before any per-edge work, and moving expansion onto the request
639
+ # path put the expensive step first. Checking the list length restores that
640
+ # order for every caller, including the Celery pass, where it is simply earlier.
641
+ if len(sched_deps) > MAX_DEPENDENCIES:
642
+ raise InvalidScheduleInput(
643
+ f"Project has {len(sched_deps)} dependencies, exceeding the maximum of "
644
+ f"{MAX_DEPENDENCIES}; the graph cannot be scheduled within resource limits."
645
+ )
646
+
647
+ children_map = build_children_map(db_tasks)
648
+ summary_ids = set(children_map.keys())
649
+ leaf_tasks, expanded_deps = expand_summary_dependencies(sched_tasks, sched_deps, children_map)
650
+ return SchedGraph(
651
+ tasks=leaf_tasks,
652
+ dependencies=expanded_deps,
653
+ summary_ids=summary_ids,
654
+ children_map=children_map,
655
+ leaf_db_tasks=[t for t in db_tasks if str(t.id) not in summary_ids],
656
+ )
657
+
658
+
659
+ def build_sched_calendar(cal: Calendar | None) -> Any:
660
+ """Convert a Django ``Calendar`` (plus its ``CalendarException`` rows) into a
661
+ scheduler ``Calendar`` dataclass.
662
+
663
+ Single source of truth for the API → engine calendar mapping (issue #1491).
664
+ Every call site that feeds a project's calendar to the CPM pass or Monte
665
+ Carlo previously constructed ``trueppm_scheduler.models.Calendar`` inline
666
+ with only ``working_days``/``hours_per_day``/``timezone`` — never
667
+ ``exceptions`` — so a configured holiday or shutdown (``CalendarException``)
668
+ was silently scheduled straight through: early/late dates, float, the
669
+ critical path, and Monte Carlo P50/P80/P95 all ignored it. Routing every
670
+ caller through this one converter is what stops that field from being
671
+ forgotten again at a fourth call site.
672
+
673
+ Args:
674
+ cal: The project's Django ``Calendar``, or ``None`` if the project has
675
+ no calendar assigned (falls back to the Mon-Fri/8h/UTC default).
676
+ When not ``None``, callers should have prefetched ``cal.exceptions``
677
+ (e.g. via ``prefetch_related("calendar__exceptions")``) to avoid an
678
+ N+1 query per project.
679
+
680
+ Returns:
681
+ A ``trueppm_scheduler.models.Calendar`` with ``exceptions`` populated
682
+ from the calendar's ``CalendarException`` rows.
683
+ """
684
+ from trueppm_scheduler.models import Calendar as SchedCalendar
685
+ from trueppm_scheduler.models import DateRange
686
+
687
+ if cal is None:
688
+ return SchedCalendar(working_days=31, hours_per_day=8.0, timezone="UTC")
689
+
690
+ return SchedCalendar(
691
+ working_days=cal.working_days,
692
+ hours_per_day=cal.hours_per_day,
693
+ timezone=cal.timezone,
694
+ exceptions=[
695
+ DateRange(start=exc.exc_start, end=exc.exc_end) for exc in cal.exceptions.all()
696
+ ],
697
+ )
698
+
699
+
700
+ # Reason codes for a flat (deterministic) Monte Carlo forecast, in precedence order.
701
+ # Exposed so the API contract and tests reference one source rather than literals.
702
+ FORECAST_NO_COMMITTED_TASKS = "no_committed_tasks"
703
+ FORECAST_ALL_COMPLETE = "all_complete"
704
+ FORECAST_ESTIMATES_BELOW_PLAN_DURATION = "estimates_below_plan_duration"
705
+ FORECAST_ESTIMATES_OFF_CRITICAL_PATH = "estimates_off_critical_path"
706
+ FORECAST_ESTIMATES_PENDING_APPROVAL = "estimates_pending_approval"
707
+ FORECAST_NO_VELOCITY_HISTORY = "no_velocity_history"
708
+ FORECAST_NO_ESTIMATES = "no_estimates"
709
+
710
+
711
+ def _forecast_task_signal(
712
+ t: Any, *, suggest_approve: bool, has_velocity_signal: bool
713
+ ) -> tuple[bool, bool, bool, bool, bool]:
714
+ """Classify one committed task for :func:`forecast_diagnostic`.
715
+
716
+ Returns ``(is_incomplete, has_variance, is_pending_approval,
717
+ is_agile_without_velocity, is_estimate_below_plan)`` — mirroring the exact
718
+ sampling gate the engine applies (``build_sched_tasks._pert`` plus the velocity
719
+ condition) so the diagnostic explains what the forecast actually did.
720
+ """
721
+ from trueppm_api.apps.projects.models import DeliveryMode, EstimateStatus
722
+
723
+ # Completion mirrors trueppm_scheduler.engine._is_complete (ADR-0136): a
724
+ # finished task is laid out at fixed duration and contributes no variance.
725
+ is_complete = t.actual_finish is not None or (t.percent_complete or 0) >= 100
726
+
727
+ has_triple = (
728
+ t.optimistic_duration is not None
729
+ and t.most_likely_duration is not None
730
+ and t.pessimistic_duration is not None
731
+ )
732
+ # Mirror build_sched_tasks._pert: SUGGEST_APPROVE withholds a triple from the
733
+ # engine until it is ACCEPTED — a withheld triple is "pending", not variance.
734
+ withheld = suggest_approve and t.estimate_status != EstimateStatus.ACCEPTED
735
+ # A real spread needs opt != pess; a degenerate triple samples to a constant.
736
+ pert_spread = has_triple and not withheld and t.optimistic_duration != t.pessimistic_duration
737
+ # ...and, since #3765, sampling room ABOVE the floor. The engine clamps every
738
+ # sampled duration up to ``Task.duration``, so a triple whose pessimistic value
739
+ # sits at or below the planned duration collapses to a constant column no matter
740
+ # how wide its spread is. Counting it as variance made the flat-forecast reason
741
+ # resolve to ``estimates_off_critical_path`` — telling a PM their estimates miss
742
+ # the critical path when the real cause is that they sit under the plan. This
743
+ # function's whole contract is to mirror the sampling gate; the gate moved, so
744
+ # it moves with it.
745
+ below_plan = bool(pert_spread and t.pessimistic_duration <= (t.duration or 0))
746
+ pert_variance = pert_spread and not below_plan
747
+ is_scrum_pointed = t.delivery_mode == DeliveryMode.SCRUM and t.story_points is not None
748
+ velocity_variance = is_scrum_pointed and has_velocity_signal
749
+
750
+ return (
751
+ not is_complete,
752
+ bool(pert_variance or velocity_variance),
753
+ bool(has_triple and withheld),
754
+ bool(is_scrum_pointed and not has_velocity_signal),
755
+ below_plan,
756
+ )
757
+
758
+
759
+ def _forecast_reason(
760
+ *,
761
+ total: int,
762
+ incomplete: int,
763
+ with_variance: int,
764
+ pending_approval: int,
765
+ agile_without_velocity: int,
766
+ estimates_below_plan: int,
767
+ ) -> str:
768
+ """Derive the single most-actionable flat-forecast reason code, in precedence order."""
769
+ if total == 0:
770
+ return FORECAST_NO_COMMITTED_TASKS
771
+ if incomplete == 0:
772
+ return FORECAST_ALL_COMPLETE
773
+ if with_variance > 0:
774
+ # Variance exists but never reaches the project finish: the estimated work
775
+ # sits off the critical path (the sensitivity tornado's own empty case).
776
+ # Surfacing this stops the UI telling a user with estimates to add some.
777
+ return FORECAST_ESTIMATES_OFF_CRITICAL_PATH
778
+ if pending_approval > 0:
779
+ return FORECAST_ESTIMATES_PENDING_APPROVAL
780
+ if agile_without_velocity > 0:
781
+ return FORECAST_NO_VELOCITY_HISTORY
782
+ if estimates_below_plan > 0:
783
+ # Last before the fallback, so it strictly narrows ``no_estimates`` rather
784
+ # than displacing any existing explanation. The task DOES carry a spread —
785
+ # it just sits at or below the planned duration the engine floors at
786
+ # (#3765), so it samples to a constant. "Add estimates" is the one piece of
787
+ # advice guaranteed not to help here, and it is exactly what the fallback
788
+ # would have said.
789
+ return FORECAST_ESTIMATES_BELOW_PLAN_DURATION
790
+ return FORECAST_NO_ESTIMATES
791
+
792
+
793
+ def forecast_diagnostic(
794
+ db_tasks: list[Any],
795
+ *,
796
+ suggest_approve: bool,
797
+ has_velocity_signal: bool,
798
+ deterministic: bool,
799
+ ) -> dict[str, Any]:
800
+ """Explain why a Monte Carlo forecast carries — or lacks — an uncertainty band.
801
+
802
+ A flat forecast (``P50 == P80 == P95``) is *correct* whenever no committed task
803
+ can contribute duration variance, but the bare percentiles give the user no clue
804
+ *why*. The UI historically hard-coded "add PERT estimates", which is wrong when
805
+ the estimates already exist but are withheld (``SUGGEST_APPROVE`` pending
806
+ approval), when the work is agile (its uncertainty comes from velocity history,
807
+ not a three-point range), or when the estimated work simply sits off the critical
808
+ path. This derives the single most-actionable reason from the committed task set,
809
+ mirroring the exact sampling gate the engine applies — ``build_sched_tasks._pert``
810
+ plus the velocity condition in ``trueppm_scheduler.engine.monte_carlo`` — so the
811
+ message the UI shows matches what the forecast actually did.
812
+
813
+ Args:
814
+ db_tasks: the committed ``Task`` rows fed to the simulation (already
815
+ BACKLOG-filtered by ``Task.committed``).
816
+ suggest_approve: whether the project withholds un-accepted estimates
817
+ (``estimation_mode == SUGGEST_APPROVE``) — the same flag passed to
818
+ :func:`build_sched_tasks`.
819
+ has_velocity_signal: whether the project has usable completed-sprint velocity
820
+ (a non-empty ``scheduler_velocity_inputs`` sample set).
821
+ deterministic: whether the simulation collapsed to a single date.
822
+
823
+ Returns:
824
+ A diagnostic dict with ``deterministic``, a ``reason`` code (``None`` when the
825
+ forecast has a real band), and supporting counts (``tasks_total``,
826
+ ``tasks_with_variance``, ``tasks_pending_approval``,
827
+ ``agile_tasks_without_velocity``, ``tasks_estimates_below_plan``).
828
+ ``reason`` is one of the ``FORECAST_*`` codes above.
829
+ """
830
+ total = len(db_tasks)
831
+ incomplete = 0
832
+ with_variance = 0
833
+ pending_approval = 0
834
+ agile_without_velocity = 0
835
+ estimates_below_plan = 0
836
+
837
+ for t in db_tasks:
838
+ t_incomplete, t_variance, t_pending, t_agile, t_below_plan = _forecast_task_signal(
839
+ t, suggest_approve=suggest_approve, has_velocity_signal=has_velocity_signal
840
+ )
841
+ incomplete += t_incomplete
842
+ with_variance += t_variance
843
+ pending_approval += t_pending
844
+ agile_without_velocity += t_agile
845
+ estimates_below_plan += t_below_plan
846
+
847
+ reason: str | None = None
848
+ if deterministic:
849
+ reason = _forecast_reason(
850
+ total=total,
851
+ incomplete=incomplete,
852
+ with_variance=with_variance,
853
+ pending_approval=pending_approval,
854
+ agile_without_velocity=agile_without_velocity,
855
+ estimates_below_plan=estimates_below_plan,
856
+ )
857
+
858
+ return {
859
+ "deterministic": deterministic,
860
+ "reason": reason,
861
+ "tasks_total": total,
862
+ "tasks_with_variance": with_variance,
863
+ "tasks_pending_approval": pending_approval,
864
+ "agile_tasks_without_velocity": agile_without_velocity,
865
+ "tasks_estimates_below_plan": estimates_below_plan,
866
+ }
867
+
868
+
869
+ def _rollup_one_summary(
870
+ sid: str, leaf_results: list[Any], db_task_by_id: dict[str, Any]
871
+ ) -> Any | None:
872
+ """Derive one summary task's synthetic CPM row from its leaf results (ADR-0105).
873
+
874
+ Returns ``None`` when no leaf carries both an early start and an early finish:
875
+ there is nothing to roll up, and a half-populated row would render a bar with
876
+ no span rather than no bar at all.
877
+ """
878
+ from datetime import timedelta
879
+
880
+ from trueppm_scheduler.models import Task as SchedTask
881
+
882
+ es_dates = [t.early_start for t in leaf_results if t.early_start is not None]
883
+ ef_dates = [t.early_finish for t in leaf_results if t.early_finish is not None]
884
+ if not es_dates or not ef_dates:
885
+ return None
886
+
887
+ ls_dates = [t.late_start for t in leaf_results if t.late_start is not None]
888
+ lf_dates = [t.late_finish for t in leaf_results if t.late_finish is not None]
889
+ # ADR-0752: a summary's span start is the earliest of its leaves' span
890
+ # starts — the same min-aggregation as early_start, so a leaf whose work
891
+ # actually began before its own remaining-work window (actual_start <
892
+ # early_start) still pulls the rolled-up summary bar's span left. Falls
893
+ # back to early_start when no leaf carries one (pre-#2622 synced rows).
894
+ ss_dates = [t.scheduled_start for t in leaf_results if t.scheduled_start is not None]
895
+ floats = [t.total_float for t in leaf_results if t.total_float is not None]
896
+
897
+ summary_sched = SchedTask(
898
+ id=sid,
899
+ name=db_task_by_id[sid].name if sid in db_task_by_id else sid,
900
+ duration=timedelta(days=0),
901
+ )
902
+ summary_sched.early_start = min(es_dates)
903
+ summary_sched.early_finish = max(ef_dates)
904
+ summary_sched.late_start = min(ls_dates) if ls_dates else summary_sched.early_start
905
+ summary_sched.late_finish = max(lf_dates) if lf_dates else summary_sched.early_finish
906
+ summary_sched.scheduled_start = min(ss_dates) if ss_dates else summary_sched.early_start
907
+ summary_sched.total_float = min(floats) if floats else timedelta(days=0)
908
+ summary_sched.free_float = timedelta(days=0)
909
+ summary_sched.is_critical = any(t.is_critical for t in leaf_results)
910
+ return summary_sched
911
+
912
+
913
+ def apply_summary_rollups(
914
+ result_map: dict[str, Any],
915
+ summary_ids: set[str],
916
+ children_map: dict[str, list[str]],
917
+ db_task_by_id: dict[str, Any],
918
+ ) -> None:
919
+ """Add synthetic CPM rows for summary tasks to ``result_map`` in place (ADR-0105).
920
+
921
+ Summary tasks are excluded from the leaf CPM pass (they are grouping nodes, not
922
+ schedulable work), so their dates are *derived* from their leaf descendants:
923
+ ``early_start = min(leaf early_start)``, ``early_finish = max(leaf early_finish)``,
924
+ tightest float, critical if any leaf is critical. Shared by the single-project
925
+ write-back (``scheduling.tasks._run_schedule``) and the program-scoped write-back
926
+ (``recalculate_program_schedule``) so the two never drift (the #1185 class). The
927
+ merged ``children_map`` keys are globally-unique task ids, so this works
928
+ unchanged across the per-project and program graphs.
929
+ """
930
+ from trueppm_scheduler.engine import _collect_leaves
931
+
932
+ for sid in summary_ids:
933
+ leaves = _collect_leaves(sid, children_map)
934
+ leaf_results = [result_map[lid] for lid in leaves if lid in result_map]
935
+ if not leaf_results:
936
+ continue
937
+ rolled = _rollup_one_summary(sid, leaf_results, db_task_by_id)
938
+ if rolled is not None:
939
+ result_map[sid] = rolled
940
+
941
+
942
+ # Capture-path dedup window (ADR-0154 §3): a recompute that produces an
943
+ # unchanged forecast within this window of the previous snapshot is a no-op, so
944
+ # a project recomputed many times during a heavy edit session does not write a
945
+ # row per recompute. This window also makes a duplicate recompute (broker retry,
946
+ # manual re-queue) idempotent.
947
+ FORECAST_DEDUP_WINDOW_SECONDS = 3600
948
+
949
+ # The fields compared for the dedup no-op decision — the forecast itself plus the
950
+ # schedule-shape context. captured_at/triggered_by are intentionally excluded.
951
+ _FORECAST_DEDUP_FIELDS = (
952
+ "cpm_finish",
953
+ "total_float_days",
954
+ "mc_p50_finish",
955
+ "mc_p80_finish",
956
+ "mc_p95_finish",
957
+ "mc_iterations",
958
+ "task_count",
959
+ "completed_task_count",
960
+ )
961
+
962
+
963
+ def capture_forecast_snapshot(project_id: str | uuid.UUID, trigger: str) -> Any | None:
964
+ """Capture a project-grain ``ProjectForecastSnapshot`` (ADR-0154, #388).
965
+
966
+ Derives every field from already-committed state — the just-recomputed
967
+ ``Task`` rows (CPM spine) plus the project's most-recent ``MonteCarloRun``
968
+ (probabilistic band, best-effort, may be absent or stale). Idempotent within
969
+ ``FORECAST_DEDUP_WINDOW_SECONDS``: if the latest snapshot is newer than the
970
+ window and every forecast field is unchanged, this no-ops and returns ``None``.
971
+
972
+ The aggregate population is ``Task.committed`` — the same set every
973
+ ``SchedProject`` construction site feeds to CPM. See the comment on the
974
+ aggregate below for why using ``Task.objects`` here was a correctness bug
975
+ (#3539) rather than a scoping preference.
976
+
977
+ Returns the created row, or ``None`` when the capture was deduped.
978
+ """
979
+ from datetime import timedelta
980
+
981
+ from django.db.models import Count, Max, Min, Q
982
+ from django.utils import timezone
983
+
984
+ from trueppm_api.apps.projects.models import Task, TaskStatus
985
+ from trueppm_api.apps.scheduling.models import MonteCarloRun, ProjectForecastSnapshot
986
+
987
+ # One aggregate query for the whole-project schedule shape. cpm_finish is the
988
+ # latest task finish; total_float_days is the tightest slack across the project
989
+ # (negative = a constraint is breached).
990
+ #
991
+ # The population is Task.committed, NOT Task.objects (#3539). A snapshot must
992
+ # describe the population CPM actually ran on, and every SchedProject
993
+ # construction site schedules Task.committed — BACKLOG rows, EPIC grouping
994
+ # nodes and recurring occurrences are never fed to the engine. Two things go
995
+ # wrong when they are admitted to the aggregate:
996
+ #
997
+ # 1. A task groomed back to BACKLOG keeps whatever early_finish/total_float
998
+ # it carried when it was last scheduled — _apply_cpm_results only
999
+ # bulk_updates the rows it scheduled, so nothing ever clears them
1000
+ # (clearing them is a separate decision, tracked in #3578).
1001
+ # That stale value then wins the Max/Min and reports a finish date, or a
1002
+ # slack figure, belonging to work the schedule does not contain. Measured
1003
+ # on dev: a 6-day false project finish, and a project with no float at
1004
+ # all reporting 30 days of it from seven groomed-out stories.
1005
+ # 2. cpm_finish feeds notify_project_end_date_shift, which emails the
1006
+ # PM/Owner cohort on a material move. A stale BACKLOG row entering or
1007
+ # leaving the Max moves cpm_finish with no change to any committed work,
1008
+ # so the wrong population does not merely mis-report — it emails owners
1009
+ # that their end date shifted when it did not, and symmetrically hides a
1010
+ # real shift inside that noise.
1011
+ #
1012
+ # The counts are aggregated over the same population on purpose: they are
1013
+ # recorded as "schedule shape at capture time — context for interpreting the
1014
+ # forecast", and a task_count drawn from a wider set than cpm_finish makes the
1015
+ # snapshot internally incoherent (a trend chart annotating a committed-set
1016
+ # finish with a whole-board task count).
1017
+ agg = Task.committed.filter(project_id=project_id).aggregate(
1018
+ cpm_finish=Max("early_finish"),
1019
+ total_float_days=Min("total_float"),
1020
+ task_count=Count("id"),
1021
+ completed=Count("id", filter=Q(status=TaskStatus.COMPLETE)),
1022
+ )
1023
+
1024
+ latest_mc = (
1025
+ MonteCarloRun.objects.filter(project_id=project_id)
1026
+ .order_by("-taken_at")
1027
+ .values("p50", "p80", "p95", "n_simulations")
1028
+ .first()
1029
+ )
1030
+
1031
+ fields = {
1032
+ "cpm_finish": agg["cpm_finish"],
1033
+ "total_float_days": agg["total_float_days"],
1034
+ "mc_p50_finish": latest_mc["p50"] if latest_mc else None,
1035
+ "mc_p80_finish": latest_mc["p80"] if latest_mc else None,
1036
+ "mc_p95_finish": latest_mc["p95"] if latest_mc else None,
1037
+ "mc_iterations": latest_mc["n_simulations"] if latest_mc else None,
1038
+ "task_count": agg["task_count"] or 0,
1039
+ "completed_task_count": agg["completed"] or 0,
1040
+ }
1041
+
1042
+ # Capture-path dedup: no-op if the latest snapshot is within the window AND
1043
+ # every tracked field matches. The latest-row read is the idempotency guard.
1044
+ latest = (
1045
+ ProjectForecastSnapshot.objects.filter(project_id=project_id)
1046
+ .order_by("-captured_at")
1047
+ .first()
1048
+ )
1049
+ if latest is not None:
1050
+ within_window = (timezone.now() - latest.captured_at) < timedelta(
1051
+ seconds=FORECAST_DEDUP_WINDOW_SECONDS
1052
+ )
1053
+ unchanged = all(getattr(latest, name) == fields[name] for name in _FORECAST_DEDUP_FIELDS)
1054
+ if within_window and unchanged:
1055
+ return None
1056
+
1057
+ return ProjectForecastSnapshot.objects.create(
1058
+ project_id=project_id, triggered_by=trigger, **fields
1059
+ )
1060
+
1061
+
1062
+ def notify_project_end_date_shift(snapshot: Any) -> None:
1063
+ """Notify the project's PM/Owner cohort when the CPM finish moves by more
1064
+ than the project's configurable threshold (#1911, the third #82 schedule-
1065
+ notification rule — companion to #1668's dependency-slip / became-critical
1066
+ pair, and the ``ProjectForecastSnapshot`` analog of
1067
+ ``projects.services.notify_milestone_forecast_shift`` (#861)).
1068
+
1069
+ Compares ``snapshot`` (the just-captured row) against the immediately-prior
1070
+ ``ProjectForecastSnapshot`` for the same project. Material shift = the
1071
+ absolute day delta between the two ``cpm_finish`` values is strictly
1072
+ greater than ``Project.end_date_shift_threshold_days``. A project with no
1073
+ prior snapshot (or no cpm_finish on either side) has nothing to compare
1074
+ against and is skipped — the first-ever finish is not a "shift".
1075
+
1076
+ Debounce (issue AC) is structural, not a separate guard here: this is
1077
+ called once per *newly created* snapshot, and ``capture_forecast_snapshot``
1078
+ already no-ops (creates no row) when the forecast is unchanged within
1079
+ ``FORECAST_DEDUP_WINDOW_SECONDS`` — so a recompute that doesn't move the
1080
+ finish produces no new snapshot and therefore no repeat notification for
1081
+ the same shift.
1082
+
1083
+ Role targeting mirrors ``notify_milestone_forecast_shift``: PM/Owner cohort
1084
+ is ``role >= Role.ADMIN``, ``is_deleted=False``. The ``is_deleted=False``
1085
+ filter is load-bearing for privacy — member removal is a soft delete that
1086
+ leaves the row (with its role) intact, so without it a revoked PM would
1087
+ keep receiving end-date-shift digests for a project they no longer belong
1088
+ to (rbac-check).
1089
+ """
1090
+ from trueppm_api.apps.access.models import ProjectMembership, Role
1091
+ from trueppm_api.apps.notifications.models import NotificationEventType
1092
+ from trueppm_api.apps.notifications.services import create_event_notifications
1093
+ from trueppm_api.apps.projects.models import Project
1094
+ from trueppm_api.apps.scheduling.models import ProjectForecastSnapshot
1095
+
1096
+ if snapshot.cpm_finish is None:
1097
+ return
1098
+
1099
+ prior = (
1100
+ ProjectForecastSnapshot.objects.filter(project_id=snapshot.project_id)
1101
+ .exclude(pk=snapshot.pk)
1102
+ .order_by("-captured_at")
1103
+ .first()
1104
+ )
1105
+ if prior is None or prior.cpm_finish is None:
1106
+ return # no prior finish to compare against — not a "shift"
1107
+
1108
+ delta_days = abs((snapshot.cpm_finish - prior.cpm_finish).days)
1109
+
1110
+ project_row = (
1111
+ Project.objects.filter(pk=snapshot.project_id)
1112
+ .values("name", "end_date_shift_threshold_days")
1113
+ .first()
1114
+ )
1115
+ if project_row is None or delta_days <= project_row["end_date_shift_threshold_days"]:
1116
+ return
1117
+
1118
+ # rbac-check: recipients are computed server-side from role, never taken from
1119
+ # request input or filtered client-side — a plain member can never end up in
1120
+ # this list regardless of what the caller passes.
1121
+ recipient_ids = list(
1122
+ ProjectMembership.objects.filter(
1123
+ project_id=snapshot.project_id, role__gte=Role.ADMIN, is_deleted=False
1124
+ ).values_list("user_id", flat=True)
1125
+ )
1126
+ if not recipient_ids:
1127
+ return
1128
+
1129
+ direction = "pushed out" if snapshot.cpm_finish > prior.cpm_finish else "pulled in"
1130
+ day_word = "day" if delta_days == 1 else "days"
1131
+ subject = f"Project end date shifted — {project_row['name']}"
1132
+ body = (
1133
+ f"The project finish {direction} by {delta_days} {day_word}: "
1134
+ f"{prior.cpm_finish.isoformat()} → {snapshot.cpm_finish.isoformat()}."
1135
+ )
1136
+
1137
+ project_id_str = str(snapshot.project_id)
1138
+ # Deferred like every other event-notification write in this codebase
1139
+ # (notify_milestone_forecast_shift, notify_carryover_assignees): on_commit
1140
+ # is safe to call even with no active transaction (runs immediately), and
1141
+ # protects a future caller that invokes this from inside an open one.
1142
+ transaction.on_commit(
1143
+ lambda: create_event_notifications(
1144
+ event_type=NotificationEventType.PROJECT_END_DATE_SHIFTED,
1145
+ recipient_ids=recipient_ids,
1146
+ subject=subject,
1147
+ body=body,
1148
+ project_id=project_id_str,
1149
+ )
1150
+ )
1151
+
1152
+
1153
+ def safe_capture_forecast_snapshot(project_id: str | uuid.UUID, trigger: str) -> None:
1154
+ """Best-effort wrapper around :func:`capture_forecast_snapshot` (ADR-0154 §3).
1155
+
1156
+ Used by the recompute ``on_commit`` hook: a capture failure must never roll
1157
+ back or block the CPM write (we are strictly post-commit), and the data is
1158
+ fully reconstructable, so any exception is logged and discarded — the daily
1159
+ floor task backfills the miss.
1160
+
1161
+ Also fires the project end-date shift notification (#1911) whenever a new
1162
+ snapshot is actually captured. Wiring it here — rather than at each of the
1163
+ three call sites (recompute, program recompute, daily floor) — is the
1164
+ single hook point that covers every trigger uniformly. Wrapped in its own
1165
+ try/except so a notification bug can never surface as (or mask) a snapshot
1166
+ capture failure.
1167
+ """
1168
+ try:
1169
+ snapshot = capture_forecast_snapshot(project_id, trigger)
1170
+ except Exception:
1171
+ logger.warning("forecast snapshot capture failed for project %s", project_id, exc_info=True)
1172
+ return
1173
+
1174
+ if snapshot is not None:
1175
+ try:
1176
+ notify_project_end_date_shift(snapshot)
1177
+ except Exception:
1178
+ logger.warning(
1179
+ "project end-date shift notification failed for project %s",
1180
+ project_id,
1181
+ exc_info=True,
1182
+ )