trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1695 @@
1
+ """Replay a v2 seed event timeline with backdated history (ADR-0114).
2
+
3
+ The v1 importer materializes final state: one ORM write per row, every
4
+ ``django-simple-history`` row dated ``now()`` by the importer. A v2 seed instead
5
+ authors the program's *life* — an ordered ``events`` timeline — and this module
6
+ replays it so the demo reads like a program that has run for months:
7
+
8
+ - Tasks are created at their base column and walked forward through real status
9
+ transitions; each transition writes a history row dated to the event, attributed
10
+ to a named persona, so a COMPLETE task's History tab shows dated moves by people.
11
+ - A deterministic synthesizer fills the unauthored "boring middle": any task whose
12
+ final state implies it passed through earlier columns gets synthetic transitions
13
+ (it always traverses IN_PROGRESS, never NOT_STARTED→COMPLETE, so burndowns are
14
+ not hollow — VoC/Alex). Authored events always win over synthesis.
15
+ - A day-by-day sim clock writes one ``SprintBurnSnapshot`` per active-sprint day,
16
+ so burndown curves and multi-sprint velocity are real history, not a single
17
+ fabricated number.
18
+
19
+ Replay runs inside the importer's existing ``transaction.atomic()`` and under the
20
+ ``seed_replay`` context flag, so the live side effects a real edit would trigger —
21
+ today-dated burndown, board broadcasts, notifications, webhooks — are suppressed.
22
+ A single CPM recalc per project is enqueued by the importer after commit.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import functools
28
+ import logging
29
+ import random
30
+ from collections.abc import Callable, Sequence
31
+ from dataclasses import dataclass, field
32
+ from datetime import date, datetime, timedelta
33
+ from typing import TYPE_CHECKING, Any
34
+ from zoneinfo import ZoneInfo
35
+
36
+ from django.db.models import F, Q
37
+ from django.utils import timezone
38
+
39
+ from trueppm_api.apps.projects.actual_date_rules import check_actual_dates
40
+ from trueppm_api.apps.projects.models import (
41
+ AcceptanceCriterion,
42
+ Baseline,
43
+ BaselineTask,
44
+ CommentAcknowledgement,
45
+ CommentReaction,
46
+ EstimateStatus,
47
+ RetroActionItem,
48
+ Risk,
49
+ RiskComment,
50
+ ScopeChangeStatus,
51
+ Sprint,
52
+ SprintRetro,
53
+ SprintScopeChange,
54
+ SprintState,
55
+ Task,
56
+ TaskComment,
57
+ TaskNote,
58
+ TaskStatus,
59
+ )
60
+ from trueppm_api.apps.projects.seed.reldates import WorkingCalendar, resolve_timestamp
61
+ from trueppm_api.apps.projects.seed.replay_ctx import seed_replay
62
+ from trueppm_api.apps.projects.services import upsert_burndown_for_sprint
63
+ from trueppm_api.apps.timetracking.models import TimeEntry
64
+
65
+ if TYPE_CHECKING:
66
+ from trueppm_api.apps.notifications.services import ParsedMention
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+ # Seed-script beat action key for board-column moves.
71
+ _TASK_STATUS_ACTION = "task.status"
72
+
73
+ # Forward progression of the board. The synthesizer walks a task from its base
74
+ # column up to its authored final column through this sequence, so every
75
+ # in-flight/done task passes through IN_PROGRESS (a hollow burndown is the bug
76
+ # Alex called out in VoC). BACKLOG/ON_HOLD are off the progression spine.
77
+ _PROGRESSION = [
78
+ TaskStatus.NOT_STARTED,
79
+ TaskStatus.IN_PROGRESS,
80
+ TaskStatus.REVIEW,
81
+ TaskStatus.COMPLETE,
82
+ ]
83
+ _PROGRESSION_INDEX: dict[str, int] = {str(s): i for i, s in enumerate(_PROGRESSION)}
84
+
85
+ # Upper bound on the day-by-day snapshot window (~10 years). The schema already
86
+ # caps a single date offset at 4 digits; this bounds the *span* between the
87
+ # earliest and latest beat so no crafted timeline drives an oversized loop.
88
+ MAX_REPLAY_SPAN_DAYS = 3660
89
+
90
+
91
+ @dataclass
92
+ class ReplayContext:
93
+ """Symbol tables + resolved settings the importer hands to the replay pass."""
94
+
95
+ anchor: date
96
+ program_code: str
97
+ default_actor: Any # the importing owner; fallback when an event names no actor
98
+ users: dict[str, Any] # account slug -> User (or None on the generic path)
99
+ tasks: dict[tuple[str, str], Task] # (project_slug, wbs_path) -> Task
100
+ sprints: dict[tuple[str, str], Sprint] # (project_slug, sprint_slug) -> Sprint
101
+ projects: dict[str, Any] # project_slug -> Project
102
+ project_calendars: dict[str, WorkingCalendar] # project_slug -> calendar facts
103
+ risks: dict[str, Risk] # risk slug -> Risk
104
+ final_status: dict[tuple[str, str], str] # desired end column per task
105
+ final_sprint: dict[tuple[str, str], dict[str, Any]] # desired end state per sprint
106
+ # Authored in-flight progress per task, restored after the timeline runs
107
+ # (#3486). Only carries the keys the document actually wrote, so a dated
108
+ # ``task.points`` beat is never overwritten by a value nobody authored.
109
+ final_progress: dict[tuple[str, str], dict[str, Any]] = field(default_factory=dict)
110
+ tz: Any = None # program timezone for synthesized timestamps (defaults to UTC)
111
+ # Scope-change rows opened during replay, so a later resolve can close them.
112
+ open_scope: dict[Any, SprintScopeChange] = field(default_factory=dict)
113
+ # v2.1 (#3603): acceptance criteria per task in position order, so a
114
+ # ``task.ac_met`` beat can tick one by index; and comments by the slug their
115
+ # ``task.comment`` beat declared, so a reply/reaction/ack can find them.
116
+ criteria: dict[tuple[str, str], list[AcceptanceCriterion]] = field(default_factory=dict)
117
+ comments: dict[str, TaskComment] = field(default_factory=dict)
118
+
119
+
120
+ @dataclass(order=True)
121
+ class _Beat:
122
+ """One resolved timeline beat, sortable by (when, order)."""
123
+
124
+ when: datetime
125
+ order: int
126
+ action: str = field(compare=False)
127
+ target: str = field(compare=False)
128
+ actor: Any = field(compare=False, default=None)
129
+ data: dict[str, Any] = field(compare=False, default_factory=dict)
130
+ synthetic: bool = field(compare=False, default=False)
131
+
132
+
133
+ def replay_timeline(payload: dict[str, Any], ctx: ReplayContext) -> None:
134
+ """Replay the document's events (+ synthesized fill) against ``ctx``.
135
+
136
+ Idempotent within an import: the caller wipes-and-recreates the program, so
137
+ the timeline is always replayed from a clean base. Determinism comes from a
138
+ seed derived from ``program_code`` + the task wbs path.
139
+
140
+ Also seeds every persona's notification preferences, then — beside the
141
+ per-beat notifications individual handlers below emit — synthesizes the two
142
+ event types with no single triggering beat: a ``task.stale`` sweep and the
143
+ sponsor persona's ``program.health_digest`` (#3489).
144
+ """
145
+ with seed_replay():
146
+ _seed_notification_preferences(payload, ctx)
147
+ beats = _resolve_authored(payload, ctx)
148
+ beats.extend(_synthesize(ctx, beats))
149
+ beats.sort()
150
+ _run_sim_clock(beats, ctx)
151
+ _synthesize_stale_notifications(ctx)
152
+ _synthesize_program_digest(payload, ctx)
153
+ _apply_notification_realism(ctx)
154
+
155
+
156
+ # --- resolution --------------------------------------------------------------
157
+
158
+
159
+ def _resolve_authored(payload: dict[str, Any], ctx: ReplayContext) -> list[_Beat]:
160
+ """Turn authored event dicts into sortable beats with concrete datetimes."""
161
+ out: list[_Beat] = []
162
+ for i, ev in enumerate(payload.get("events", [])):
163
+ when = resolve_timestamp(ev["at"], anchor=ctx.anchor)
164
+ actor = ctx.users.get(ev["actor"]) if ev.get("actor") else None
165
+ out.append(
166
+ _Beat(
167
+ when=when,
168
+ order=i,
169
+ action=ev["action"],
170
+ target=ev.get("target", ""),
171
+ actor=actor or ctx.default_actor,
172
+ data=ev,
173
+ )
174
+ )
175
+ return out
176
+
177
+
178
+ def _resolve_task(ctx: ReplayContext, target: str) -> Task | None:
179
+ """Resolve a ``task:<project>:<wbs>`` target to a Task in the symbol table."""
180
+ _, _, ref = target.partition(":")
181
+ project_slug, _, wbs = ref.partition(":")
182
+ return ctx.tasks.get((project_slug, wbs))
183
+
184
+
185
+ def _resolve_sprint(ctx: ReplayContext, target: str) -> Sprint | None:
186
+ _, _, ref = target.partition(":")
187
+ project_slug, _, sprint_slug = ref.partition(":")
188
+ return ctx.sprints.get((project_slug, sprint_slug))
189
+
190
+
191
+ def _task_window(ctx: ReplayContext, key: tuple[str, str], task: Task) -> tuple[date, date]:
192
+ """The [start, end] working window a synthesized task progresses across.
193
+
194
+ Agile tasks ride their sprint window; scheduled tasks ride
195
+ planned_start..+duration; everything else rides the project start..anchor.
196
+ The end is clamped to the anchor — a demo never has work completing in the
197
+ future relative to "today".
198
+ """
199
+ project_slug = key[0]
200
+ project = ctx.projects[project_slug]
201
+ sprint = task.sprint
202
+ if sprint is not None:
203
+ start, end = sprint.start_date, sprint.finish_date
204
+ elif task.planned_start is not None:
205
+ start = task.planned_start
206
+ end = start + timedelta(days=max(task.duration, 1))
207
+ else:
208
+ start = project.start_date
209
+ end = ctx.anchor
210
+ # Clamp both ends to the anchor: a completed task whose nominal window sits
211
+ # in the future (relative to import day) still gets past-dated history, never
212
+ # future-dated — the demo's "today" is the anchor.
213
+ start = min(start, ctx.anchor)
214
+ end = min(end, ctx.anchor)
215
+ if end < start:
216
+ end = start
217
+ return start, end
218
+
219
+
220
+ def _synthesize(ctx: ReplayContext, authored: list[_Beat]) -> list[_Beat]:
221
+ """Fill the unauthored timeline: sprint ceremonies + task progressions."""
222
+ return _synthesize_sprints(ctx, authored) + _synthesize_tasks(ctx, authored)
223
+
224
+
225
+ def _synthesize_sprints(ctx: ReplayContext, authored: list[_Beat]) -> list[_Beat]:
226
+ """Synthesize activate/close beats for sprints the timeline left implicit.
227
+
228
+ A sample that merely declares a sprint's end state + points (the v1 shape)
229
+ still gets a real run: activate at start, close at finish, so the sim clock
230
+ writes a burndown curve and a velocity number that is actual history.
231
+ """
232
+ authored_targets = {b.target for b in authored if b.action.startswith("sprint.")}
233
+ out: list[_Beat] = []
234
+ for key, sprint in ctx.sprints.items():
235
+ target = f"sprint:{key[0]}:{key[1]}"
236
+ if target in authored_targets:
237
+ continue
238
+ final_state = ctx.final_sprint.get(key, {}).get("state", SprintState.PLANNED)
239
+ if final_state not in (SprintState.ACTIVE, SprintState.COMPLETED):
240
+ continue
241
+ out.append(
242
+ _Beat(
243
+ when=_aware_dt(ctx, sprint.start_date, 9, 0),
244
+ order=0,
245
+ action="sprint.activate",
246
+ target=target,
247
+ actor=ctx.default_actor,
248
+ data={},
249
+ synthetic=True,
250
+ )
251
+ )
252
+ if final_state == SprintState.COMPLETED:
253
+ out.append(
254
+ _Beat(
255
+ when=_aware_dt(ctx, sprint.finish_date, 17, 0),
256
+ order=2_000_000, # close sorts after that day's task moves
257
+ action="sprint.close",
258
+ target=target,
259
+ actor=ctx.default_actor,
260
+ data={"goal_outcome": ctx.final_sprint.get(key, {}).get("goal_outcome")},
261
+ synthetic=True,
262
+ )
263
+ )
264
+ return out
265
+
266
+
267
+ def _synthesize_tasks(ctx: ReplayContext, authored: list[_Beat]) -> list[_Beat]:
268
+ """Generate status transitions for tasks the authored timeline left implicit.
269
+
270
+ For each task whose final column is past NOT_STARTED and which has no authored
271
+ ``task.status`` beat, walk it forward through every intermediate column on
272
+ evenly-spaced, seeded dates within its window. Deterministic: a fixed seed per
273
+ (program, wbs) means re-import and round-trip reproduce the same history.
274
+ """
275
+ authored_status_targets = {b.target for b in authored if b.action == _TASK_STATUS_ACTION}
276
+ out: list[_Beat] = []
277
+ for key, task in ctx.tasks.items():
278
+ project_slug, wbs = key
279
+ target = f"task:{project_slug}:{wbs}"
280
+ if target in authored_status_targets:
281
+ continue # author owns this task's progression
282
+ final = ctx.final_status.get(key, TaskStatus.NOT_STARTED)
283
+ final_idx = _PROGRESSION_INDEX.get(final)
284
+ if final_idx is None or final_idx == 0:
285
+ continue # BACKLOG / ON_HOLD / still NOT_STARTED — nothing to walk
286
+ start, end = _task_window(ctx, key, task)
287
+ steps = _PROGRESSION[1 : final_idx + 1] # columns to move into, in order
288
+ rng = random.Random(f"{ctx.program_code}:{wbs}")
289
+ actor = task.assignee or ctx.default_actor
290
+ span = (end - start).days or 1
291
+ # Place each transition at a stable fraction of the window, jittered.
292
+ for n, status in enumerate(steps, start=1):
293
+ frac = n / (len(steps) + 1)
294
+ jitter = rng.uniform(-0.5, 0.5) / (len(steps) + 1)
295
+ day_offset = round(min(max(frac + jitter, 0.02), 0.98) * span)
296
+ when = _aware_dt(
297
+ ctx, start + timedelta(days=day_offset), rng.randint(9, 16), rng.randint(0, 59)
298
+ )
299
+ out.append(
300
+ _Beat(
301
+ when=when,
302
+ order=1_000_000 + n, # synthetic beats sort after same-instant authored
303
+ action=_TASK_STATUS_ACTION,
304
+ target=target,
305
+ actor=actor,
306
+ data={"to": status},
307
+ synthetic=True,
308
+ )
309
+ )
310
+ return out
311
+
312
+
313
+ # --- sim clock ---------------------------------------------------------------
314
+
315
+
316
+ def _run_sim_clock(beats: list[_Beat], ctx: ReplayContext) -> None:
317
+ """Apply beats in time order; snapshot active-sprint burndown each day.
318
+
319
+ Iterating every calendar day (not only event days) means an active sprint
320
+ gets a snapshot on quiet days too, so the burndown curve has no gaps.
321
+ """
322
+ if not beats:
323
+ # No timeline (e.g. a v2 doc with only relative dates) — still finalize
324
+ # sprint states and in-flight progress so the seed's intended end state
325
+ # holds.
326
+ _finalize(ctx, ctx.anchor)
327
+ return
328
+
329
+ by_day: dict[date, list[_Beat]] = {}
330
+ for b in beats:
331
+ by_day.setdefault(b.when.date(), []).append(b)
332
+
333
+ first = min(by_day)
334
+ last = max(by_day)
335
+ # Bound the per-day snapshot loop. The schema caps any single offset at ~27
336
+ # years, but a seed could still author a multi-decade span; clamp the
337
+ # snapshotted window so a crafted timeline can't drive an oversized loop.
338
+ # Beats before the window are still applied (so task states entering the
339
+ # window are correct) — only their day-by-day snapshots are skipped.
340
+ window_start = first
341
+ if (last - first).days > MAX_REPLAY_SPAN_DAYS:
342
+ window_start = last - timedelta(days=MAX_REPLAY_SPAN_DAYS)
343
+ logger.warning(
344
+ "seed replay: span %sd exceeds %sd cap; snapshotting recent window only",
345
+ (last - first).days,
346
+ MAX_REPLAY_SPAN_DAYS,
347
+ )
348
+ for d in sorted(by_day):
349
+ if d >= window_start:
350
+ break
351
+ for beat in by_day[d]:
352
+ _apply(beat, ctx)
353
+
354
+ day = window_start
355
+ while day <= last:
356
+ for beat in by_day.get(day, []):
357
+ _apply(beat, ctx)
358
+ _snapshot_active_sprints(ctx, day)
359
+ day += timedelta(days=1)
360
+
361
+ _finalize(ctx, last)
362
+
363
+
364
+ def _snapshot_active_sprints(ctx: ReplayContext, day: date) -> None:
365
+ for sprint in ctx.sprints.values():
366
+ if sprint.state == SprintState.ACTIVE and sprint.start_date <= day <= sprint.finish_date:
367
+ try:
368
+ upsert_burndown_for_sprint(sprint, snapshot_date=day)
369
+ # TODO(#4421): swallows SoftTimeLimitExceeded; the drain-loop guard
370
+ # is ineffective until this is fixed
371
+ except Exception: # burndown is a secondary observation; never fail the import
372
+ logger.exception(
373
+ "seed replay: burndown snapshot failed sprint=%s day=%s", sprint.pk, day
374
+ )
375
+
376
+
377
+ def _finalize(ctx: ReplayContext, day: date) -> None:
378
+ """Restore the end states the replay had to walk up to, once it has.
379
+
380
+ Two passes, both backstops for the same structural fact: replay births an
381
+ entity at the *base* of its progression so the timeline has somewhere to walk
382
+ from, and the walk does not always land on every authored field.
383
+ """
384
+ _finalize_sprints(ctx, day)
385
+ _finalize_tasks(ctx, day)
386
+
387
+
388
+ def _finalize_sprints(ctx: ReplayContext, day: date) -> None:
389
+ """Force every sprint to the seed's authored end state after replay.
390
+
391
+ Authored/synthesized activate+close beats normally drive this; this is the
392
+ backstop for sprints the timeline never touched, so the seed's declared
393
+ state still holds.
394
+ """
395
+ for key, sprint in ctx.sprints.items():
396
+ desired = ctx.final_sprint.get(key, {}).get("state")
397
+ if desired and sprint.state != desired:
398
+ sprint.state = desired
399
+ _save(sprint, _aware(ctx, day), ctx.default_actor, ["state"])
400
+
401
+
402
+ def _finalize_tasks(ctx: ReplayContext, day: date) -> None:
403
+ """Restore the authored progress of every task that ends in flight or in review.
404
+
405
+ A task whose end column is IN_PROGRESS or REVIEW is born at 0% with its full
406
+ points remaining so the timeline has room to walk it forward, and nothing
407
+ ever puts the authored numbers back on its own: ``_apply_task_status``
408
+ writes only the column and the actual dates. For IN_PROGRESS neither field
409
+ is rescued elsewhere (#3486). For REVIEW, ``Task._coerce_signoff_percent``
410
+ fixes ``percent_complete`` to 100 by design ("work done, awaiting
411
+ sign-off") but has no opinion on ``remaining_points`` — a story in review
412
+ with points still open is a real, authorable state (#3518). Without this
413
+ pass every in-flight or in-review task in every v2 seed loads holding its
414
+ full ``story_points``.
415
+
416
+ That is not cosmetic. ``percent_complete`` sets the *remaining* duration CPM
417
+ schedules (ADR-0132/0136), so a 0% in-flight task is billed at its full
418
+ duration and both the utilization heat map and the burndown's "remaining"
419
+ read high. The pass therefore runs before the importer's post-commit
420
+ ``enqueue_recalculate``, so the recompute sees the corrected values.
421
+
422
+ Only fields the document actually authored are written, which is what keeps
423
+ a dated ``task.points`` beat authoritative: a task that declares no
424
+ ``remaining_points`` of its own is left holding whatever the timeline gave
425
+ it. The importer never hands back ``percent_complete`` for a REVIEW task
426
+ (see its ``final_progress`` construction), so the coerced 100 is never
427
+ fought here even though this loop treats both statuses uniformly.
428
+ """
429
+ for key, task in ctx.tasks.items():
430
+ if ctx.final_status.get(key) not in (TaskStatus.IN_PROGRESS, TaskStatus.REVIEW):
431
+ continue
432
+ authored = ctx.final_progress.get(key)
433
+ if not authored:
434
+ continue
435
+ fields = [
436
+ name
437
+ for name in ("percent_complete", "remaining_points")
438
+ if name in authored and getattr(task, name) != authored[name]
439
+ ]
440
+ for name in fields:
441
+ setattr(task, name, authored[name])
442
+ if fields:
443
+ _save(task, _aware(ctx, day), ctx.default_actor, fields)
444
+
445
+
446
+ # --- event handlers ----------------------------------------------------------
447
+
448
+
449
+ def _apply(beat: _Beat, ctx: ReplayContext) -> None:
450
+ handler = _HANDLERS.get(beat.action)
451
+ if handler is None: # pragma: no cover - validator restricts the action set
452
+ logger.warning("seed replay: no handler for action %r", beat.action)
453
+ return
454
+ handler(beat, ctx)
455
+
456
+
457
+ def _apply_task_status(beat: _Beat, ctx: ReplayContext) -> None:
458
+ task = _resolve_task(ctx, beat.target)
459
+ if task is None:
460
+ return
461
+ new_status = beat.data.get("to")
462
+ if not new_status or new_status == task.status:
463
+ return
464
+ task.status = new_status
465
+ fields = ["status"]
466
+ # Actual dates make baseline-vs-actual slip visible (VoC/Sarah): first move
467
+ # into IN_PROGRESS stamps actual_start; reaching COMPLETE stamps finish.
468
+ if new_status == TaskStatus.IN_PROGRESS and task.actual_start is None:
469
+ task.actual_start = beat.when.date()
470
+ fields.append("actual_start")
471
+ if new_status == TaskStatus.COMPLETE:
472
+ task.actual_finish = beat.when.date()
473
+ task.remaining_points = 0
474
+ fields += ["actual_finish", "remaining_points"]
475
+ # Defense-in-depth (ADR-1153, #3709): beats are deterministic,
476
+ # developer-authored data and should never trip these rules, but this is a
477
+ # direct model write like the MS Project importer's, so it runs through the
478
+ # same shared check rather than trusting that by construction. A violation
479
+ # here means the beat script itself is wrong — log it and drop the
480
+ # offending field rather than persisting a pair the engine would reject at
481
+ # the project's next recompute.
482
+ violation = check_actual_dates(
483
+ actual_start=task.actual_start,
484
+ actual_finish=task.actual_finish,
485
+ status=task.status,
486
+ project=task.project,
487
+ )
488
+ if violation is not None:
489
+ logger.warning(
490
+ "seed replay: beat %r on task %s would violate ADR-1153 (%s); dropping %s",
491
+ beat.action,
492
+ task.pk,
493
+ violation.message,
494
+ violation.field,
495
+ )
496
+ setattr(task, violation.field, None)
497
+ if violation.field not in fields:
498
+ fields.append(violation.field)
499
+ _save(task, beat.when, beat.actor, fields)
500
+ # Task.save force-stamps status_changed_at=now(); correct it to the beat time
501
+ # so cycle-time and "in column since" read as history, not import time.
502
+ Task.objects.filter(pk=task.pk).update(status_changed_at=beat.when)
503
+ task.status_changed_at = beat.when
504
+
505
+
506
+ def _apply_task_assign(beat: _Beat, ctx: ReplayContext) -> None:
507
+ task = _resolve_task(ctx, beat.target)
508
+ if task is None:
509
+ return
510
+ previous_assignee_id = task.assignee_id
511
+ assignee = ctx.users.get(beat.data["assignee"]) if beat.data.get("assignee") else None
512
+ task.assignee = assignee
513
+ _save(task, beat.when, beat.actor, ["assignee"])
514
+ _notify_task_assigned(task, previous_assignee_id, assignee, beat)
515
+
516
+
517
+ def _notify_task_assigned(
518
+ task: Task, previous_assignee_id: Any, assignee: Any, beat: _Beat
519
+ ) -> None:
520
+ """``task.assigned`` to the new assignee (#638/#639), mirroring
521
+ ``views._emit_assignee_change_events``'s notification half.
522
+
523
+ Fires on any change that lands a real assignee — a fresh None→user
524
+ assignment or a user→user reassignment alike — except a clear (assignee
525
+ set to None) and never to the actor who made the assignment.
526
+ """
527
+ if assignee is None or assignee.pk == previous_assignee_id:
528
+ return
529
+ if assignee.pk == getattr(beat.actor, "pk", None):
530
+ return
531
+ from trueppm_api.apps.notifications.models import NotificationEventType
532
+ from trueppm_api.apps.notifications.services import create_event_notifications
533
+
534
+ subject = f"You were assigned to {task.name}"
535
+ body = f'You were assigned to the task "{task.name}" in TruePPM.'
536
+ _dispatch_and_backdate(
537
+ beat.when,
538
+ event_type=NotificationEventType.TASK_ASSIGNED.value,
539
+ project_id=task.project_id,
540
+ recipient_ids=[assignee.pk],
541
+ dispatch=lambda: create_event_notifications(
542
+ event_type=NotificationEventType.TASK_ASSIGNED.value,
543
+ recipient_ids=[assignee.pk],
544
+ subject=subject,
545
+ body=body,
546
+ project_id=task.project_id,
547
+ task_id=task.pk,
548
+ ),
549
+ )
550
+
551
+
552
+ def _apply_task_estimate(beat: _Beat, ctx: ReplayContext) -> None:
553
+ task = _resolve_task(ctx, beat.target)
554
+ est = beat.data.get("estimate")
555
+ if task is None or not est:
556
+ return
557
+ task.optimistic_duration = est["optimistic"]
558
+ task.most_likely_duration = est["most_likely"]
559
+ task.pessimistic_duration = est["pessimistic"]
560
+ task.estimate_status = EstimateStatus.ACCEPTED
561
+ _save(
562
+ task,
563
+ beat.when,
564
+ beat.actor,
565
+ ["optimistic_duration", "most_likely_duration", "pessimistic_duration", "estimate_status"],
566
+ )
567
+
568
+
569
+ def _apply_task_points(beat: _Beat, ctx: ReplayContext) -> None:
570
+ task = _resolve_task(ctx, beat.target)
571
+ if task is None:
572
+ return
573
+ fields = []
574
+ if "points" in beat.data:
575
+ task.story_points = beat.data["points"]
576
+ fields.append("story_points")
577
+ if "remaining_points" in beat.data:
578
+ task.remaining_points = beat.data["remaining_points"]
579
+ fields.append("remaining_points")
580
+ if fields:
581
+ _save(task, beat.when, beat.actor, fields)
582
+
583
+
584
+ def _apply_task_ac_met(beat: _Beat, ctx: ReplayContext) -> None:
585
+ """Tick one acceptance criterion (v2.1 ``criterion``), else mark the story ready.
586
+
587
+ With ``criterion`` the beat records the review trail on that row —
588
+ ``met_by``/``met_at`` dated to the beat — and leaves Definition of Ready alone:
589
+ ticking a criterion during sprint review is not a readiness decision. Without
590
+ it the beat keeps its v2.0 meaning.
591
+ """
592
+ task = _resolve_task(ctx, beat.target)
593
+ if task is None:
594
+ return
595
+ index = beat.data.get("criterion")
596
+ if index is not None:
597
+ _, _, ref = beat.target.partition(":")
598
+ project_slug, _, wbs = ref.partition(":")
599
+ rows = ctx.criteria.get((project_slug, wbs), [])
600
+ if index < len(rows):
601
+ row = rows[index]
602
+ row.met = True
603
+ row.met_by = beat.actor
604
+ row.met_at = beat.when
605
+ row.save(update_fields=["met", "met_by", "met_at"])
606
+ return
607
+ task.dor = "ready"
608
+ _save(task, beat.when, beat.actor, ["dor"])
609
+
610
+
611
+ def _apply_task_block(beat: _Beat, ctx: ReplayContext) -> None:
612
+ """Raise the explicit blocker flag, dated to the beat (#3094).
613
+
614
+ ``blocked_reason`` is the flag-of-record. ``Task.save()`` stamps
615
+ ``blocked_since`` with ``timezone.now()`` on the empty -> non-empty
616
+ transition, which on a backdated timeline would render every blocker as
617
+ "0d blocked" — and age is the entire triage signal. So the stamp is
618
+ overwritten with the beat time through a queryset update, the same way
619
+ ``_apply_scope_inject`` corrects its ``auto_now_add`` column.
620
+ """
621
+ task = _resolve_task(ctx, beat.target)
622
+ reason = beat.data.get("body")
623
+ if task is None or not reason:
624
+ return
625
+ task.blocked_reason = reason
626
+ task.blocker_type = beat.data.get("blocker_type", "")
627
+ task.blocked_by = beat.actor
628
+ fields = ["blocked_reason", "blocker_type", "blocked_by"]
629
+ blocking_ref = beat.data.get("blocking_task")
630
+ if blocking_ref:
631
+ blocking = _resolve_task(ctx, f"task:{blocking_ref}")
632
+ if blocking is not None and blocking.pk != task.pk:
633
+ task.blocking_task = blocking
634
+ fields.append("blocking_task")
635
+ _save(task, beat.when, beat.actor, fields)
636
+ Task.objects.filter(pk=task.pk).update(blocked_since=beat.when)
637
+ task.blocked_since = beat.when
638
+ _notify_task_blocked(task, beat)
639
+
640
+
641
+ def _notify_task_blocked(task: Task, beat: _Beat) -> None:
642
+ """``task.blocked`` to the assignee + project leads (#855/#476/ADR-0124),
643
+ reusing the real recipient resolver and renderer so the content matches the
644
+ live path exactly (``views._emit_blocked_notification``).
645
+
646
+ The rendered body's age line reads "just now" for a freshly-flagged task —
647
+ it always does here too, since this fires in the same beat that sets
648
+ ``blocked_since``.
649
+ """
650
+ from trueppm_api.apps.notifications.models import NotificationEventType
651
+ from trueppm_api.apps.notifications.services import create_event_notifications
652
+ from trueppm_api.apps.projects.blocker_services import (
653
+ render_blocker_notification,
654
+ resolve_impediment_recipients,
655
+ )
656
+
657
+ recipients = resolve_impediment_recipients(task)
658
+ recipients.discard(getattr(beat.actor, "pk", None))
659
+ if not recipients:
660
+ return
661
+ subject, body = render_blocker_notification(task)
662
+ recipient_ids = list(recipients)
663
+ _dispatch_and_backdate(
664
+ beat.when,
665
+ event_type=NotificationEventType.TASK_BLOCKED.value,
666
+ project_id=task.project_id,
667
+ recipient_ids=recipient_ids,
668
+ dispatch=lambda: create_event_notifications(
669
+ event_type=NotificationEventType.TASK_BLOCKED.value,
670
+ recipient_ids=recipient_ids,
671
+ subject=subject,
672
+ body=body,
673
+ project_id=task.project_id,
674
+ task_id=task.pk,
675
+ ),
676
+ )
677
+
678
+
679
+ def _apply_task_unblock(beat: _Beat, ctx: ReplayContext) -> None:
680
+ """Clear the blocker. ``Task.save()`` owns the cascade — emptying
681
+ ``blocked_reason`` also nulls ``blocked_since``, ``blocker_type``,
682
+ ``blocking_task`` and ``blocked_by`` — so this only empties the flag and
683
+ lets the model do the rest, keeping one definition of "unblocked"."""
684
+ task = _resolve_task(ctx, beat.target)
685
+ if task is None or not (task.blocked_reason or "").strip():
686
+ return
687
+ task.blocked_reason = ""
688
+ _save(task, beat.when, beat.actor, ["blocked_reason"])
689
+
690
+
691
+ def _apply_risk_note(beat: _Beat, ctx: ReplayContext) -> None:
692
+ """Append a RiskComment, so a risk.status flip carries its reason (#3094).
693
+
694
+ Without this a risk walks OPEN -> MITIGATING -> RESOLVED with no artifact of
695
+ the work: the register records *that* it was mitigated and never *how*.
696
+ ``created_at`` is ``auto_now_add``, so it is backdated after insert.
697
+ """
698
+ _, _, slug = beat.target.partition(":")
699
+ risk = ctx.risks.get(slug)
700
+ body = beat.data.get("body")
701
+ if risk is None or not body:
702
+ return
703
+ comment = RiskComment.objects.create(risk=risk, author=beat.actor, message=body)
704
+ RiskComment.objects.filter(pk=comment.pk).update(created_at=beat.when)
705
+
706
+
707
+ def _apply_task_comment(beat: _Beat, ctx: ReplayContext) -> None:
708
+ task = _resolve_task(ctx, beat.target)
709
+ body = beat.data.get("body")
710
+ if task is None or not body:
711
+ return
712
+ # v2.1 threading (#3493). Validation holds a reply to a top-level comment on
713
+ # the same task; the task check is repeated because a parent on another task
714
+ # would render the reply under a thread it is not part of.
715
+ parent = ctx.comments.get(beat.data["reply_to"]) if beat.data.get("reply_to") else None
716
+ if parent is not None and (parent.task_id != task.pk or parent.parent_id is not None):
717
+ parent = None
718
+ comment = TaskComment.objects.create(task=task, author=beat.actor, body=body, parent=parent)
719
+ # created_at is auto_now_add (stamped now() on insert); backdate it.
720
+ TaskComment.objects.filter(pk=comment.pk).update(created_at=beat.when)
721
+ if beat.data.get("slug"):
722
+ ctx.comments[beat.data["slug"]] = comment
723
+ # A reply is still a comment on the task, and the live product's own
724
+ # _notify_assignee_of_comment applies uniformly regardless of parent — so
725
+ # this fires for a reply beat exactly as it does for a top-level one (#3489).
726
+ parsed = _fan_out_mentions(comment, task, beat)
727
+ _notify_comment_on_assignee(task, comment, beat, parsed)
728
+
729
+
730
+ def _fan_out_mentions(comment: TaskComment, task: Task, beat: _Beat) -> list[ParsedMention]:
731
+ """Create Mention + Notification rows for an @mention in a seeded comment.
732
+
733
+ Only the live view path parsed mentions, so a seeded ``@mei`` was plain text:
734
+ it *read* like a mention and produced no Mention row and no notification, and
735
+ a persona signing in landed on an empty notification list. That is the shape
736
+ of defect this seed audit keeps finding — a surface that looks populated and
737
+ is not.
738
+
739
+ Resolution filters user mentions to current **project members**, which is why
740
+ this only became possible once seeded accounts held ProjectMemberships
741
+ (#3092). ``now=beat.when`` keeps the notification contemporaneous with the
742
+ comment rather than stamping import day; a mention of a non-member resolves
743
+ to ``skipped_users`` and is silently dropped, exactly as the live path
744
+ reports it.
745
+
746
+ Both ``Mention.created_at`` and ``Notification.created_at`` are
747
+ ``auto_now_add`` — always real wall-clock time — so the rows this call
748
+ writes are backdated to the beat afterward, scoped to this exact comment's
749
+ (freshly-created, unique) pk so a concurrent import elsewhere can never be
750
+ touched (#3489).
751
+
752
+ Returns the parsed mentions so the caller can de-dup the sibling
753
+ ``comment_on_my_task`` notification against an assignee who was also
754
+ directly @mentioned.
755
+ """
756
+ from trueppm_api.apps.notifications.models import Mention, Notification
757
+ from trueppm_api.apps.notifications.services import (
758
+ create_mention_notifications,
759
+ parse_mentions,
760
+ resolve_parsed_mentions,
761
+ )
762
+
763
+ parsed = parse_mentions(comment.body)
764
+ if not parsed:
765
+ return []
766
+ resolved = resolve_parsed_mentions(parsed, task.project_id)
767
+ if not resolved.user_targets and not resolved.group_targets:
768
+ return list(parsed)
769
+ t0 = timezone.now()
770
+ create_mention_notifications(
771
+ task_comment=comment,
772
+ mentioner=beat.actor,
773
+ parsed_result=resolved,
774
+ project_id=task.project_id,
775
+ now=beat.when,
776
+ )
777
+ Mention.objects.filter(task_comment=comment, created_at__gte=t0).update(created_at=beat.when)
778
+ Notification.objects.filter(mention__task_comment=comment, created_at__gte=t0).update(
779
+ created_at=beat.when
780
+ )
781
+ return parsed
782
+
783
+
784
+ def _notify_comment_on_assignee(
785
+ task: Task, comment: TaskComment, beat: _Beat, parsed: list[ParsedMention]
786
+ ) -> None:
787
+ """``comment_on_my_task`` to the task's assignee (#639), mirroring
788
+ ``views.TaskCommentViewSet._notify_assignee_of_comment``.
789
+
790
+ Skipped when there is no assignee, the assignee wrote the comment, or the
791
+ assignee was already @mentioned in it — the mention path already notified
792
+ them, and this de-dups so one comment never pings the same person twice.
793
+ """
794
+ assignee = task.assignee
795
+ if assignee is None or assignee.pk == getattr(beat.actor, "pk", None):
796
+ return
797
+ mentioned_usernames = {p.value for p in parsed if p.kind == "user"}
798
+ if assignee.username in mentioned_usernames:
799
+ return
800
+
801
+ from trueppm_api.apps.notifications.models import NotificationEventType
802
+ from trueppm_api.apps.notifications.services import create_event_notifications
803
+
804
+ actor = beat.actor
805
+ full_name = getattr(actor, "get_full_name", lambda: "")()
806
+ author_name = full_name or getattr(actor, "username", "") or "Someone"
807
+ subject = f"New comment on {task.name}"
808
+ body = f'{author_name} commented on your task "{task.name}" in TruePPM.'
809
+ _dispatch_and_backdate(
810
+ beat.when,
811
+ event_type=NotificationEventType.COMMENT_ON_MY_TASK.value,
812
+ project_id=task.project_id,
813
+ recipient_ids=[assignee.pk],
814
+ dispatch=lambda: create_event_notifications(
815
+ event_type=NotificationEventType.COMMENT_ON_MY_TASK.value,
816
+ recipient_ids=[assignee.pk],
817
+ subject=subject,
818
+ body=body,
819
+ project_id=task.project_id,
820
+ task_id=task.pk,
821
+ ),
822
+ )
823
+
824
+
825
+ def _apply_sprint_activate(beat: _Beat, ctx: ReplayContext) -> None:
826
+ sprint = _resolve_sprint(ctx, beat.target)
827
+ if sprint is None:
828
+ return
829
+ sprint.state = SprintState.ACTIVE
830
+ sprint.activated_at = beat.when
831
+ # Snapshot the commitment at activation from the seed's authored end state.
832
+ key = _sprint_key(ctx, sprint)
833
+ committed = ctx.final_sprint.get(key, {}).get("committed_points")
834
+ if committed is not None:
835
+ sprint.committed_points = committed
836
+ _save(sprint, beat.when, beat.actor, ["state", "activated_at", "committed_points"])
837
+
838
+
839
+ def _apply_sprint_close(beat: _Beat, ctx: ReplayContext) -> None:
840
+ """Route the close through the real close contract (ADR-0176, #3488).
841
+
842
+ The prior version set four columns directly and stopped, so every closed
843
+ sample sprint carried zero ``SprintTaskOutcome`` rows and produced no
844
+ ``VelocitySuggestion`` — the sprint-review "what didn't finish" panel, the
845
+ carry-over history, and the velocity-calibration prompt all read from
846
+ exactly those rows, so a fresh load of every sample reviewed as empty.
847
+ This now calls the same snapshot/outcome/velocity functions the live
848
+ ``close_sprint`` drain task calls, in the same order, so a replayed close
849
+ produces the audit trail a real one would.
850
+
851
+ Narrowed from the live path on purpose, following ``is_seed_replay_active()``'s
852
+ own contract (the burndown receiver in ``receivers.py`` is the established
853
+ precedent): the board broadcast, the ``sprint.closed`` webhook, the
854
+ carry-over assignee notification, and the milestone reforecast digest are
855
+ all "the live side effect a real edit would trigger" and are skipped
856
+ outright — none of them write a row the review panel, carry-over history,
857
+ or velocity flow reads, and firing them here would spam a workspace
858
+ nobody is watching during import. ``apply_pending_disposition`` is
859
+ likewise skipped: its "carry" branch calls ``record_sprint_scope_change``,
860
+ which fires the un-suppressed ``sprint_scope_changed`` notify signal — the
861
+ same reason ``_apply_scope_inject`` above writes its ``SprintScopeChange``
862
+ row directly instead of going through that path. ``was_pending`` on the
863
+ outcome row is unaffected: it is read by ``snapshot_sprint_task_outcomes``
864
+ below, before any disposition would run, exactly as the live close orders
865
+ it.
866
+
867
+ Every write here — the sprint's own state/closed_at/goal_outcome/
868
+ completed_*, and any task the carry-over moves — goes through this
869
+ module's ``_save()`` rather than a bare ``instance.save()``, so its history
870
+ row is backdated to the beat like every other write in this file. Calling
871
+ ``services.apply_carry_over`` directly instead of ``_replay_carry_over``
872
+ below was the first attempt: it produced the right FK moves but dated the
873
+ *moved task's own* history row to import time, which
874
+ ``test_authored_percent_restore_is_backdated_not_import_time`` catches
875
+ immediately — the fixture's own sprint closes with in-flight tasks still
876
+ on it. ``_replay_carry_over`` re-applies the identical two classification
877
+ tuples the real service uses (imported, not copied by value, so a future
878
+ change to which statuses carry stays in sync) through ``_save()`` instead.
879
+ """
880
+ sprint = _resolve_sprint(ctx, beat.target)
881
+ if sprint is None:
882
+ return
883
+
884
+ from trueppm_api.apps.projects.services import (
885
+ snapshot_completed_metrics,
886
+ snapshot_sprint_task_outcomes,
887
+ )
888
+ from trueppm_api.apps.scheduling.services import compute_velocity_suggestions
889
+
890
+ # ADR-0176 §2 / #3488: derive completed_*/the default goal_outcome from
891
+ # current task state before anything else moves, exactly like the live
892
+ # close. A synthesized close (no authored `sprint.close` beat) has no
893
+ # `ctx.final_sprint` goal_outcome — the importer never populates one for a
894
+ # synthesized beat (see `_synthesize_sprints`) — so without this every
895
+ # such sprint closed with `goal_outcome=None`; this derives a real
896
+ # MET/PARTIAL/MISSED verdict from committed vs. completed points instead.
897
+ snapshot_completed_metrics(sprint)
898
+ if beat.data.get("goal_outcome"):
899
+ # An authored value is the seed author's editorial call (e.g. "hit the
900
+ # points but missed the actual goal") and overrides the points-ratio
901
+ # default — mirroring the SCHEDULER+ override the live product allows
902
+ # after close.
903
+ sprint.goal_outcome = beat.data["goal_outcome"]
904
+ sprint.state = SprintState.COMPLETED
905
+ sprint.closed_at = beat.when
906
+ _save(
907
+ sprint,
908
+ beat.when,
909
+ beat.actor,
910
+ ["state", "closed_at", "goal_outcome", "completed_points", "completed_task_count"],
911
+ )
912
+
913
+ # ADR-0176 §2: snapshot task membership-at-close BEFORE the carry-over
914
+ # mutates Task.sprint — same order as the live close — otherwise the
915
+ # "what didn't ship" set would already be gone by the time this reads it.
916
+ carry_over_to = _implied_carry_over_target(ctx, sprint)
917
+ snapshot_sprint_task_outcomes(sprint, carry_over_to=carry_over_to)
918
+ carried_task_ids = _replay_carry_over(sprint, carry_over_to, beat.when, beat.actor)
919
+ _notify_carry_over(sprint, carry_over_to, carried_task_ids, beat)
920
+
921
+ compute_velocity_suggestions(sprint.pk)
922
+
923
+
924
+ def _replay_carry_over(sprint: Sprint, carry_over_to: str, when: datetime, actor: Any) -> list[str]:
925
+ """Mirror ``services.apply_carry_over`` (ADR-0176) with a backdated write.
926
+
927
+ The real close's ``task.save()`` has no beat to backdate to — correct
928
+ there, timestamped wrong here (#3488): a replayed carry-over must date the
929
+ moved task's history row to the beat, not to import time, like every
930
+ other write in this file. The move rules are the same two classification
931
+ tuples the real service reads (imported, not copied by value, so this
932
+ stays in sync if the close contract's carry-over policy changes) — only
933
+ *how* the result is saved differs.
934
+
935
+ Returns the moved tasks' ids so the caller can fan out the
936
+ ``task.moved_sprint`` notification (#3489) against exactly the set this
937
+ call actually carried.
938
+ """
939
+ from trueppm_api.apps.projects.services import (
940
+ _CARRY_OVER_INCOMPLETE_STATUSES,
941
+ _DECOMMITTED_ON_CARRY_OVER_TO_BACKLOG,
942
+ )
943
+
944
+ if carry_over_to == "none":
945
+ return []
946
+
947
+ incomplete = Task.objects.filter(
948
+ sprint_id=sprint.pk, status__in=_CARRY_OVER_INCOMPLETE_STATUSES, is_deleted=False
949
+ )
950
+ carried_ids: list[str] = []
951
+ for task in incomplete:
952
+ fields = ["sprint"]
953
+ if carry_over_to == "backlog":
954
+ task.sprint = None
955
+ if task.status in _DECOMMITTED_ON_CARRY_OVER_TO_BACKLOG:
956
+ task.status = TaskStatus.BACKLOG
957
+ fields.append("status")
958
+ else:
959
+ task.sprint_id = carry_over_to
960
+ _save(task, when, actor, fields)
961
+ carried_ids.append(str(task.pk))
962
+ return carried_ids
963
+
964
+
965
+ def _notify_carry_over(
966
+ sprint: Sprint, carry_over_to: str, carried_task_ids: list[str], beat: _Beat
967
+ ) -> None:
968
+ """``task.moved_sprint`` to each carried task's assignee (#1470/ADR-0232),
969
+ reusing the real grouping/copy helpers (``services._carried_tasks_by_assignee``
970
+ / ``_carryover_row``) so a demo persona with several carried tasks gets the
971
+ same one-row-per-assignee summary the live close produces.
972
+ """
973
+ if not carried_task_ids:
974
+ return
975
+
976
+ from trueppm_api.apps.notifications.models import NotificationEventType
977
+ from trueppm_api.apps.notifications.services import create_event_notifications_batch
978
+ from trueppm_api.apps.projects.services import (
979
+ _carried_tasks_by_assignee,
980
+ _carry_over_destination_label,
981
+ _carryover_row,
982
+ )
983
+
984
+ actor_pk = getattr(beat.actor, "pk", None)
985
+ by_assignee = _carried_tasks_by_assignee(carried_task_ids, actor_pk)
986
+ if not by_assignee:
987
+ return
988
+ destination = _carry_over_destination_label(carry_over_to)
989
+ rows = [
990
+ _carryover_row(assignee_id, tasks, origin=sprint.name, destination=destination)
991
+ for assignee_id, tasks in by_assignee.items()
992
+ ]
993
+ recipient_ids = list(by_assignee.keys())
994
+ _dispatch_and_backdate(
995
+ beat.when,
996
+ event_type=NotificationEventType.TASK_MOVED_SPRINT.value,
997
+ project_id=sprint.project_id,
998
+ recipient_ids=recipient_ids,
999
+ dispatch=lambda: create_event_notifications_batch(
1000
+ event_type=NotificationEventType.TASK_MOVED_SPRINT.value,
1001
+ project_id=sprint.project_id,
1002
+ rows=rows,
1003
+ ),
1004
+ )
1005
+
1006
+
1007
+ def _apply_scope_inject(beat: _Beat, ctx: ReplayContext) -> None:
1008
+ """Record a mid-sprint scope injection as the real audit row (VoC/Alex).
1009
+
1010
+ Creates the SprintScopeChange the drawer chip + Enterprise audit read, and
1011
+ flags the task pending — the same row the live ``record_sprint_scope_change``
1012
+ path writes — without firing the notify signal (suppressed during replay).
1013
+
1014
+ The injected sprint is ``task.sprint`` whenever the task still holds one;
1015
+ see :func:`_injected_sprint` for the case a re-imported export creates,
1016
+ where it no longer does.
1017
+ """
1018
+ task = _resolve_task(ctx, beat.target)
1019
+ if task is None:
1020
+ return
1021
+ sprint = _injected_sprint(task, beat, ctx)
1022
+ if sprint is None:
1023
+ return
1024
+ scope = SprintScopeChange.objects.create(
1025
+ task=task,
1026
+ sprint=sprint,
1027
+ subtask_name=task.name,
1028
+ added_by=beat.actor,
1029
+ goal_impact=bool(beat.data.get("goal_impact", False)),
1030
+ status=ScopeChangeStatus.PENDING,
1031
+ )
1032
+ # added_at is auto_now_add (stamped now() on insert); backdate it to the beat
1033
+ # so the scope-injection audit reads chronologically and — for a still-PENDING
1034
+ # injection — the exporter can reconstruct a deterministic scope_inject event
1035
+ # from this row (the round-trip fixpoint depends on a stable timestamp).
1036
+ SprintScopeChange.objects.filter(pk=scope.pk).update(added_at=beat.when)
1037
+ ctx.open_scope[task.pk] = scope
1038
+ task.sprint_pending = True
1039
+ _save(task, beat.when, beat.actor, ["sprint_pending"])
1040
+ _notify_scope_inject(task, sprint, beat)
1041
+
1042
+
1043
+ def _notify_scope_inject(task: Task, sprint: Sprint, beat: _Beat) -> None:
1044
+ """``sprint.membership_changed`` to the project lead cohort (#1946/ADR-0412),
1045
+ reusing the real recipient/copy helpers (``services._sprint_lead_recipient_ids``
1046
+ / ``_sprint_change_body``).
1047
+
1048
+ Fires only when the injected sprint is currently ACTIVE, mirroring the live
1049
+ trigger's "a live-commitment change" gate — a scope change into a
1050
+ PLANNED/COMPLETED/CANCELLED sprint carries no accountability signal.
1051
+ """
1052
+ if sprint.state != SprintState.ACTIVE:
1053
+ return
1054
+
1055
+ from trueppm_api.apps.notifications.models import NotificationEventType
1056
+ from trueppm_api.apps.notifications.services import create_event_notifications_batch
1057
+ from trueppm_api.apps.projects.services import _sprint_change_body, _sprint_lead_recipient_ids
1058
+
1059
+ actor_pk = getattr(beat.actor, "pk", None)
1060
+ recipient_ids = _sprint_lead_recipient_ids(task.project_id, actor_pk)
1061
+ if not recipient_ids:
1062
+ return
1063
+ actor_name = getattr(beat.actor, "username", "") or "Someone"
1064
+ body = _sprint_change_body(
1065
+ actor_name,
1066
+ task.name,
1067
+ None,
1068
+ {"name": sprint.name},
1069
+ entered_active=True,
1070
+ left_active=False,
1071
+ )
1072
+ rows = [(rid, "Sprint scope changed", body, str(task.pk)) for rid in recipient_ids]
1073
+ _dispatch_and_backdate(
1074
+ beat.when,
1075
+ event_type=NotificationEventType.SPRINT_MEMBERSHIP_CHANGED.value,
1076
+ project_id=task.project_id,
1077
+ recipient_ids=recipient_ids,
1078
+ dispatch=lambda: create_event_notifications_batch(
1079
+ event_type=NotificationEventType.SPRINT_MEMBERSHIP_CHANGED.value,
1080
+ project_id=task.project_id,
1081
+ rows=rows,
1082
+ ),
1083
+ )
1084
+
1085
+
1086
+ def _injected_sprint(task: Task, beat: _Beat, ctx: ReplayContext) -> Sprint | None:
1087
+ """The sprint a ``sprint.scope_inject`` beat injects ``task`` into.
1088
+
1089
+ Normally read straight off ``task.sprint``: the documented contract (see
1090
+ ``validation._EVENT_TARGET_KIND``) is that the beat targets the task and its
1091
+ sprint is derived, matching ``record_sprint_scope_change(task, sprint)``,
1092
+ where every live call site links the task to the sprint first and records
1093
+ the audit row second. That derivation holds for every hand-authored seed,
1094
+ because a document declares a task's ``sprint`` statically before it can
1095
+ date an injection against it.
1096
+
1097
+ It stops holding for a *re-imported export* (#3488). The exporter writes each
1098
+ task's ``sprint`` from its **current** membership, and a replayed sprint close
1099
+ now carries an unfinished task back to the backlog — so a still-PENDING
1100
+ ``SprintScopeChange``, which the exporter reconstructs as a
1101
+ ``sprint.scope_inject`` beat, can name a task that by export time holds no
1102
+ sprint at all. Born sprintless, the beat wrote no row, the re-export dropped
1103
+ the event, and the #616 byte-identical round trip failed.
1104
+
1105
+ The fallback is a derivation, not a guess: an injection is by definition a
1106
+ link into the sprint that is *running at that moment* (ADR-0102 §4), so the
1107
+ beat's own instant identifies it — the one sprint in the task's project that
1108
+ replay has already activated, has not yet closed, and whose dates contain the
1109
+ beat. Ambiguity is refused rather than resolved: nothing in the schema stops a
1110
+ seed authoring two overlapping active sprints in one project, and picking one
1111
+ of them arbitrarily would file the audit row against the wrong sprint.
1112
+ """
1113
+ if task.sprint_id is not None:
1114
+ return task.sprint
1115
+ _, _, ref = beat.target.partition(":")
1116
+ project_slug, _, _ = ref.partition(":")
1117
+ day = beat.when.date()
1118
+ candidates = [
1119
+ sprint
1120
+ for key, sprint in ctx.sprints.items()
1121
+ if key[0] == project_slug
1122
+ and sprint.state == SprintState.ACTIVE
1123
+ and sprint.start_date <= day <= sprint.finish_date
1124
+ ]
1125
+ return candidates[0] if len(candidates) == 1 else None
1126
+
1127
+
1128
+ def _apply_scope_resolve(beat: _Beat, ctx: ReplayContext) -> None:
1129
+ task = _resolve_task(ctx, beat.target)
1130
+ if task is None:
1131
+ return
1132
+ scope = ctx.open_scope.pop(task.pk, None)
1133
+ accepted = (beat.data.get("to") or "ACCEPTED").upper() != "REJECTED"
1134
+ if scope is not None:
1135
+ scope.status = ScopeChangeStatus.ACCEPTED if accepted else ScopeChangeStatus.REJECTED
1136
+ scope.save(update_fields=["status"])
1137
+ task.sprint_pending = False
1138
+ fields = ["sprint_pending"]
1139
+ if not accepted:
1140
+ task.sprint = None
1141
+ fields.append("sprint")
1142
+ _save(task, beat.when, beat.actor, fields)
1143
+
1144
+
1145
+ def _apply_risk_status(beat: _Beat, ctx: ReplayContext) -> None:
1146
+ _, _, slug = beat.target.partition(":")
1147
+ risk = ctx.risks.get(slug)
1148
+ if risk is None:
1149
+ return
1150
+ new_status = beat.data.get("to")
1151
+ if not new_status or new_status == risk.status:
1152
+ return
1153
+ risk.status = new_status
1154
+ _save(risk, beat.when, beat.actor, ["status"])
1155
+
1156
+
1157
+ def _apply_baseline_capture(beat: _Beat, ctx: ReplayContext) -> None:
1158
+ """Capture a baseline of the project's current task dates at the beat time.
1159
+
1160
+ ``is_active`` (default False, #3495) collapses the real app's two-step
1161
+ capture-then-activate flow (``BaselineViewSet.perform_create`` +
1162
+ ``BaselineActivateView``) into one beat: when true, any baseline already
1163
+ active for the project is deactivated first — the same swap
1164
+ ``BaselineActivateView`` performs — so an authored rebaseline can supersede
1165
+ the one it replaces in a single timeline entry instead of leaving the
1166
+ project with two inactive baselines or an ``IntegrityError`` from
1167
+ ``unique_active_baseline_per_project``.
1168
+ """
1169
+ _, _, project_slug = beat.target.partition(":")
1170
+ project = ctx.projects.get(project_slug)
1171
+ if project is None:
1172
+ return
1173
+ is_active = bool(beat.data.get("is_active", False))
1174
+ if is_active:
1175
+ Baseline.objects.filter(project=project, is_active=True).update(is_active=False)
1176
+ baseline = Baseline.objects.create(
1177
+ project=project,
1178
+ name=beat.data.get("body") or f"Baseline {beat.when.date().isoformat()}",
1179
+ is_active=is_active,
1180
+ )
1181
+ Baseline.objects.filter(pk=baseline.pk).update(created_at=beat.when)
1182
+ rows = [
1183
+ BaselineTask(
1184
+ baseline=baseline,
1185
+ task_id=task.pk,
1186
+ task_name=task.name,
1187
+ start=task.planned_start,
1188
+ finish=task.planned_start + timedelta(days=task.duration)
1189
+ if task.planned_start
1190
+ else None,
1191
+ duration=task.duration,
1192
+ story_points=task.story_points,
1193
+ )
1194
+ for (slug, _), task in ctx.tasks.items()
1195
+ if slug == project_slug
1196
+ ]
1197
+ BaselineTask.objects.bulk_create(rows)
1198
+
1199
+
1200
+ def _apply_retro_action(beat: _Beat, ctx: ReplayContext) -> None:
1201
+ """Create a RetroActionItem on the target sprint's retro (ADR-0114 §7).
1202
+
1203
+ The parent ``SprintRetro`` is created lazily the first time an action item
1204
+ attaches to the sprint (mirroring the live ``_get_or_create_retro`` path),
1205
+ so a seed authors retro outcomes without a separate "open retro" event. An
1206
+ optional ``notes`` on the beat sets the retro's own meeting-summary text
1207
+ (distinct from the action item's ``body``) — a seed typically carries it on
1208
+ the last action-item beat for that sprint, once the retro has something to
1209
+ summarize (#3497).
1210
+ """
1211
+ sprint = _resolve_sprint(ctx, beat.target)
1212
+ body = beat.data.get("body")
1213
+ if sprint is None or not body:
1214
+ return
1215
+ retro, created = SprintRetro.objects.get_or_create(
1216
+ sprint=sprint, defaults={"created_by": beat.actor}
1217
+ )
1218
+ if created:
1219
+ # created_at is auto_now_add; backdate the retro to its first action so
1220
+ # the demo's retro is dated to the ceremony, not import time.
1221
+ SprintRetro.objects.filter(pk=retro.pk).update(created_at=beat.when)
1222
+ notes = beat.data.get("notes")
1223
+ if notes:
1224
+ retro.notes = notes
1225
+ _save(retro, beat.when, beat.actor, ["notes"])
1226
+ assignee = ctx.users.get(beat.data["assignee"]) if beat.data.get("assignee") else None
1227
+ item = RetroActionItem.objects.create(
1228
+ retro=retro,
1229
+ text=body,
1230
+ assignee=assignee,
1231
+ story_points=beat.data.get("points"),
1232
+ )
1233
+ RetroActionItem.objects.filter(pk=item.pk).update(created_at=beat.when)
1234
+
1235
+
1236
+ def _apply_retro_promote(beat: _Beat, ctx: ReplayContext) -> None:
1237
+ """Promote a retro action item (matched by ``body``) to a backlog task.
1238
+
1239
+ Mirrors ``promote_retro_action_item`` but writes directly (no on_commit
1240
+ broadcast / recalc enqueue) because replay is side-effect-suppressed — the
1241
+ importer enqueues a single per-project recalc after commit. The resulting
1242
+ Task is a project-backlog item (``status=BACKLOG``, ``sprint=None``) exactly
1243
+ as the live promote path produces, so the demo shows the retro→task loop
1244
+ closed with a real ``T-XXX`` link.
1245
+ """
1246
+ sprint = _resolve_sprint(ctx, beat.target)
1247
+ body = beat.data.get("body")
1248
+ if sprint is None or not body:
1249
+ return
1250
+ retro = SprintRetro.objects.filter(sprint=sprint).first()
1251
+ if retro is None:
1252
+ return
1253
+ item = (
1254
+ RetroActionItem.objects.filter(
1255
+ retro=retro, text=body, promoted_task_id__isnull=True, is_deleted=False
1256
+ )
1257
+ .order_by("created_at")
1258
+ .first()
1259
+ )
1260
+ if item is None:
1261
+ return # nothing (left) to promote for this text
1262
+ short_id = sprint.short_id or str(sprint.pk)[:8]
1263
+ task = Task(
1264
+ project=sprint.project,
1265
+ name=body[:255],
1266
+ duration=1,
1267
+ status=TaskStatus.BACKLOG,
1268
+ sprint=None,
1269
+ assignee=item.assignee,
1270
+ story_points=item.story_points,
1271
+ notes=f'source: "retrospective" (from Sprint {short_id} retro)',
1272
+ )
1273
+ _save_new_backdated(task, beat.when, beat.actor)
1274
+ item.promoted_task_id = task.pk
1275
+ item.save(update_fields=["promoted_task_id"])
1276
+
1277
+
1278
+ # --- notifications -------------------------------------------------------------
1279
+ #
1280
+ # #3489: fresh samples produced notifications only from the mention fan-out
1281
+ # above (`_fan_out_mentions`) — every other beat wrote through the ORM and
1282
+ # never touched `notifications.services`, so a persona signing in saw an
1283
+ # empty bell. The handlers above call the small `_notify_*` functions added
1284
+ # alongside them; this section holds the cross-cutting backdating helper plus
1285
+ # the two event types with no single triggering beat (a synthesized
1286
+ # `task.stale` sweep and the sponsor's `program.health_digest`) and the final
1287
+ # read/unread/archived/snoozed realism pass.
1288
+
1289
+
1290
+ def _dispatch_and_backdate(
1291
+ when: datetime,
1292
+ *,
1293
+ event_type: str,
1294
+ project_id: Any,
1295
+ recipient_ids: Sequence[Any],
1296
+ dispatch: Callable[[], Any],
1297
+ ) -> None:
1298
+ """Run a ``notifications.services`` dispatch, then backdate the rows it wrote.
1299
+
1300
+ Every dispatcher in that module stamps ``Notification.created_at`` via
1301
+ ``auto_now_add`` — always real wall-clock time, correct for the live
1302
+ product and wrong for a replayed beat. This captures the instant right
1303
+ before dispatch and rewrites every matching row to ``when`` afterward.
1304
+
1305
+ The filter is scoped to ``(recipient, project, event_type, created_at >= t0)``.
1306
+ ``project_id`` is always a project this same import just created (or
1307
+ ``None`` for the one account-scoped digest, whose recipient is likewise a
1308
+ user this import just created), so the window can never reach a row a
1309
+ concurrent, unrelated import or request wrote in the same instant.
1310
+ """
1311
+ ids = [rid for rid in recipient_ids if rid is not None]
1312
+ if not ids:
1313
+ return
1314
+
1315
+ from trueppm_api.apps.notifications.models import Notification
1316
+
1317
+ t0 = timezone.now()
1318
+ dispatch()
1319
+ Notification.objects.filter(
1320
+ recipient_id__in=ids,
1321
+ project_id=project_id,
1322
+ event_type=event_type,
1323
+ created_at__gte=t0,
1324
+ ).update(created_at=when)
1325
+
1326
+
1327
+ def _seed_notification_preferences(payload: dict[str, Any], ctx: ReplayContext) -> None:
1328
+ """Backfill every persona's default ``NotificationPreference`` rows, plus one
1329
+ ``ProjectNotificationPreference`` with a non-default quiet-hours window for
1330
+ the program's OWNER persona (#3489's "preference pages render seeded rows,
1331
+ not defaults" acceptance bar).
1332
+ """
1333
+ import datetime as _dt
1334
+
1335
+ from trueppm_api.apps.notifications.models import ProjectNotificationPreference
1336
+ from trueppm_api.apps.notifications.services import get_or_create_default_preferences
1337
+
1338
+ for user in ctx.users.values():
1339
+ if user is not None:
1340
+ get_or_create_default_preferences(user)
1341
+
1342
+ owner_slug = next(
1343
+ (a["slug"] for a in payload.get("accounts", []) if a.get("role") == "OWNER"), None
1344
+ )
1345
+ owner = ctx.users.get(owner_slug) if owner_slug else None
1346
+ project = next(iter(ctx.projects.values()), None)
1347
+ if owner is None or project is None:
1348
+ return
1349
+ ProjectNotificationPreference.objects.update_or_create(
1350
+ project=project,
1351
+ user=owner,
1352
+ defaults={
1353
+ "quiet_hours_enabled": True,
1354
+ "quiet_hours_from": _dt.time(18, 0),
1355
+ "quiet_hours_until": _dt.time(8, 0),
1356
+ },
1357
+ )
1358
+
1359
+
1360
+ def _synthesize_stale_notifications(ctx: ReplayContext) -> None:
1361
+ """Seed-local ``task.stale`` sweep (ADR-0200), scoped to this program's own tasks.
1362
+
1363
+ Deliberately NOT ``notifications.services.create_stale_task_notifications``:
1364
+ that function scans every project in the install against the real wall
1365
+ clock, which would (a) sweep already-imported, unrelated samples and (b)
1366
+ stamp ``created_at`` at real import time rather than a date this replay
1367
+ can own. This mirrors its eligibility rule (every non-``COMPLETE`` status,
1368
+ an assignee set, untouched past the project's ``stale_task_threshold_days``)
1369
+ but dates each notification to when the task actually went stale.
1370
+ """
1371
+ from trueppm_api.apps.notifications.models import NotificationEventType
1372
+ from trueppm_api.apps.notifications.services import (
1373
+ DEFAULT_STALE_TASK_THRESHOLD_DAYS,
1374
+ create_event_notifications_batch,
1375
+ )
1376
+
1377
+ for (project_slug, _wbs), task in ctx.tasks.items():
1378
+ if task.status == TaskStatus.COMPLETE or task.assignee_id is None:
1379
+ continue
1380
+ if task.status_changed_at is None:
1381
+ continue
1382
+ project = ctx.projects[project_slug]
1383
+ threshold = project.stale_task_threshold_days or DEFAULT_STALE_TASK_THRESHOLD_DAYS
1384
+ stale_since = task.status_changed_at.date() + timedelta(days=threshold)
1385
+ if stale_since > ctx.anchor:
1386
+ continue
1387
+ display_name = task.name if len(task.name) <= 200 else task.name[:200].rstrip() + "…"
1388
+ subject = f'"{display_name}" has gone stale'
1389
+ body = (
1390
+ f'Your task "{display_name}" has sat in the same status for more than '
1391
+ f"{threshold} days. If it is still active, move it forward; "
1392
+ f"otherwise update its status so the board reflects reality."
1393
+ )
1394
+ when = _aware_dt(ctx, stale_since, 8, 0)
1395
+ rows = [(task.assignee_id, subject, body, str(task.pk))]
1396
+ _dispatch_and_backdate(
1397
+ when,
1398
+ event_type=NotificationEventType.TASK_STALE.value,
1399
+ project_id=task.project_id,
1400
+ recipient_ids=[task.assignee_id],
1401
+ # functools.partial (not a lambda closing over the loop variables)
1402
+ # so each call is bound to THIS iteration's rows/project, not
1403
+ # whatever the loop variable holds by the time it runs.
1404
+ dispatch=functools.partial(
1405
+ create_event_notifications_batch,
1406
+ event_type=NotificationEventType.TASK_STALE.value,
1407
+ project_id=task.project_id,
1408
+ rows=rows,
1409
+ ),
1410
+ )
1411
+
1412
+
1413
+ def _synthesize_program_digest(payload: dict[str, Any], ctx: ReplayContext) -> None:
1414
+ """One ``program.health_digest`` row for the sponsor persona — the seed's
1415
+ VIEWER account, the read-only executive role every sample carries exactly
1416
+ one of — dated to the most recent Sunday evening on/before the anchor,
1417
+ matching the real weekly send slot (ADR-0663).
1418
+
1419
+ ``PROGRAM_HEALTH_DIGEST`` defaults OFF on both channels (a weekly send
1420
+ nobody asked for is exactly the noise the default guards against), so the
1421
+ sponsor is opted in explicitly here first — otherwise the dispatch below
1422
+ would silently create nothing.
1423
+ """
1424
+ accounts = payload.get("accounts", [])
1425
+ sponsor_slug = next((a["slug"] for a in accounts if a.get("role") == "VIEWER"), None)
1426
+ if sponsor_slug is None:
1427
+ return
1428
+ sponsor = ctx.users.get(sponsor_slug)
1429
+ if sponsor is None:
1430
+ return
1431
+
1432
+ from trueppm_api.apps.notifications.digests import build_program_health_digest
1433
+ from trueppm_api.apps.notifications.models import (
1434
+ NotificationChannel,
1435
+ NotificationEventType,
1436
+ NotificationPreference,
1437
+ )
1438
+ from trueppm_api.apps.notifications.services import create_event_notifications
1439
+
1440
+ # `_seed_notification_preferences` already backfilled the sponsor's default
1441
+ # rows earlier in `replay_timeline`; only the digest event's opt-in (below)
1442
+ # needs an explicit override.
1443
+ NotificationPreference.objects.update_or_create(
1444
+ user=sponsor,
1445
+ event_type=NotificationEventType.PROGRAM_HEALTH_DIGEST.value,
1446
+ channel=NotificationChannel.IN_APP.value,
1447
+ defaults={"enabled": True},
1448
+ )
1449
+
1450
+ digest_day = ctx.anchor - timedelta(days=(ctx.anchor.weekday() + 1) % 7)
1451
+ when = _aware_dt(ctx, digest_day, 18, 0)
1452
+ subject, body = build_program_health_digest(sponsor, when)
1453
+ _dispatch_and_backdate(
1454
+ when,
1455
+ event_type=NotificationEventType.PROGRAM_HEALTH_DIGEST.value,
1456
+ project_id=None,
1457
+ recipient_ids=[sponsor.pk],
1458
+ dispatch=lambda: create_event_notifications(
1459
+ event_type=NotificationEventType.PROGRAM_HEALTH_DIGEST.value,
1460
+ recipient_ids=[sponsor.pk],
1461
+ subject=subject,
1462
+ body=body,
1463
+ project_id=None,
1464
+ ),
1465
+ )
1466
+
1467
+
1468
+ def _apply_notification_realism(ctx: ReplayContext) -> None:
1469
+ """Age the freshly-emitted inbox into a mix of read / unread / archived /
1470
+ snoozed rows (#3489) — a batch created in one instant and left untouched
1471
+ would all read as equally fresh, which is its own tell.
1472
+
1473
+ Rows backdated more than ~5 days before the anchor are marked read; a
1474
+ deterministic handful of those are archived; one still-unread row is
1475
+ snoozed into the demo's near future. Recent rows are left unread so the
1476
+ bell has something to show. Scoped to this program's own projects (plus,
1477
+ for the one account-scoped digest, this program's own users) so a
1478
+ re-import of a different sample can never be touched.
1479
+ """
1480
+ from trueppm_api.apps.notifications.models import Notification
1481
+
1482
+ project_ids = [p.pk for p in ctx.projects.values()]
1483
+ user_ids = [u.pk for u in ctx.users.values() if u is not None]
1484
+ qs = Notification.objects.filter(
1485
+ Q(project_id__in=project_ids) | Q(project_id__isnull=True, recipient_id__in=user_ids)
1486
+ )
1487
+
1488
+ read_cutoff = _aware(ctx, ctx.anchor - timedelta(days=5))
1489
+ qs.filter(created_at__lt=read_cutoff, is_read=False).update(
1490
+ is_read=True, read_at=F("created_at") + timedelta(hours=2)
1491
+ )
1492
+
1493
+ archive_ids = list(
1494
+ qs.filter(is_read=True, is_archived=False)
1495
+ .order_by("created_at")
1496
+ .values_list("pk", flat=True)[:3]
1497
+ )
1498
+ if archive_ids:
1499
+ Notification.objects.filter(pk__in=archive_ids).update(is_archived=True)
1500
+
1501
+ snooze_candidate = (
1502
+ qs.filter(is_read=False, is_archived=False, snoozed_until__isnull=True)
1503
+ .order_by("-created_at")
1504
+ .first()
1505
+ )
1506
+ if snooze_candidate is not None:
1507
+ Notification.objects.filter(pk=snooze_candidate.pk).update(
1508
+ snoozed_until=_aware_dt(ctx, ctx.anchor + timedelta(days=2), 9, 0)
1509
+ )
1510
+
1511
+
1512
+ # --- helpers -----------------------------------------------------------------
1513
+
1514
+
1515
+ def _save_new_backdated(instance: Any, when: datetime, user: Any) -> None:
1516
+ """Insert a brand-new instance, dating its creation history row to ``when``.
1517
+
1518
+ Unlike ``_save`` (which uses ``update_fields`` for a narrow update), this is
1519
+ for a first insert: simple-history honors ``_history_date`` / ``_history_user``
1520
+ on the create so the row is attributed and backdated in one write.
1521
+ """
1522
+ instance._history_date = when
1523
+ instance._history_user = user
1524
+ instance.save()
1525
+
1526
+
1527
+ def _save(instance: Any, when: datetime, user: Any, fields: list[str]) -> None:
1528
+ """Save ``instance`` writing a history row dated ``when`` by ``user``.
1529
+
1530
+ django-simple-history honors ``_history_date``/``_history_user`` set on the
1531
+ instance before save (ADR-0114 — the first backdated-history writer in the
1532
+ codebase). update_fields keeps the write narrow.
1533
+ """
1534
+ instance._history_date = when
1535
+ instance._history_user = user
1536
+ instance.save(update_fields=fields)
1537
+
1538
+
1539
+ def _aware(ctx: ReplayContext, day: date) -> datetime:
1540
+ return _aware_dt(ctx, day, 12, 0)
1541
+
1542
+
1543
+ def _aware_dt(ctx: ReplayContext, day: date, hour: int, minute: int) -> datetime:
1544
+ """Build an aware datetime in the program timezone (default UTC)."""
1545
+ tz = ctx.tz or ZoneInfo("UTC")
1546
+ return datetime(day.year, day.month, day.day, hour, minute, tzinfo=tz)
1547
+
1548
+
1549
+ def _sprint_key(ctx: ReplayContext, sprint: Sprint) -> tuple[str, str]:
1550
+ for key, candidate in ctx.sprints.items():
1551
+ if candidate.pk == sprint.pk:
1552
+ return key
1553
+ return ("", "")
1554
+
1555
+
1556
+ def _implied_carry_over_target(ctx: ReplayContext, sprint: Sprint) -> str:
1557
+ """Infer this close's carry-over destination from the seed's own timeline (#3488).
1558
+
1559
+ The live close reads ``carry_over_to`` off the API request; a replayed
1560
+ close has none, so it is read off the seed's intent instead: the next
1561
+ sprint in the same project that the timeline actually runs (state ACTIVE
1562
+ or COMPLETED in ``ctx.final_sprint`` — i.e. it gets an authored or
1563
+ synthesized ``sprint.activate`` beat somewhere in the timeline) is where
1564
+ an incomplete task would land. No such sprint — this closing sprint is
1565
+ the project's last to actually run — falls back to ``"backlog"``, the
1566
+ same default ``POST .../close/`` uses.
1567
+ """
1568
+ project_slug, _ = _sprint_key(ctx, sprint)
1569
+ candidates = [
1570
+ (candidate.start_date, candidate.pk)
1571
+ for key, candidate in ctx.sprints.items()
1572
+ if key[0] == project_slug
1573
+ and candidate.pk != sprint.pk
1574
+ and candidate.start_date > sprint.finish_date
1575
+ and ctx.final_sprint.get(key, {}).get("state")
1576
+ in (SprintState.ACTIVE, SprintState.COMPLETED)
1577
+ ]
1578
+ if not candidates:
1579
+ return "backlog"
1580
+ candidates.sort(key=lambda c: c[0])
1581
+ return str(candidates[0][1])
1582
+
1583
+
1584
+ def _named_actor(beat: _Beat, ctx: ReplayContext) -> Any:
1585
+ """The account a beat names, without the importing-owner fallback.
1586
+
1587
+ ``_Beat.actor`` falls back to the importing owner, which is right for a
1588
+ status move or a comment but wrong for a fact whose attribution *is* the
1589
+ fact: an hour logged, a reaction, an acknowledgement. On the generic import
1590
+ path a pre-existing real account resolves to ``None`` (#1057); crediting its
1591
+ hours to the importer instead would fabricate the importer's own timesheet.
1592
+ """
1593
+ slug = beat.data.get("actor")
1594
+ return ctx.users.get(slug) if slug else None
1595
+
1596
+
1597
+ def _comment_for(ctx: ReplayContext, target: str) -> TaskComment | None:
1598
+ _, _, slug = target.partition(":")
1599
+ return ctx.comments.get(slug)
1600
+
1601
+
1602
+ def _apply_task_note(beat: _Beat, ctx: ReplayContext) -> None:
1603
+ """Append a dated ``TaskNote`` — the task's why/decision log (ADR-0143, #3492).
1604
+
1605
+ ``decision`` is what the Decisions view reads. ``created_at`` is
1606
+ ``auto_now_add``, so it is backdated after insert like a comment.
1607
+ """
1608
+ task = _resolve_task(ctx, beat.target)
1609
+ body = beat.data.get("body")
1610
+ if task is None or not body:
1611
+ return
1612
+ note = TaskNote.objects.create(
1613
+ task=task,
1614
+ author=beat.actor,
1615
+ body=body,
1616
+ pinned=bool(beat.data.get("pinned", False)),
1617
+ decision=bool(beat.data.get("decision", False)),
1618
+ )
1619
+ TaskNote.objects.filter(pk=note.pk).update(created_at=beat.when)
1620
+
1621
+
1622
+ def _apply_task_react(beat: _Beat, ctx: ReplayContext) -> None:
1623
+ """Add a reaction to a slugged comment (ADR-0075 §A.4). Never notifies."""
1624
+ comment = _comment_for(ctx, beat.target)
1625
+ user = _named_actor(beat, ctx)
1626
+ emoji = beat.data.get("emoji")
1627
+ if comment is None or user is None or not emoji:
1628
+ return
1629
+ reaction, created = CommentReaction.objects.get_or_create(
1630
+ comment=comment, user=user, emoji=emoji
1631
+ )
1632
+ if created:
1633
+ CommentReaction.objects.filter(pk=reaction.pk).update(created_at=beat.when)
1634
+
1635
+
1636
+ def _apply_task_ack(beat: _Beat, ctx: ReplayContext) -> None:
1637
+ """Acknowledge a slugged comment — "I saw this / I'm on it" (ADR-0075 §A.3)."""
1638
+ comment = _comment_for(ctx, beat.target)
1639
+ user = _named_actor(beat, ctx)
1640
+ if comment is None or user is None:
1641
+ return
1642
+ ack, created = CommentAcknowledgement.objects.get_or_create(comment=comment, user=user)
1643
+ if created:
1644
+ CommentAcknowledgement.objects.filter(pk=ack.pk).update(created_at=beat.when)
1645
+
1646
+
1647
+ def _apply_time_log(beat: _Beat, ctx: ReplayContext) -> None:
1648
+ """Write a ``TimeEntry`` dated to the beat (ADR-0185, #3490).
1649
+
1650
+ Never forward-dated: a beat after the anchor is dropped, because logged time
1651
+ records work that happened and the demo's "today" is the anchor. Validation
1652
+ already rejects a relative ``A+N`` beat; this also covers an ISO literal.
1653
+ """
1654
+ task = _resolve_task(ctx, beat.target)
1655
+ user = _named_actor(beat, ctx)
1656
+ minutes = beat.data.get("minutes")
1657
+ if task is None or user is None or not minutes:
1658
+ return
1659
+ entry_date = beat.when.date()
1660
+ if entry_date > ctx.anchor:
1661
+ return
1662
+ entry = TimeEntry.objects.create(
1663
+ task=task,
1664
+ user=user,
1665
+ minutes=minutes,
1666
+ entry_date=entry_date,
1667
+ note=beat.data.get("note", ""),
1668
+ source=beat.data.get("source", "manual"),
1669
+ )
1670
+ TimeEntry.objects.filter(pk=entry.pk).update(created_at=beat.when)
1671
+
1672
+
1673
+ _HANDLERS = {
1674
+ "task.note": _apply_task_note,
1675
+ "task.react": _apply_task_react,
1676
+ "task.ack": _apply_task_ack,
1677
+ "time.log": _apply_time_log,
1678
+ _TASK_STATUS_ACTION: _apply_task_status,
1679
+ "task.assign": _apply_task_assign,
1680
+ "task.estimate": _apply_task_estimate,
1681
+ "task.points": _apply_task_points,
1682
+ "task.ac_met": _apply_task_ac_met,
1683
+ "task.block": _apply_task_block,
1684
+ "task.unblock": _apply_task_unblock,
1685
+ "task.comment": _apply_task_comment,
1686
+ "sprint.activate": _apply_sprint_activate,
1687
+ "sprint.close": _apply_sprint_close,
1688
+ "sprint.scope_inject": _apply_scope_inject,
1689
+ "sprint.scope_resolve": _apply_scope_resolve,
1690
+ "risk.status": _apply_risk_status,
1691
+ "risk.note": _apply_risk_note,
1692
+ "baseline.capture": _apply_baseline_capture,
1693
+ "retro.action": _apply_retro_action,
1694
+ "retro.promote": _apply_retro_promote,
1695
+ }