trueppm-api 0.4.0b2__py3-none-any.whl

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