trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,2764 @@
1
+ """Import a canonical JSON seed document into the database (ADR-0109, issue #615).
2
+
3
+ ``import_seed`` validates a payload (#614) and then materializes one program,
4
+ its projects, tasks, dependencies, sprints, baselines, risks, resources, and
5
+ memberships inside a single transaction. File-local slugs and ltree wbs paths
6
+ are resolved to freshly-minted UUIDs through in-memory symbol tables; nothing in
7
+ the seed file carries a UUID.
8
+
9
+ Re-import is keyed on the program slug (persisted in ``Program.code``). The seed
10
+ format carries no stable entity ids until 0.5 (#1959), so a field-level merge has
11
+ nothing to key on and wipe-then-rebuild remains the right idempotency model —
12
+ but it is destructive, so as of ADR-0726 it requires explicit consent
13
+ (``replace=True``) and is *recoverable*: a real program's subtree is
14
+ **soft**-deleted into Trash with tombstones and per-project delete broadcasts.
15
+ Only the disposable demo path (``is_sample``) still hard-deletes, which is what
16
+ that data is for (ADR-0109).
17
+
18
+ The seeded tasks carry no CPM outputs (those are derived), so a schedule
19
+ recalculation is enqueued per project after commit; a board broadcast is
20
+ likewise deferred to ``transaction.on_commit``.
21
+
22
+ Writes are batched (ADR-0726 §8). The whole run sits inside
23
+ ``coalesce_sync_seq()`` so the per-row delta-cursor allocation collapses to one
24
+ draw per project, and the O(n) collections go through ``_bulk_insert``, which
25
+ hand-maintains the three things ``VersionedModel.save()`` would otherwise do for
26
+ free — ``server_version``, ``sync_seq``, and the ``simple_history`` row.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import logging
34
+ import random
35
+ import uuid
36
+ from collections import defaultdict
37
+ from dataclasses import dataclass
38
+ from datetime import date, datetime, timedelta
39
+ from datetime import time as dt_time
40
+ from decimal import Decimal
41
+ from functools import partial
42
+ from typing import Any
43
+ from zoneinfo import ZoneInfo
44
+
45
+ from django.contrib.auth import get_user_model
46
+ from django.db import transaction
47
+ from django.db.models import F
48
+ from django.utils import timezone
49
+ from simple_history.utils import bulk_create_with_history
50
+
51
+ from trueppm_api.apps.access.models import ProgramMembership, ProjectMembership, Role
52
+ from trueppm_api.apps.access.services import create_program
53
+ from trueppm_api.apps.projects.models import (
54
+ AcceptanceCriterion,
55
+ BacklogItem,
56
+ Baseline,
57
+ BaselineTask,
58
+ BoardColumnConfig,
59
+ Calendar,
60
+ CalendarException,
61
+ CeremonyTemplate,
62
+ Dependency,
63
+ DorState,
64
+ EstimateStatus,
65
+ Label,
66
+ ObjectKeySource,
67
+ Program,
68
+ Project,
69
+ Risk,
70
+ RiskTask,
71
+ Sprint,
72
+ Task,
73
+ TaskAttachment,
74
+ TaskLabel,
75
+ TaskRecurrenceRule,
76
+ TaskRelation,
77
+ TaskSource,
78
+ TaskStatus,
79
+ bulk_create_tasks,
80
+ )
81
+ from trueppm_api.apps.projects.seed.forecast_backfill import (
82
+ backfill_forecast_history,
83
+ backfill_monte_carlo_run_history,
84
+ )
85
+ from trueppm_api.apps.projects.seed.reldates import (
86
+ WorkingCalendar,
87
+ resolve_anchor,
88
+ resolve_date,
89
+ resolve_timestamp,
90
+ )
91
+ from trueppm_api.apps.projects.seed.replace import resolve_replace_candidates
92
+ from trueppm_api.apps.projects.seed.replay import ReplayContext, replay_timeline
93
+ from trueppm_api.apps.projects.seed.validation import validate_seed
94
+ from trueppm_api.apps.resources.models import (
95
+ Resource,
96
+ ResourceSkill,
97
+ Skill,
98
+ TaskResource,
99
+ TaskSkillRequirement,
100
+ )
101
+ from trueppm_api.apps.resources.services import ensure_project_resource
102
+ from trueppm_api.apps.scheduling.services import enqueue_recalculate
103
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
104
+ from trueppm_api.apps.sync.sequence import allocate_for_projects, coalesce_sync_seq
105
+ from trueppm_api.apps.timetracking.models import TimeEntry, TimesheetSubmission
106
+
107
+ logger = logging.getLogger(__name__)
108
+
109
+ _UTC = ZoneInfo("UTC")
110
+
111
+ #: Most days of synthesized time one task contributes on the sample path (#3490).
112
+ #: The most recent days of a long waterfall task are what a timesheet shows; an
113
+ #: uncapped fill would write a row per person per working day for months.
114
+ _TIME_FILL_MAX_DAYS = 40
115
+
116
+ #: The seed's proficiency names -> ``Proficiency`` integers (#3498).
117
+ _PROFICIENCY_BY_NAME = {"beginner": 1, "intermediate": 2, "expert": 3}
118
+
119
+ #: Weekday names -> the ``TaskRecurrenceRule.weekdays`` bitmask (Mon=1 … Sun=64).
120
+ _WEEKDAY_BIT = {"mon": 1, "tue": 2, "wed": 4, "thu": 8, "fri": 16, "sat": 32, "sun": 64}
121
+
122
+ #: Rows per INSERT statement in the batched passes (ADR-0726 §8).
123
+ #:
124
+ #: Django's PostgreSQL backend does not override ``bulk_batch_size``, so an
125
+ #: unbounded ``bulk_create`` emits *one* statement for the whole project. With
126
+ #: psycopg's default client-side binding that is a single mogrified string
127
+ #: holding every literal — ``Task`` has ~57 columns, and the history table adds
128
+ #: five more — so an adversarially dense document would build a multi-hundred-MB
129
+ #: statement (twice, row + history) before Postgres ever sees it. Worker memory
130
+ #: is the binding resource once the import is async, so the batch is capped.
131
+ _BULK_BATCH_SIZE = 500
132
+
133
+ # Stand-in reason for a ``blocked`` cluster that carries no ``reason`` (#4082). The
134
+ # exporter withholds the ADR-0124 private text from anyone but the assignee or an
135
+ # @-mentioned user, but ``blocked_reason`` is the flag-of-record — emptiness means
136
+ # "not blocked". Importing the bare cluster as-is would silently unblock the task
137
+ # and drop the age/type/link triage signal the export deliberately kept, so a
138
+ # present cluster with no reason imports as blocked with this placeholder.
139
+ _PRIVATE_BLOCKER_REASON = "(private)"
140
+
141
+ # Ceiling for a TaskResource synthesized from a bare ``assignee`` (#2900). The
142
+ # actual units are ``min(this, resource.max_units)`` — a bare assignee means "this
143
+ # person is on this task at their normal availability", so a half-time engineer
144
+ # lands at 0.5 and a 10% advisor at 0.1 rather than all of them at 1.0.
145
+ #
146
+ # That distinction is load-bearing for the demo the packs exist to give: they
147
+ # deliberately staff part-timers and 10% advisors ("not everyone is at 100%", per
148
+ # the sample-projects docs), and billing an advisor a full unit would put her at
149
+ # 1000% on every task she owns — an all-red heatmap misrepresenting the very
150
+ # capacity story the fixture was authored to tell. A seed that wants some other
151
+ # split says so with an explicit ``assignments`` array, which always wins.
152
+ _ASSIGNEE_MAX_UNITS = Decimal("1.0")
153
+
154
+
155
+ class SeedReplaceRequired(Exception):
156
+ """A live program the caller owns holds this seed's slug and ``replace`` was not given.
157
+
158
+ Raised *before* anything is written. Carries the resolved ``Program`` so the
159
+ view can render the ``409`` conflict body without re-running the ownership
160
+ query — and so the only program that can ever be named is one the resolver
161
+ already proved the caller owns (#994).
162
+ """
163
+
164
+ def __init__(self, program: Program) -> None:
165
+ self.program = program
166
+ super().__init__(f"A program you own already uses the code {program.code!r}.")
167
+
168
+
169
+ class SeedReplaceMismatch(Exception):
170
+ """``expected_program_id`` did not match the program that would actually be replaced.
171
+
172
+ The compare-and-swap failure. A client that resolved a conflict via the dry
173
+ run and then acted on it is protected from the collision set having changed
174
+ underneath it in the meantime — the alternative, a bare ``replace=True``,
175
+ would silently follow the change and destroy a different program.
176
+ """
177
+
178
+
179
+ class SeedReplaceAmbiguous(Exception):
180
+ """Several live programs the caller owns hold this slug, and none was named.
181
+
182
+ ``Program.code`` carries no uniqueness constraint, so owning two programs
183
+ under one code is legitimate. A confirmation can only describe one of them,
184
+ so replacing the whole set would destroy a program the caller was never
185
+ shown a count for — ``expected_program_id`` is the way out.
186
+ """
187
+
188
+
189
+ User = get_user_model()
190
+
191
+ # Program membership role names in the seed map to the Role integer ladder.
192
+ _ROLE_BY_NAME = {
193
+ "VIEWER": Role.VIEWER,
194
+ "MEMBER": Role.MEMBER,
195
+ "SCHEDULER": Role.SCHEDULER,
196
+ "ADMIN": Role.ADMIN,
197
+ "OWNER": Role.OWNER,
198
+ }
199
+
200
+
201
+ def import_seed(
202
+ payload: dict[str, Any],
203
+ *,
204
+ owner: Any,
205
+ create_users: bool = False,
206
+ is_sample: bool = False,
207
+ persona_password: str | None = None,
208
+ replace: bool = False,
209
+ expected_program_id: str | None = None,
210
+ target_program: Program | None = None,
211
+ adopt_keys_from: list[str] | None = None,
212
+ ) -> Program:
213
+ """Validate and import a seed document, returning the created ``Program``.
214
+
215
+ Args:
216
+ payload: a parsed seed document (already JSON-decoded).
217
+ owner: the ``User`` who owns the imported program (gets OWNER membership).
218
+ create_users: when ``True``, ``accounts[]`` that name no existing user
219
+ are created (used by ``make seed`` / the management command). When
220
+ ``False`` (the REST default), missing accounts resolve to ``None`` —
221
+ importing a seed must not silently mint logins on a live instance.
222
+ is_sample: when ``True`` this is the disposable demo/sample path
223
+ (``load_sample``). It marks every created project ``is_sample`` and
224
+ reuses the shared persona resource catalog. When ``False`` (the
225
+ generic ``import_seed`` path) imported resources are created fresh so
226
+ a seed can never bind a pre-existing global resource — and the real
227
+ user it may carry — into the importer's program (#1004).
228
+ persona_password: when set (and ``is_sample``), persona accounts are given
229
+ this as a usable login password so an evaluator can actually sign in as
230
+ each persona (#1760). Applies both to accounts this run creates and to
231
+ pre-existing persona rows that carry **no usable password of their own**
232
+ (#3484 — a reload is the common case, and skipping those made the CLI
233
+ report a login it had not enabled). A real account's password is never
234
+ overwritten, and staff/superuser rows are never touched. ``None`` (the
235
+ default) leaves personas with an unusable password, as before. Only the
236
+ server-curated sample path passes this; the caller is responsible for
237
+ never letting a fixed weak value reach a public instance (see the
238
+ ``load_sample_project --with-personas`` DEBUG/env gate, mirroring #1350).
239
+ replace: authorizes tearing down a live program of the caller's that
240
+ already holds this seed's slug (ADR-0726, #2581). ``False`` — the
241
+ default, and the only safe default — raises
242
+ :class:`SeedReplaceRequired` instead, so a re-import can never
243
+ destroy real work that the caller did not name.
244
+ expected_program_id: optional compare-and-swap token. When given it must
245
+ equal the id of the program that would actually be replaced, or
246
+ :class:`SeedReplaceMismatch` is raised. Protects a client acting on a
247
+ dry run from a collision set that moved in between.
248
+ target_program: adopt this pre-created ``Program`` shell instead of
249
+ minting one (ADR-0726 §4). The async path creates the shell inside
250
+ the request — so the destructive replace happens in the request the
251
+ operator authorized, and the caller has somewhere to land — and hands
252
+ it here for the worker to fill. ``None`` keeps the create-it-myself
253
+ behavior the management command and ``load_sample`` rely on.
254
+ adopt_keys_from: ids of the projects the async request moved to Trash
255
+ when it replaced a program. Each rebuilt project takes the keys of
256
+ the trashed one with the same name, inside this import's
257
+ transaction (ADR-1237 §3). Only meaningful with ``target_program``.
258
+
259
+ Raises:
260
+ SeedValidationError: if the payload fails validation; nothing is written.
261
+ SeedReplaceRequired: if a live program collides and ``replace`` is False.
262
+ SeedReplaceMismatch: if ``expected_program_id`` names a different program.
263
+ """
264
+ # Agent actions and share links are evidence and credentials: only the
265
+ # server-curated sample path (``load_sample``, the one caller that passes
266
+ # is_sample) may carry them, the same trust line ``persona_password`` uses.
267
+ validate_seed(payload, allow_sample_sections=is_sample)
268
+ importer = _SeedImporter(
269
+ payload,
270
+ owner=owner,
271
+ create_users=create_users,
272
+ is_sample=is_sample,
273
+ persona_password=persona_password,
274
+ replace=replace,
275
+ expected_program_id=expected_program_id,
276
+ target_program=target_program,
277
+ adopt_keys_from=adopt_keys_from,
278
+ )
279
+ # coalesce_sync_seq draws one delta cursor per project for the whole import
280
+ # instead of one per row (ADR-0686). Rows written together may share a
281
+ # sync_seq — the delta is `> since` and the checkpoint is the maximum, so a
282
+ # batch lands wholly on one side of any client checkpoint.
283
+ with transaction.atomic(), coalesce_sync_seq():
284
+ program = importer.run()
285
+ return program
286
+
287
+
288
+ @dataclass
289
+ class _TaskFields:
290
+ """Fields derived from one seed task's document before the ``Task`` is built.
291
+
292
+ Split out of :meth:`_SeedImporter._build_task` so the derivation (pure,
293
+ read-only) and the ``Task(...)`` construction it feeds are separately
294
+ readable; see that method's callers for the business rules behind each
295
+ field.
296
+ """
297
+
298
+ sprint: Sprint | None
299
+ assignee: Any
300
+ is_milestone: bool
301
+ estimate_fields: dict[str, Any]
302
+ final_status: str
303
+ planned_start: date | None
304
+ story_points: Any
305
+ base_status: str
306
+ base_percent: float
307
+ base_remaining: Any
308
+
309
+
310
+ class _SeedImporter:
311
+ """Holds the per-import symbol tables while materializing a seed document."""
312
+
313
+ def __init__(
314
+ self,
315
+ payload: dict[str, Any],
316
+ *,
317
+ owner: Any,
318
+ create_users: bool,
319
+ is_sample: bool = False,
320
+ persona_password: str | None = None,
321
+ replace: bool = False,
322
+ expected_program_id: str | None = None,
323
+ target_program: Program | None = None,
324
+ adopt_keys_from: list[str] | None = None,
325
+ provenance_kind: str = TaskSource.SEED_IMPORT,
326
+ provenance_source_id: uuid.UUID | None = None,
327
+ ) -> None:
328
+ self.payload = payload
329
+ self.owner = owner
330
+ self.create_users = create_users
331
+ self.is_sample = is_sample
332
+ self.persona_password = persona_password
333
+ self.replace = replace
334
+ self.expected_program_id = expected_program_id
335
+ self.target_program = target_program
336
+ self.adopt_keys_from = list(adopt_keys_from or [])
337
+ #: Seed provenance stamped on every task this run creates (ADR-0786).
338
+ #: Parameterized rather than hard-coded because #2729's template seeding
339
+ #: materializes through this same importer and must record itself as
340
+ #: ``TaskSource.TEMPLATE`` with the template's id, not as a seed import.
341
+ self.provenance_kind = provenance_kind
342
+ self.provenance_source_id = provenance_source_id
343
+ #: Program this run tore down, for the caller's audit record. None when
344
+ #: nothing collided.
345
+ self.replaced_program_id: str | None = None
346
+ #: Key rows released by the program (and, on the sample hard-delete path,
347
+ #: the projects) this run replaced, so the rebuild adopts them instead of
348
+ #: minting suffixed keys (ADR-1237). Project rows are keyed by name.
349
+ self.released_program_keys: list[Any] = []
350
+ self.released_project_keys: dict[str, list[Any]] = {}
351
+ self.users: dict[str, Any] = {}
352
+ self.calendars: dict[str, Calendar] = {}
353
+ self.resources: dict[str, Resource] = {}
354
+ # account slug -> Resource, so a task carrying only ``assignee`` (an
355
+ # ACCOUNT slug) can still be given the TaskResource row that capacity,
356
+ # utilization and the heatmap actually read (#2900).
357
+ self.resources_by_account: dict[str, Resource] = {}
358
+ self.projects: dict[str, Project] = {}
359
+ # global task / sprint indices keyed by (project_slug, local_id)
360
+ self.tasks: dict[tuple[str, str], Task] = {}
361
+ self.sprints: dict[tuple[str, str], Sprint] = {}
362
+ # Label index keyed by (project_slug, label_slug) — ADR-0400 labels folded
363
+ # into the seed (#1958). Populated in Pass A, consumed by the Pass B
364
+ # ``_link_task_labels`` attach step.
365
+ self.labels: dict[tuple[str, str], Label] = {}
366
+ # v2 (ADR-0114): relative dates resolve against an anchor (import day),
367
+ # and an events timeline is replayed with backdated history. v1 docs set
368
+ # the major to "1", so replay is off and dates are plain ISO literals.
369
+ self.replay = str(payload.get("schema_version", "")).split(".")[0] == "2"
370
+ self.anchor: date = resolve_anchor(payload, date.today())
371
+ # (project_slug, wbs_path) -> earliest authored timeline beat naming that
372
+ # task, so a synthesized creation-history date can never land after a
373
+ # comment/status/estimate/etc. the seed itself dated against the task
374
+ # (#3487). Indexed once up front — Pass A builds tasks project by
375
+ # project, but the events timeline is not project-scoped.
376
+ self.earliest_task_event: dict[tuple[str, str], date] = self._index_earliest_task_events()
377
+ # (project_slug, wbs_path) of every task the timeline dates a status move
378
+ # against, so ``_build_task`` can tell a task that is *moved* into the
379
+ # backlog from one that simply lives there (#3488). Indexed here for the
380
+ # same reason as the map above: Pass A is project-scoped, the timeline is
381
+ # not.
382
+ self.authored_status_targets: set[tuple[str, str]] = self._index_authored_status_targets()
383
+ self.working_calendars: dict[str, WorkingCalendar] = {}
384
+ self.risks_by_slug: dict[str, Risk] = {}
385
+ # Desired END states for replay — tasks/sprints are created at a base
386
+ # state and walked forward to these by the timeline + synthesizer.
387
+ self.final_status: dict[tuple[str, str], str] = {}
388
+ self.final_sprint: dict[tuple[str, str], dict[str, Any]] = {}
389
+ # Authored progress for tasks that end IN_PROGRESS or REVIEW, restored
390
+ # by replay's ``_finalize_tasks`` (#3486, widened #3518). Holds only
391
+ # the keys the document wrote.
392
+ self.final_progress: dict[tuple[str, str], dict[str, Any]] = {}
393
+ # (project_slug, wbs_path) -> the task's acceptance criteria in position
394
+ # order (#3492); built in Pass B, handed to replay for task.ac_met ticks.
395
+ self.criteria: dict[tuple[str, str], list[AcceptanceCriterion]] = {}
396
+
397
+ def run(self) -> Program:
398
+ # Adopting a pre-created shell means the caller already resolved and
399
+ # performed the replacement inside the request it was authorized in
400
+ # (ADR-0726 §4). Running the teardown again here would be actively
401
+ # wrong, not merely redundant: the shell itself now carries this seed's
402
+ # slug and the caller owns it, so it would match its own candidate query
403
+ # and soft-delete the program it was handed to fill.
404
+ if self.target_program is None:
405
+ self._replace_existing()
406
+ else:
407
+ self._release_trashed_project_keys()
408
+ self._resolve_accounts()
409
+ self._resolve_calendars()
410
+ self._resolve_resources()
411
+ program = self._create_program()
412
+
413
+ # Pass A: create each project's structure (sprints, tasks). This fills
414
+ # the global task/sprint indices so Pass B can resolve cross-project
415
+ # references and links that may point anywhere in the program.
416
+ for project_data in self.payload["projects"]:
417
+ self._create_project_structure(program, project_data)
418
+
419
+ # Pass B: wire links that may reference tasks created in any project.
420
+ for project_data in self.payload["projects"]:
421
+ project = self.projects[project_data["slug"]]
422
+ self._link_dependencies(project, project_data)
423
+ self._link_parent_epics(project_data)
424
+ self._link_sprint_milestones(project_data)
425
+ self._link_task_labels(project_data)
426
+ self._link_task_relations(project_data["slug"], project_data)
427
+ self._resolve_blockers(project_data)
428
+ self._create_attachments(project_data)
429
+ self._create_acceptance_criteria(project_data)
430
+ self._create_recurrence_rules(project_data)
431
+ self._create_skill_requirements(project_data)
432
+ self._assign_resources(project, project_data)
433
+ self._capture_baselines(project, project_data)
434
+ self._create_risks(project, project_data.get("risks", []), project_data["slug"])
435
+
436
+ self._create_program_risks(program)
437
+ # v2.1 program sections (#3603). After Pass B so a pulled backlog item can
438
+ # link a task in any project.
439
+ self._create_backlog_items(program)
440
+ self._create_ceremonies(program)
441
+
442
+ # Pass C (v2 only): replay the events timeline + synthesized fill so the
443
+ # demo reads as a program that has run for months — backdated history,
444
+ # real burndown/velocity, scope-injection audit. Runs inside this same
445
+ # transaction under the seed_replay flag (side effects suppressed).
446
+ if self.replay:
447
+ replay_timeline(self.payload, self._replay_context())
448
+
449
+ # Sample-only v2.1 passes (#3603), after replay: time fills the *actual*
450
+ # windows replay stamped, submissions read the time just written, and the
451
+ # agent trail and share links reference the finished program. Each method
452
+ # returns immediately off the sample path.
453
+ self._synthesize_time()
454
+ self._synthesize_timesheet_submissions()
455
+ self._create_share_links()
456
+
457
+ # Pass D (#376): backfill ProjectForecastSnapshot history for any project
458
+ # that authored a forecast_history block. Runs after replay so task_count /
459
+ # completed_task_count reflect the final replayed state, and still inside the
460
+ # import transaction so a failure rolls the whole import back (ADR-0211).
461
+ self._backfill_forecast_history()
462
+
463
+ # Last write of the import, deliberately. record_agent_action takes the
464
+ # instance-wide chain-head row lock and holds it to commit, so every live MCP
465
+ # read that must record an audit row waits behind it. Anything written after
466
+ # it would widen that window for no reason.
467
+ self._record_sample_agent_actions()
468
+
469
+ project_ids = [str(p.pk) for p in self.projects.values()]
470
+ # Seeded tasks have no CPM dates; recompute so the schedule renders.
471
+ # Both effects are deferred to post-commit so they never fire on a
472
+ # rolled-back import.
473
+ for pid in project_ids:
474
+ transaction.on_commit(partial(enqueue_recalculate, project_id=pid))
475
+
476
+ # A direct call rather than partial(broadcast_board_event, ...): the WS
477
+ # freeze guard AST-scans for the event-type literal in the argument slot
478
+ # of a ``broadcast_board_event`` call, and under partial the callee name
479
+ # is ``partial``, which makes the literal invisible to it. The default
480
+ # argument pins the loop variable.
481
+ def _emit_created(project_id: str = pid) -> None:
482
+ broadcast_board_event(project_id, "project_created", {"id": project_id})
483
+
484
+ transaction.on_commit(_emit_created)
485
+ return program
486
+
487
+ # --- v2 date resolution + replay --------------------------------------
488
+
489
+ def _build_working_calendar(self, cal: Calendar | None) -> WorkingCalendar:
490
+ """Calendar facts for weekend-snapping: bitmask + materialized exceptions."""
491
+ if cal is None:
492
+ return WorkingCalendar()
493
+ exc: set[date] = set()
494
+ for e in cal.exceptions.all()[:500]:
495
+ d = e.exc_start
496
+ while d <= e.exc_end and len(exc) < 5000:
497
+ exc.add(d)
498
+ d += timedelta(days=1)
499
+ try:
500
+ tz = ZoneInfo(cal.timezone) if cal.timezone else ZoneInfo("UTC")
501
+ except Exception:
502
+ tz = ZoneInfo("UTC")
503
+ return WorkingCalendar(working_days=cal.working_days, exception_dates=frozenset(exc), tz=tz)
504
+
505
+ def _wc(self, project_slug: str | None) -> WorkingCalendar:
506
+ if project_slug is None:
507
+ return WorkingCalendar()
508
+ return self.working_calendars.get(project_slug) or WorkingCalendar()
509
+
510
+ def _date(self, value: str, project_slug: str | None, *, snap: bool = True) -> date:
511
+ """Resolve a required seed date. v1 ISO literals pass straight through.
512
+
513
+ ``snap`` forward to the next working day is right for *plan* dates, which
514
+ cannot legitimately fall on a non-working day. Pass ``snap=False`` for a
515
+ date recording something that already happened: history is not confined
516
+ to working days, and snapping it forward misstates when it occurred.
517
+ """
518
+ return resolve_date(value, anchor=self.anchor, calendar=self._wc(project_slug), snap=snap)
519
+
520
+ @staticmethod
521
+ def _blocker_reason(blocked: dict[str, Any] | None) -> str:
522
+ """Reason text for a task's ``blocked`` cluster; ``""`` when not blocked."""
523
+ if not blocked:
524
+ return ""
525
+ return str(blocked.get("reason") or "").strip() or _PRIVATE_BLOCKER_REASON
526
+
527
+ def _date_opt(
528
+ self, value: str | None, project_slug: str | None, *, snap: bool = True
529
+ ) -> date | None:
530
+ return self._date(value, project_slug, snap=snap) if value else None
531
+
532
+ def _creation_dt(self, when: date) -> datetime:
533
+ """A backdated creation timestamp (UTC 09:00) for replay history rows."""
534
+ return datetime(when.year, when.month, when.day, 9, 0, tzinfo=ZoneInfo("UTC"))
535
+
536
+ def _index_earliest_task_events(self) -> dict[tuple[str, str], date]:
537
+ """Earliest ``task:<project>:<wbs>`` beat in the authored events timeline.
538
+
539
+ Only the target parsing matters here (mirrors ``replay._resolve_task``);
540
+ the action doesn't — a comment, an assignment, a status move, all of them
541
+ are things that happened *to* the task, so all of them bound how early
542
+ its own creation row is allowed to be. A seed that references a task
543
+ before it declares a plan for it (comment predates any baseline) still
544
+ gets a consistent, in-order history: the creation row simply follows the
545
+ earliest thing anyone said about it.
546
+ """
547
+ earliest: dict[tuple[str, str], date] = {}
548
+ for event in self.payload.get("events", []):
549
+ target = event.get("target", "")
550
+ if not target.startswith("task:"):
551
+ continue
552
+ _, _, ref = target.partition(":")
553
+ project_slug, _, wbs = ref.partition(":")
554
+ when = resolve_timestamp(event["at"], anchor=self.anchor).date()
555
+ key = (project_slug, wbs)
556
+ if key not in earliest or when < earliest[key]:
557
+ earliest[key] = when
558
+ return earliest
559
+
560
+ def _index_authored_status_targets(self) -> set[tuple[str, str]]:
561
+ """Tasks the authored events timeline moves with a ``task.status`` beat.
562
+
563
+ Unlike :meth:`_index_earliest_task_events`, the action *is* the point
564
+ here: only a dated column move tells ``_build_task`` that a task's final
565
+ status is somewhere the timeline walks it to, rather than where it was
566
+ born. The literal action key is repeated rather than imported from
567
+ ``replay._TASK_STATUS_ACTION`` — it is private to that module, and the
568
+ codebase already restates it per-module (see ``validation``'s
569
+ ``_EVENT_TARGET_KIND``).
570
+ """
571
+ targets: set[tuple[str, str]] = set()
572
+ for event in self.payload.get("events", []):
573
+ if event.get("action") != "task.status":
574
+ continue
575
+ target = event.get("target", "")
576
+ if not target.startswith("task:"):
577
+ continue
578
+ _, _, ref = target.partition(":")
579
+ project_slug, _, wbs = ref.partition(":")
580
+ targets.add((project_slug, wbs))
581
+ return targets
582
+
583
+ # --- batched inserts (ADR-0726 §8) -------------------------------------
584
+
585
+ @staticmethod
586
+ def _short_id_block(project_id: Any, count: int) -> list[str]:
587
+ """Reserve ``count`` consecutive ``short_id`` values for one project.
588
+
589
+ ``Task.save()`` / ``Sprint.save()`` allocate one at a time from
590
+ ``Project.object_sequence`` (an ``UPDATE … + 1`` plus a read-back, so two
591
+ round-trips *per row*). Batched inserts bypass ``save()`` entirely, and a
592
+ row that skipped the allocation would carry ``short_id = ""`` — which the
593
+ ``unique_(task|sprint)_short_id_per_project`` constraints turn into an
594
+ IntegrityError on the second row.
595
+
596
+ Advancing the counter by ``count`` in one statement reserves the whole
597
+ block atomically: the read-back is the last value, so the block is
598
+ ``[new - count + 1, new]``. Two round-trips per batch instead of two per
599
+ row, and the values stay in the same hex format and the same shared
600
+ Task/Sprint/Risk sequence as the per-row path.
601
+ """
602
+ if count <= 0:
603
+ return []
604
+ Project.objects.filter(pk=project_id).update(object_sequence=F("object_sequence") + count)
605
+ last: int = Project.objects.values_list("object_sequence", flat=True).get(pk=project_id)
606
+ return [f"{seq:08X}" for seq in range(last - count + 1, last + 1)]
607
+
608
+ def _bulk_insert(
609
+ self,
610
+ model: type[Any],
611
+ rows: list[Any],
612
+ *,
613
+ project_ids: list[Any] | None = None,
614
+ history_dates: list[date] | None = None,
615
+ ) -> None:
616
+ """Insert ``rows`` in one round-trip while preserving what ``save()`` does.
617
+
618
+ ``bulk_create`` skips three things a ``VersionedModel.save()`` performs,
619
+ and every one of them fails *silently* rather than loudly:
620
+
621
+ 1. ``server_version = 1`` on the INSERT branch. ``sync/conflict.py``
622
+ slices ``current - base_version`` history rows for the ADR-0217
623
+ field-level merge, so a row at version 0 is arithmetically broken.
624
+ 2. The ``sync_seq`` delta cursor (ADR-0686). A row that never allocates
625
+ stays at 0, and ``sync_seq__gt=since`` never returns it — *not even on
626
+ a cold start with ``since=0``*. The rows would be permanently
627
+ invisible to every offline client. One value is drawn per project and
628
+ shared across the batch, which ``coalesce_sync_seq``'s contract
629
+ explicitly permits.
630
+ 3. The ``simple_history`` row, written here by
631
+ ``bulk_create_with_history`` for models that record history.
632
+
633
+ Args:
634
+ model: the concrete model being inserted.
635
+ rows: instances to insert. Empty is a no-op.
636
+ project_ids: owning projects for the cursor draw. ``None`` means the
637
+ model is outside the sync union (no cursor, no history) and a
638
+ plain ``bulk_create`` is correct.
639
+ history_dates: parallel to ``rows``; the backdated ``history_date``
640
+ for each. Used only under v2 replay, where a task's creation row
641
+ is dated to when the entity came into being rather than to import
642
+ time. Rows are grouped by date so the number of INSERT batches is
643
+ bounded by the distinct dates (the sprint count), not the rows.
644
+ """
645
+ if not rows:
646
+ return
647
+ if project_ids is None:
648
+ model.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
649
+ return
650
+
651
+ seq = allocate_for_projects(list(project_ids))
652
+ for row in rows:
653
+ row.server_version = 1
654
+ row.sync_seq = seq
655
+
656
+ # Keyed on the row's own pk, not on ``id(row)``: every model here has a UUID
657
+ # primary key assigned at construction, so this survives a caller that hands
658
+ # the insert a re-materialized instance rather than the identical object.
659
+ date_by_row: dict[Any, date] = (
660
+ {row.pk: when for row, when in zip(rows, history_dates, strict=True)}
661
+ if history_dates is not None
662
+ else {}
663
+ )
664
+
665
+ def _insert(batch: Any) -> None:
666
+ """The INSERT itself, over whatever slice of ``rows`` it is handed.
667
+
668
+ Taken as a closure so the Task path can wrap it (see below) without the
669
+ declaration pass having to know which of the three insert shapes applies.
670
+ """
671
+ batch = list(batch)
672
+ if not hasattr(model, "history"):
673
+ model.objects.bulk_create(batch, batch_size=_BULK_BATCH_SIZE)
674
+ return
675
+ if not date_by_row:
676
+ bulk_create_with_history(
677
+ batch, model, batch_size=_BULK_BATCH_SIZE, default_user=self.owner
678
+ )
679
+ return
680
+ by_date: dict[date, list[Any]] = defaultdict(list)
681
+ for row in batch:
682
+ by_date[date_by_row[row.pk]].append(row)
683
+ for when, group in by_date.items():
684
+ bulk_create_with_history(
685
+ group,
686
+ model,
687
+ batch_size=_BULK_BATCH_SIZE,
688
+ default_user=self.owner,
689
+ default_date=self._creation_dt(when),
690
+ )
691
+
692
+ if model is Task:
693
+ # A seed pack has phases, and `bulk_create` reaches none of the
694
+ # declaration that `Task.save()` paths run — so without this a seeded
695
+ # phase lands `structure_role='work'` with children and the Board
696
+ # renders it as a card (#3030). Routed through the same funnel as the
697
+ # importers rather than re-derived here.
698
+ bulk_create_tasks(rows, insert=_insert)
699
+ return
700
+ _insert(rows)
701
+
702
+ def _save_new(self, instance: Any, created_on: date) -> Any:
703
+ """Insert ``instance``, backdating its creation history row under replay.
704
+
705
+ Under v2 replay the creation row is dated to when the entity came into
706
+ being (its window/sprint start), not import time, so the History tab and
707
+ activity timeline read chronologically. v1 import saves normally.
708
+ """
709
+ if self.replay:
710
+ instance._history_date = self._creation_dt(created_on)
711
+ instance._history_user = self.owner
712
+ instance.save()
713
+ return instance
714
+
715
+ def _replay_context(self) -> ReplayContext:
716
+ lead_project = next(iter(self.projects.values()), None)
717
+ try:
718
+ tz = ZoneInfo(getattr(lead_project, "timezone", "") or "UTC")
719
+ except Exception:
720
+ tz = ZoneInfo("UTC")
721
+ return ReplayContext(
722
+ anchor=self.anchor,
723
+ program_code=self.payload["program"]["slug"],
724
+ default_actor=self.owner,
725
+ users=self.users,
726
+ tasks=self.tasks,
727
+ sprints=self.sprints,
728
+ projects=self.projects,
729
+ project_calendars=self.working_calendars,
730
+ risks=self.risks_by_slug,
731
+ final_status=self.final_status,
732
+ final_sprint=self.final_sprint,
733
+ final_progress=self.final_progress,
734
+ tz=tz,
735
+ criteria=self.criteria,
736
+ )
737
+
738
+ # --- idempotency -------------------------------------------------------
739
+
740
+ def _replace_existing(self) -> None:
741
+ """Tear down a prior import the caller owns that holds this seed's slug.
742
+
743
+ Re-import rebuilds the *caller's own* program with this slug (keyed on
744
+ ``Program.code``, which carries it). Three guards, in order of how badly
745
+ each one's absence would hurt:
746
+
747
+ **Ownership (#994).** Candidates come from
748
+ :func:`~trueppm_api.apps.projects.seed.replace.resolve_replace_candidates`,
749
+ which restricts them to programs the importing ``owner`` holds a live
750
+ OWNER ``ProgramMembership`` on. ``Program.code`` is user-assigned and
751
+ non-unique, so collisions between strangers are realistic and enumerable;
752
+ without this scope any authenticated user could destroy a victim's
753
+ program and every child project/task/sprint/risk/baseline by crafting a
754
+ seed whose ``slug`` matches the victim's code. ``select_for_update``
755
+ locks each candidate so a concurrent member-add or project-assign cannot
756
+ resurrect a ``PROTECT``-ed reference mid-teardown.
757
+
758
+ **Consent (#2581, ADR-0726).** Even against the caller's own data the
759
+ teardown is refused unless ``replace`` was explicitly given, and
760
+ ``expected_program_id`` — when supplied — must name the program that
761
+ would actually go. Both refusals raise *before* the first write.
762
+
763
+ **Sample scoping (#2476).** On the demo path (``is_sample``) a program
764
+ containing any real (non-sample) project is never replaced, so a sample
765
+ reload cannot purge real work even within the caller's own programs.
766
+
767
+ Non-sample programs are **soft**-deleted: the subtree lands in Trash,
768
+ offline clients receive tombstones, and each removed project emits a
769
+ ``project_deleted`` broadcast. Sample programs keep the hard delete —
770
+ disposable demo data is what it is for — but route through
771
+ ``hard_delete_program``, which resolves the ``PROTECT``-ing set from
772
+ ``_meta`` rather than the hand-written list that rotted in #2364.
773
+ """
774
+ from trueppm_api.apps.access.services import hard_delete_program
775
+ from trueppm_api.apps.projects.services import (
776
+ release_keys_for_replace,
777
+ soft_delete_program_subtree,
778
+ )
779
+
780
+ slug = self.payload["program"]["slug"]
781
+ candidates = resolve_replace_candidates(self.owner, slug, lock=True)
782
+ if self.is_sample:
783
+ # A mixed program (real projects under a sample slug) is never torn
784
+ # down. Filtering here rather than inside the loop keeps the consent
785
+ # check below from refusing on a candidate we would have skipped.
786
+ candidates = [
787
+ prog
788
+ for prog in candidates
789
+ if not Project.objects.filter(
790
+ program=prog, is_sample=False, is_deleted=False
791
+ ).exists()
792
+ ]
793
+ if not candidates:
794
+ return
795
+
796
+ # Narrow to the named program before consenting, so the set the caller
797
+ # authorized and the set actually torn down are the same one.
798
+ # ``Program.code`` is non-unique by design, so a caller can legitimately
799
+ # own two programs under this slug; ``SeedReplaceRequired`` can only
800
+ # carry one, and destroying the rest would be exactly the unconsented
801
+ # deletion this whole gate exists to stop.
802
+ if self.expected_program_id is not None:
803
+ named = [p for p in candidates if str(p.pk) == str(self.expected_program_id)]
804
+ if not named:
805
+ raise SeedReplaceMismatch(
806
+ "expected_program_id does not name the program that would be replaced."
807
+ )
808
+ candidates = named
809
+
810
+ if not self.replace:
811
+ raise SeedReplaceRequired(candidates[0])
812
+ if len(candidates) > 1:
813
+ raise SeedReplaceAmbiguous(
814
+ f"You own {len(candidates)} live programs using the code "
815
+ f"{slug!r}. Pass expected_program_id to name the one to replace."
816
+ )
817
+
818
+ target = candidates[0]
819
+ self.replaced_program_id = str(target.pk)
820
+ # The program shell is unrecoverable on both paths, so its keys move to the
821
+ # rebuild — the same program to its owner, with its slug and old links
822
+ # intact (ADR-1237). Released before the teardown locks and saves the row.
823
+ self.released_program_keys = release_keys_for_replace(target)
824
+ # Each replaced project's keys move to the rebuilt project of the same
825
+ # name, so a re-import reproduces the keys (and the #616 round trip stays
826
+ # byte-identical) instead of minting PLAT2. On the soft path the trashed
827
+ # original is left without a key; restoring it derives a fresh one
828
+ # (``ProjectViewSet.restore``), so a restore still never collides.
829
+ for doomed_project in Project.objects.filter(program=target, is_deleted=False):
830
+ self.released_project_keys.setdefault(doomed_project.name, []).extend(
831
+ release_keys_for_replace(doomed_project)
832
+ )
833
+ if self.is_sample:
834
+ # A hard delete leaves no tombstone, so the project_hard_deleted
835
+ # broadcast is the only signal a client holding these rows will ever
836
+ # get (the #2581 defect, on the branch ADR-0726 deliberately left
837
+ # hard). broadcast=True makes hard_delete_program schedule one per
838
+ # deleted project (#4374) — mirrors ``remove_sample``, which routes
839
+ # through the same helper.
840
+ program_id = str(target.pk)
841
+ hard_delete_program(target.pk, broadcast=True)
842
+ transaction.on_commit(
843
+ lambda: broadcast_board_event(program_id, "program_deleted", {"id": program_id})
844
+ )
845
+ else:
846
+ soft_delete_program_subtree(target, actor=self.owner, reason="seed_replace")
847
+
848
+ def _release_trashed_project_keys(self) -> None:
849
+ """Take back the keys of the projects the async request trashed (#4150).
850
+
851
+ The async replace runs in the request, but the rebuild runs here, so the
852
+ request leaves the trashed originals holding their keys and hands over
853
+ only their ids. Releasing them now, inside the build's transaction, is
854
+ what makes the hand-over safe: a failed build rolls the release back and
855
+ every original keeps its key, and a duplicate delivery never gets this
856
+ far (the job claim). Each released set is keyed by name, like the sync
857
+ path, and is adopted by the rebuilt project of the same name.
858
+
859
+ Only projects still in Trash and still detached qualify — one the owner
860
+ restored or re-parented in the meantime is live again and keeps its key.
861
+ A trashed project whose name the document no longer carries keeps its
862
+ key too, since nothing would adopt it.
863
+ """
864
+ from trueppm_api.apps.projects.services import release_keys_for_replace
865
+
866
+ if not self.adopt_keys_from:
867
+ return
868
+ names = {p["name"] for p in self.payload["projects"]}
869
+ for original in (
870
+ Project.objects.select_for_update()
871
+ .filter(
872
+ pk__in=self.adopt_keys_from,
873
+ is_deleted=True,
874
+ program__isnull=True,
875
+ name__in=names,
876
+ )
877
+ .order_by("pk")
878
+ ):
879
+ # Two trashed projects sharing a name would hand one rebuild two
880
+ # current keys; the first keeps the hand-over, the other its key.
881
+ if original.name in self.released_project_keys:
882
+ continue
883
+ self.released_project_keys[original.name] = release_keys_for_replace(original)
884
+
885
+ # --- top-level entities ------------------------------------------------
886
+
887
+ def _resolve_accounts(self) -> None:
888
+ for account in self.payload.get("accounts", []):
889
+ username = account["username"]
890
+ user = User.objects.filter(username=username).first()
891
+ if user is None and self.create_users:
892
+ # ``password`` is only non-None on the server-curated sample path
893
+ # when the operator opted into loginable personas via
894
+ # ``--with-personas`` (#1760); ``None`` yields an unusable password
895
+ # (create_user's default), so no dormant weak login is ever minted
896
+ # implicitly.
897
+ user = User.objects.create_user(
898
+ username=username,
899
+ email=account.get("email", ""),
900
+ first_name=account.get("display_name", "").split(" ")[0],
901
+ password=self.persona_password if self.is_sample else None,
902
+ )
903
+ elif user is not None:
904
+ self._enable_existing_persona_login(user)
905
+ # #1057: on the generic import path (create_users=False, the REST
906
+ # default) a seed's accounts[].username is attacker-controlled and may
907
+ # collide with a *pre-existing* real user. Binding that user here would
908
+ # let a crafted seed pull a known victim into the importer's program —
909
+ # as a ProgramMembership (_grant_program_memberships), the program lead,
910
+ # a task assignee, or a resource's user FK. Resolve such accounts to
911
+ # None so the generic path never associates a real account it did not
912
+ # create. The owner is exempt (their own account), and the server-
913
+ # curated sample/demo path (is_sample) binds personas freely.
914
+ if (
915
+ user is not None
916
+ and not self.create_users
917
+ and not self.is_sample
918
+ and user != self.owner
919
+ ):
920
+ user = None
921
+ self.users[account["slug"]] = user
922
+
923
+ def _enable_existing_persona_login(self, user: Any) -> None:
924
+ """Give a *pre-existing* persona account the requested demo password (#3484).
925
+
926
+ Loading a sample is idempotent, so the second run is the common one: the
927
+ persona rows already exist from an earlier plain ``load_sample_project``
928
+ (or from the demo compose stack), and only the *creation* branch above ever
929
+ applied ``persona_password``. ``--with-personas`` therefore printed
930
+ "Persona logins enabled" over fifteen accounts whose ``has_usable_password()``
931
+ was still False and whose ``POST /auth/token/`` still 401'd — the command
932
+ asserted a state it had not produced.
933
+
934
+ Two conditions bound this, and they are what keep it out of the #1057
935
+ account-hijack case:
936
+
937
+ * ``is_sample`` — the usernames come from a **server-curated** bundled
938
+ fixture, not from a caller-supplied seed. ``persona_password`` is only
939
+ ever non-None on that path (the REST ``load-sample`` action never passes
940
+ it), so no caller can steer which username gets a password.
941
+ * the account must have **no usable password of its own**. That is the
942
+ discriminator for "the importer minted this row", since an interactively
943
+ registered account always carries one. Nothing a real user set is ever
944
+ overwritten, which is the invariant
945
+ ``test_persona_password_never_repasswords_existing_user`` pins.
946
+
947
+ Privileged accounts are refused outright even when unusable: handing a
948
+ known password to a staff/superuser row would be a full takeover, and the
949
+ sample's OWNER persona can legitimately be the program owner on a stack
950
+ with no superuser (``_resolve_owner``'s third fallback).
951
+
952
+ The residual the two conditions do not cover: an **SSO-provisioned** account
953
+ also carries an unusable password (``sso.services`` calls
954
+ ``set_unusable_password`` on JIT signup), so one holding a username that
955
+ collides with a sample persona slug would be given a local password here.
956
+ It is accepted rather than guarded, because the preconditions are narrow —
957
+ an operator explicitly running ``--with-personas``, on an SSO instance,
958
+ where a federated user holds a ``<sample-key>-<name>`` username — and
959
+ because the command now names every account it enabled, so the operator
960
+ sees it rather than being told fifteen logins work.
961
+ """
962
+ if not (self.is_sample and self.create_users and self.persona_password):
963
+ return
964
+ if user.is_staff or user.is_superuser or user.has_usable_password():
965
+ return
966
+ # Seeded demo persona, not an interactive signup, so password validators
967
+ # do not apply — the value is already gated behind DEBUG/env by the caller.
968
+ # nosemgrep: unvalidated-password
969
+ user.set_password(self.persona_password)
970
+ user.save(update_fields=["password"])
971
+
972
+ def _resolve_calendars(self) -> None:
973
+ for cal in self.payload.get("calendars", []):
974
+ defaults = {
975
+ "working_days": cal.get("working_days", 31),
976
+ "hours_per_day": cal.get("hours_per_day", 8.0),
977
+ "timezone": cal.get("timezone", "UTC"),
978
+ }
979
+ if self.is_sample:
980
+ # Calendar.name carries no uniqueness constraint, so a plain
981
+ # get_or_create raises MultipleObjectsReturned (unhandled 500) when
982
+ # the shared catalog already holds two calendars of this name — the
983
+ # #2267 bug class. Reuse the first match and only create when none
984
+ # exists. Server-curated bundled samples are the one caller allowed
985
+ # to bind the shared catalog by name (see #4369 below).
986
+ obj = Calendar.objects.filter(
987
+ name=cal["name"], is_deleted=False
988
+ ).first() or Calendar.objects.create(name=cal["name"], **defaults)
989
+ else:
990
+ # Generic import (#4369): never resolve a calendar against the
991
+ # shared/global catalog by name. Calendar.name has no uniqueness
992
+ # constraint and a Calendar is reachable by any authenticated
993
+ # caller's project (CalendarViewSet's queryset is unscoped) — so
994
+ # matching by name would let an attacker-crafted seed bind a real
995
+ # calendar (e.g. the workspace default) and have
996
+ # ``_resolve_calendar_exceptions`` inject non-working exceptions
997
+ # into it, corrupting every project's CPM dates on its next
998
+ # recalc. Mirrors the #1004 fix already shipped for resources:
999
+ # always create a fresh row on the generic path.
1000
+ obj = Calendar.objects.create(name=cal["name"], **defaults)
1001
+ self.calendars[cal["slug"]] = obj
1002
+ self._resolve_calendar_exceptions(obj, cal.get("exceptions", []))
1003
+
1004
+ def _resolve_calendar_exceptions(
1005
+ self, calendar: Calendar, exceptions: list[dict[str, Any]]
1006
+ ) -> None:
1007
+ """Materialize a calendar's non-working ranges (holidays, PTO) — #376.
1008
+
1009
+ Exceptions resolve against the import anchor (relative dates land exactly —
1010
+ a holiday is a fact, never weekend-snapped) and are created idempotently on
1011
+ ``(calendar, exc_start, exc_end)``. Calendars are a shared global catalog
1012
+ that ``_replace_existing`` does not wipe on a sample reload, so get_or_create
1013
+ keeps a same-day reload from accumulating duplicate PTO rows (a reload on a
1014
+ *different* day is the stale-anchor case below). Materializing them here
1015
+ (before the per-project structure pass) means ``_build_working_calendar``
1016
+ picks them up, so a PTO range also removes those days from date snapping —
1017
+ one source of non-working truth (ADR-0211).
1018
+
1019
+ **Stale anchors are reconciled away (#4339).** The idempotency key is the
1020
+ *resolved* range, and a relative range resolves differently on every import
1021
+ day, so a reload on a later day used to add a second "Clara — PTO" row
1022
+ beside the first instead of matching it. Because the calendar outlives
1023
+ the program teardown, a daily ``demo-reset`` grew each authored range into
1024
+ a wall of stale non-working days — dozens of rows a few days apart — and
1025
+ ``snap_forward`` pushed a sprint's start *and* finish across that wall onto
1026
+ the same working day, tripping ``sprint_finish_after_start``. The CPM run
1027
+ that follows reads the same rows, so every task date drifted too. A row on
1028
+ this calendar carrying a description this payload authors is therefore the
1029
+ same authored exception at an older anchor, and is replaced rather than
1030
+ kept. Only a row matching an authored exception on description *and* span
1031
+ length (an anchor shift moves a range, it never resizes it) counts as
1032
+ stale. Rows the payload does not author (or that a later payload dropped)
1033
+ are left alone.
1034
+
1035
+ The cleanup deletes rows, so it is fenced twice, because the catalog is
1036
+ matched by calendar *name* and a seed can therefore land on a real
1037
+ calendar that merely shares one:
1038
+
1039
+ - **Bundled samples only** (``is_sample``). A sample's calendar names and
1040
+ descriptions are server-curated; a generic ``import_seed`` payload is
1041
+ caller-authored. Before #4339 a seed could only ever *add* rows to a
1042
+ shared calendar; since #4369, ``_resolve_calendars`` never resolves a
1043
+ calendar by name on the generic path at all — the ``calendar`` this
1044
+ method receives there is always one this import itself just created, so
1045
+ a caller-authored payload can no longer add rows to a shared calendar
1046
+ either. Letting any authenticated caller delete rows by naming a real
1047
+ calendar would widen what the looser import gate can destroy, which is
1048
+ why this cleanup stays fenced to ``is_sample`` even though the sibling
1049
+ risk it once guarded against on the generic path is now closed upstream.
1050
+ - **Never a calendar in real use** (:meth:`_calendar_in_real_use`): the
1051
+ workspace default, any program's, any non-sample project's primary
1052
+ calendar or calendar layer, or one a resource with real assignments
1053
+ works on. Those exceptions are someone's actual holidays — the same
1054
+ rule ``reanchor`` follows for not shifting a shared calendar's
1055
+ exceptions. A sample landing there only adds rows, as it always did.
1056
+
1057
+ What remains unprotected is a same-named calendar nothing real uses (see
1058
+ :meth:`_calendar_in_real_use` for the one gap in "uses"). There the guard
1059
+ is the row match itself — same description *and* same span as an
1060
+ exception this pack authors — not the calendar's name; a pack-specific
1061
+ calendar name only makes the collision less likely.
1062
+ """
1063
+ authored = [
1064
+ (
1065
+ resolve_date(exc["exc_start"], anchor=self.anchor, snap=False),
1066
+ resolve_date(exc["exc_end"], anchor=self.anchor, snap=False),
1067
+ exc.get("description", ""),
1068
+ )
1069
+ for exc in exceptions
1070
+ ]
1071
+ if self.is_sample:
1072
+ # Lock the calendar row before deciding. Any concurrent write that points
1073
+ # a project, layer, program, resource or the workspace at this calendar
1074
+ # takes a FOR KEY SHARE lock on it, which conflicts with FOR UPDATE — so
1075
+ # nothing can start using the calendar between the check and the delete.
1076
+ Calendar.objects.select_for_update().filter(pk=calendar.pk).first()
1077
+ if not self._calendar_in_real_use(calendar):
1078
+ self._delete_stale_exceptions(calendar, authored)
1079
+ for start, end, description in authored:
1080
+ # (calendar, exc_start, exc_end) is the intended idempotency key but has
1081
+ # no DB uniqueness constraint, so a plain get_or_create raises
1082
+ # MultipleObjectsReturned once two identical ranges exist (#2267 class).
1083
+ # Reuse the first match; only create when the range is absent.
1084
+ exists = CalendarException.objects.filter(
1085
+ calendar=calendar, exc_start=start, exc_end=end
1086
+ ).exists()
1087
+ if not exists:
1088
+ CalendarException.objects.create(
1089
+ calendar=calendar,
1090
+ exc_start=start,
1091
+ exc_end=end,
1092
+ description=description,
1093
+ )
1094
+
1095
+ @staticmethod
1096
+ def _calendar_in_real_use(calendar: Calendar) -> bool:
1097
+ """Whether anything other than sample data schedules against ``calendar``.
1098
+
1099
+ Runs after ``_replace_existing``, so the sample being reloaded has already
1100
+ been torn down; another user's live *sample* program is not real use (it
1101
+ is the same disposable data, and benefits from the cleanup too). A
1102
+ soft-deleted real project still counts — it can be restored.
1103
+
1104
+ Every reverse relation to ``Calendar`` is consulted except one, on
1105
+ purpose: a ``Resource`` on this calendar with no assignment to real work
1106
+ does not count, so a real person whose calendar shares a sample
1107
+ calendar's name, and who has not been assigned anything yet, is not
1108
+ protected. Counting every resource would disable the cleanup outright —
1109
+ the sample's own persona resources outlive the teardown on these same
1110
+ calendars. ``test_calendar_reverse_relations_are_all_considered`` pins
1111
+ the relation set so a new FK to ``Calendar`` has to be decided here.
1112
+ """
1113
+ from trueppm_api.apps.workspace.models import Workspace
1114
+
1115
+ return (
1116
+ Workspace.objects.filter(calendar=calendar).exists()
1117
+ or calendar.programs.exists()
1118
+ or calendar.projects.filter(is_sample=False).exists()
1119
+ or calendar.applied_to_layers.filter(project__is_sample=False).exists()
1120
+ or TaskResource.objects.filter(
1121
+ resource__calendar=calendar, task__project__is_sample=False
1122
+ ).exists()
1123
+ )
1124
+
1125
+ @staticmethod
1126
+ def _delete_stale_exceptions(
1127
+ calendar: Calendar, authored: list[tuple[date, date, str]]
1128
+ ) -> None:
1129
+ """Delete this seed's own exceptions left behind by an older anchor."""
1130
+ current = set(authored)
1131
+ authored_shapes = {(desc, end - start) for start, end, desc in authored if desc}
1132
+ stale_ids = [
1133
+ row.pk
1134
+ for row in CalendarException.objects.filter(
1135
+ calendar=calendar, description__in={desc for desc, _ in authored_shapes}
1136
+ )
1137
+ if (row.description, row.exc_end - row.exc_start) in authored_shapes
1138
+ and (row.exc_start, row.exc_end, row.description) not in current
1139
+ ]
1140
+ if stale_ids:
1141
+ CalendarException.objects.filter(pk__in=stale_ids).delete()
1142
+
1143
+ def _resolve_resources(self) -> None:
1144
+ for res in self.payload.get("resources", []):
1145
+ calendar = self.calendars.get(res["calendar"]) if res.get("calendar") else None
1146
+ # account_user is already None on the generic path for any account that
1147
+ # resolved to a pre-existing real user — _resolve_accounts drops those
1148
+ # (#1057), so the resource's user FK cannot bind a victim here.
1149
+ account_user = self.users.get(res["account"]) if res.get("account") else None
1150
+ defaults = {
1151
+ "name": res["name"],
1152
+ "email": res.get("email", ""),
1153
+ "job_role": res.get("job_role", ""),
1154
+ "max_units": res.get("max_units", 1.0),
1155
+ "calendar": calendar,
1156
+ "user": account_user,
1157
+ }
1158
+ if self.is_sample:
1159
+ # Demo path: resources are a global catalog and have no slug
1160
+ # column; reuse the shared persona rows by email (else name) so a
1161
+ # sample reload does not accumulate duplicate demo people.
1162
+ #
1163
+ # Neither ``email`` nor ``name`` is unique, and the generic branch
1164
+ # below creates fresh rows unconditionally — so the catalog can
1165
+ # legitimately already hold two rows matching this lookup. A plain
1166
+ # ``get_or_create`` would then raise ``MultipleObjectsReturned`` (an
1167
+ # unhandled 500 the nightly fuzzer hit on a generic-import-then-
1168
+ # sample-load sequence, #2267). Reuse the first existing match and
1169
+ # only create when none exists, which keeps the dedup intent while
1170
+ # tolerating a pre-existing duplicate.
1171
+ lookup = {"email": res["email"]} if res.get("email") else {"name": res["name"]}
1172
+ obj = Resource.objects.filter(
1173
+ is_deleted=False, **lookup
1174
+ ).first() or Resource.objects.create(**defaults)
1175
+ else:
1176
+ # Generic import (#1004): never match a live global resource by
1177
+ # email. Doing so would bind a pre-existing resource — and the
1178
+ # real ``user`` FK it may carry — into the importer's project via
1179
+ # ``_assign_resources``. Create a fresh row so an attacker-crafted
1180
+ # seed cannot pull a real user's resource into their program.
1181
+ obj = Resource.objects.create(**defaults)
1182
+ self.resources[res["slug"]] = obj
1183
+ account = res.get("account")
1184
+ if account:
1185
+ self.resources_by_account[account] = obj
1186
+ self._assign_resource_skills(obj, res.get("skills", []))
1187
+
1188
+ def _assign_resource_skills(self, resource: Resource, skills: list[dict[str, Any]]) -> None:
1189
+ """Attach a resource's skills from the workspace catalog (#3498).
1190
+
1191
+ ``Skill`` is de-duplicated on the case-folded name — the normalization
1192
+ ``SkillSerializer.create`` applies — so "Python" authored by two samples lands
1193
+ on one catalog row. On the sample path a resource is reused across reloads,
1194
+ so an existing ``ResourceSkill`` is updated in place rather than duplicated.
1195
+ """
1196
+ for entry in skills:
1197
+ name = entry["name"].strip()
1198
+ skill, _ = Skill.objects.get_or_create(
1199
+ normalized_name=name.casefold(),
1200
+ defaults={"name": name, "category": entry.get("category", "")},
1201
+ )
1202
+ proficiency = _PROFICIENCY_BY_NAME[entry.get("proficiency", "intermediate")]
1203
+ row = ResourceSkill.objects.filter(
1204
+ resource=resource, skill=skill, is_deleted=False
1205
+ ).first()
1206
+ if row is None:
1207
+ ResourceSkill.objects.create(
1208
+ resource=resource, skill=skill, proficiency=proficiency
1209
+ )
1210
+ elif row.proficiency != proficiency:
1211
+ row.proficiency = proficiency
1212
+ row.save(update_fields=["proficiency"])
1213
+
1214
+ def _create_program(self) -> Program:
1215
+ """Mint the program, or adopt the shell the caller pre-created (ADR-0726 §4).
1216
+
1217
+ The async path creates the shell inside the request — so the replace it
1218
+ may have performed stays in the request the operator authorized, and the
1219
+ 202 can name a program to land on — then hands it here for the worker to
1220
+ fill. Either way the seed's display fields and memberships are applied
1221
+ identically, so the two paths cannot produce different programs.
1222
+ """
1223
+ data = self.payload["program"]
1224
+ if self.target_program is not None:
1225
+ program = self.target_program
1226
+ # Re-apply the document's own fields over the shell. The view builds
1227
+ # the shell from this same payload, so this is normally a no-op — but
1228
+ # asserting it here means the adopted program is defined by the seed,
1229
+ # not by whatever the view happened to pass.
1230
+ program.name = data["name"]
1231
+ program.description = data.get("description", "")
1232
+ program.methodology = data["methodology"]
1233
+ else:
1234
+ program = create_program(
1235
+ name=data["name"],
1236
+ description=data.get("description", ""),
1237
+ methodology=data["methodology"],
1238
+ created_by=self.owner,
1239
+ # The importer assigns the key itself, below.
1240
+ code=None,
1241
+ )
1242
+ # Persist the slug as the natural key + carry display fields.
1243
+ self._assign_program_key(program, data["slug"])
1244
+ if data.get("color"):
1245
+ program.color = data["color"]
1246
+ lead = self.users.get(data["lead"]) if data.get("lead") else None
1247
+ if lead is not None:
1248
+ program.lead = lead
1249
+ # Per-scope Monte Carlo forecast-history overrides (#3494, ADR-0144).
1250
+ # Both are NULL-means-inherit on the model, so only set them when the
1251
+ # seed authors them explicitly — an absent key must not clobber the
1252
+ # workspace default with a false "explicitly disabled".
1253
+ if "mc_history_enabled" in data:
1254
+ program.mc_history_enabled = data["mc_history_enabled"]
1255
+ if "mc_history_attribution_audience" in data:
1256
+ program.mc_history_attribution_audience = data["mc_history_attribution_audience"]
1257
+ # Record the anchor every relative date in this document was resolved
1258
+ # against, so the drift is computable later (ADR-1175, #3481).
1259
+ #
1260
+ # Sample path only. A caller-authored import is somebody's real program;
1261
+ # stamping it would advertise a "Shift dates to today" action that would
1262
+ # bulk-rewrite real work, and `shift_sample_dates` refuses on `is_sample`
1263
+ # anyway — so a non-NULL anchor there would be a field promising something
1264
+ # the server will not do.
1265
+ if self.is_sample:
1266
+ program.sample_anchor_date = self.anchor
1267
+ program.save(
1268
+ update_fields=[
1269
+ "code",
1270
+ "color",
1271
+ "lead",
1272
+ "name",
1273
+ "description",
1274
+ "methodology",
1275
+ "mc_history_enabled",
1276
+ "mc_history_attribution_audience",
1277
+ "sample_anchor_date",
1278
+ ]
1279
+ )
1280
+ self._grant_program_memberships(program)
1281
+ return program
1282
+
1283
+ def _assign_program_key(self, program: Program, slug: str) -> None:
1284
+ """Give the program its key: adopted, the seed's slug, or a suffixed slug (ADR-1237).
1285
+
1286
+ In order: an adopted shell that already carries a key (the async path's
1287
+ view assigned it) keeps it; a replacement adopts the replaced program's
1288
+ keys; otherwise the slug is used when free. When the slug is held by a
1289
+ program this run does not replace — another user's copy of the same
1290
+ sample, or a trashed program — the key is suffixed
1291
+ (``atlas-platform-launch-2``) rather than failing: an import is not an
1292
+ interactive form, and the interactive ``POST /programs/import/`` refuses
1293
+ that case with a ``409`` before it ever reaches here.
1294
+
1295
+ ``save=False`` throughout: :meth:`_create_program` saves ``code`` with the
1296
+ other display fields in one write.
1297
+ """
1298
+ from trueppm_api.apps.projects.services import (
1299
+ adopt_released_keys,
1300
+ assign_key,
1301
+ is_key_taken,
1302
+ suggest_free_key,
1303
+ )
1304
+
1305
+ if program.code:
1306
+ return
1307
+ if self.released_program_keys and adopt_released_keys(
1308
+ program, self.released_program_keys, save=False
1309
+ ):
1310
+ return
1311
+ if is_key_taken("program", slug, exclude=program):
1312
+ assign_key(
1313
+ program,
1314
+ suggest_free_key(slug, "program"),
1315
+ source=ObjectKeySource.DERIVED,
1316
+ actor=self.owner,
1317
+ save=False,
1318
+ )
1319
+ else:
1320
+ assign_key(program, slug, source=ObjectKeySource.USER, actor=self.owner, save=False)
1321
+
1322
+ def _assign_project_key(self, project: Project, data: dict[str, Any]) -> None:
1323
+ """Give an imported project its key (ADR-1237), never failing the import.
1324
+
1325
+ A sample reload adopts the key of the hard-deleted project of the same
1326
+ name. Otherwise the seed's own ``code`` is used when it is free and a
1327
+ valid new key; a taken one is suffixed (``PLAT`` → ``PLAT2``,
1328
+ ``source=derived``) — loading the same sample twice must not collide —
1329
+ and a document without one derives from the project name.
1330
+ """
1331
+ from trueppm_api.apps.projects.keys import key_format_error, normalize_key
1332
+ from trueppm_api.apps.projects.services import (
1333
+ adopt_released_keys,
1334
+ assign_key,
1335
+ derive_key,
1336
+ is_key_taken,
1337
+ suggest_free_key,
1338
+ )
1339
+
1340
+ released = self.released_project_keys.pop(project.name, None)
1341
+ if released and adopt_released_keys(project, released):
1342
+ return
1343
+ code = normalize_key(data.get("code") or "", "project")
1344
+ if code and key_format_error(code, "project") is None:
1345
+ if not is_key_taken("project", code, exclude=project):
1346
+ assign_key(project, code, source=ObjectKeySource.USER, actor=self.owner)
1347
+ return
1348
+ key = suggest_free_key(code, "project")
1349
+ else:
1350
+ key = derive_key(project.name, "project", exclude=project)
1351
+ assign_key(project, key, source=ObjectKeySource.DERIVED, actor=self.owner)
1352
+
1353
+ def _grant_program_memberships(self, program: Program) -> None:
1354
+ for account in self.payload.get("accounts", []):
1355
+ user = self.users.get(account["slug"])
1356
+ role = _ROLE_BY_NAME.get(account.get("role", ""))
1357
+ if user is None or role is None or user == self.owner:
1358
+ continue # owner already has OWNER from create_program
1359
+ # is_deleted/deleted_version are in the defaults, not just role: on a
1360
+ # re-import over a program where this account was previously removed,
1361
+ # the tombstoned row still owns the (program, user) slot, so an update
1362
+ # that touched only `role` would report success and confer nothing
1363
+ # (#3410). A seed grant must always leave the member with access.
1364
+ ProgramMembership.objects.update_or_create(
1365
+ program=program,
1366
+ user=user,
1367
+ defaults={"role": role, "is_deleted": False, "deleted_version": None},
1368
+ )
1369
+
1370
+ def _grant_project_memberships(self, project: Project, data: dict[str, Any]) -> None:
1371
+ """Grant the seed's accounts a ``ProjectMembership`` on this project (#3092).
1372
+
1373
+ Project access is scoped by ``ProjectMembership`` — ``ProgramMembership``
1374
+ alone reaches the program rail and *no* project. Before this the importer
1375
+ granted only the owner, so every seeded persona signed in to an empty
1376
+ project list and the documented evaluation walkthrough dead-ended on its
1377
+ "sign in as atlas-alex" step.
1378
+
1379
+ Two modes, and the fallback is what keeps existing packs working:
1380
+
1381
+ * ``members`` **declared** — use exactly those pairs. This is the only way
1382
+ to express one person holding different roles on different projects,
1383
+ which is what makes project-scoped RBAC demonstrable at all.
1384
+ * ``members`` **omitted** — grant every account its program-level role.
1385
+ Equivalent to the flat roster a v2 seed already describes, so a fixture
1386
+ written before this key existed gains working access without an edit.
1387
+
1388
+ The owner is skipped: they were granted OWNER unconditionally by the
1389
+ caller, and a seed must never be able to *demote* the importing user on
1390
+ their own project. Accounts that resolved to ``None`` — a pre-existing
1391
+ real user on an untrusted import (see ``_resolve_accounts``) — are skipped
1392
+ by the same guard that protects every other account reference, so a
1393
+ crafted seed cannot pull a stranger into a project it does not own.
1394
+ """
1395
+ declared = data.get("members")
1396
+ if declared is None:
1397
+ pairs = [
1398
+ (account["slug"], account.get("role", ""))
1399
+ for account in self.payload.get("accounts", [])
1400
+ ]
1401
+ else:
1402
+ pairs = [(entry["account"], entry["role"]) for entry in declared]
1403
+
1404
+ for account_slug, role_name in pairs:
1405
+ user = self.users.get(account_slug)
1406
+ role = _ROLE_BY_NAME.get(role_name)
1407
+ if user is None or role is None or user == self.owner:
1408
+ continue
1409
+ # Un-tombstone alongside the role — see _grant_program_memberships
1410
+ # (#3410). Defensive here rather than load-bearing: the project row
1411
+ # this runs against was created moments ago in
1412
+ # _create_project_structure, so no tombstone can exist yet. It is
1413
+ # stated anyway so the invariant survives the day a project is reused,
1414
+ # which is exactly how the program-side path acquired the defect.
1415
+ # source_group is cleared for the same reason the members endpoint
1416
+ # clears it: a seed grant is a direct grant, and leaving a revoked
1417
+ # row's group provenance set would let a later workspace-group
1418
+ # reconcile revoke access the seed just conferred.
1419
+ ProjectMembership.objects.update_or_create(
1420
+ project=project,
1421
+ user=user,
1422
+ defaults={
1423
+ "role": role,
1424
+ "is_deleted": False,
1425
+ "deleted_version": None,
1426
+ "source_group": None,
1427
+ },
1428
+ )
1429
+
1430
+ # --- per-project structure (Pass A) ------------------------------------
1431
+
1432
+ def _create_project_structure(self, program: Program, data: dict[str, Any]) -> None:
1433
+ slug = data["slug"]
1434
+ calendar = self.calendars.get(data["calendar"]) if data.get("calendar") else None
1435
+ # Build the working-calendar facts first so relative dates snap against
1436
+ # this project's calendar (weekends + exceptions).
1437
+ self.working_calendars[slug] = self._build_working_calendar(calendar)
1438
+ project = Project.objects.create(
1439
+ program=program,
1440
+ name=data["name"],
1441
+ description=data.get("description", ""),
1442
+ start_date=self._date(data["start_date"], slug),
1443
+ calendar=calendar,
1444
+ methodology=data["methodology"],
1445
+ # Assigned by ``_assign_project_key`` right after the INSERT, which
1446
+ # suffixes a taken seed code instead of colliding (ADR-1237).
1447
+ code="",
1448
+ default_view=data.get("default_view", "SCHEDULE"),
1449
+ estimation_mode=data.get("estimation_mode", "open"),
1450
+ # PM health override (#520). Seeds may pin a project off AUTO so the
1451
+ # program overview shows a mix of computed and hand-set chips.
1452
+ health=data.get("health", "AUTO"),
1453
+ # is_sample is owned by the importer so the idempotency guard in
1454
+ # _replace_existing can distinguish disposable demo data from real
1455
+ # work on a later reload (#994).
1456
+ is_sample=self.is_sample,
1457
+ )
1458
+ self._assign_project_key(project, data)
1459
+ # Workstream lead (#3098). Distinct from both the program lead and the
1460
+ # OWNER membership: on a program of parallel workstreams the lead is who
1461
+ # the program manager asks about this project, and it is what the project
1462
+ # header and the program roll-up display. Resolved after create() because
1463
+ # the account may be declared later in the same document.
1464
+ lead_slug = data.get("lead")
1465
+ if lead_slug:
1466
+ lead_user = self.users.get(lead_slug)
1467
+ if lead_user is not None:
1468
+ project.lead = lead_user
1469
+ project.save(update_fields=["lead"])
1470
+ self.projects[slug] = project
1471
+ ProjectMembership.objects.update_or_create(
1472
+ project=project,
1473
+ user=self.owner,
1474
+ defaults={
1475
+ "role": Role.OWNER,
1476
+ "is_deleted": False,
1477
+ "deleted_version": None,
1478
+ "source_group": None,
1479
+ },
1480
+ )
1481
+ self._grant_project_memberships(project, data)
1482
+ # After the grants: the default team's membership rows are mirrored from them.
1483
+ self._apply_team(project, data)
1484
+ self._create_board_config(project, data)
1485
+
1486
+ self._create_project_labels(project, slug, data)
1487
+ sprint_rows = self._create_project_sprints(project, slug, data)
1488
+ self._create_project_tasks(project, slug, data)
1489
+ self._refresh_active_sprint_burndown(sprint_rows)
1490
+
1491
+ def _create_project_labels(self, project: Project, slug: str, data: dict[str, Any]) -> None:
1492
+ """Create the project's label catalog (Pass A).
1493
+
1494
+ Order within Pass A is load-bearing: labels, then sprints, then tasks.
1495
+ ``_build_task`` reads ``self.sprints`` for a task's sprint assignment,
1496
+ and Pass B's ``_link_task_labels`` reads ``self.labels`` — so both
1497
+ registries must be populated before the task loop runs.
1498
+ """
1499
+ # Labels (ADR-0400, #1958): the project's curated label catalog. Created
1500
+ # here in Pass A so the Pass B ``_link_task_labels`` step can attach each
1501
+ # task's slugged labels once all tasks exist. Slugs are file-local and
1502
+ # project-scoped; the seed carries no label UUID (0.5 adds that, #1959).
1503
+ label_rows = []
1504
+ for label_data in data.get("labels", []):
1505
+ label = Label(
1506
+ project=project,
1507
+ name=label_data["name"],
1508
+ color=label_data.get("color", "slate"),
1509
+ position=label_data.get("position", 0),
1510
+ created_by=self.owner,
1511
+ )
1512
+ label_rows.append(label)
1513
+ self.labels[(slug, label_data["slug"])] = label
1514
+ self._bulk_insert(Label, label_rows, project_ids=[project.pk])
1515
+
1516
+ def _create_project_sprints(
1517
+ self, project: Project, slug: str, data: dict[str, Any]
1518
+ ) -> list[Sprint]:
1519
+ """Create the project's sprints and return the rows.
1520
+
1521
+ The rows are returned rather than re-queried because
1522
+ ``_refresh_active_sprint_burndown`` needs the in-memory instances: they
1523
+ came from ``bulk_create`` and the caller has not re-read them.
1524
+ """
1525
+ sprint_rows: list[Sprint] = []
1526
+ sprint_dates: list[date] = []
1527
+ for sprint_data in data.get("sprints", []):
1528
+ start = self._date(sprint_data["start_date"], slug)
1529
+ finish = self._date(sprint_data["finish_date"], slug)
1530
+ # Snapping is monotone, so it can never invert an authored start < finish,
1531
+ # but it can collapse one: when every day from start through finish is
1532
+ # non-working (a short sprint over a weekend, or a long PTO/holiday run)
1533
+ # both ends land on the same next working day, and the
1534
+ # ``sprint_finish_after_start`` CHECK aborts the whole import (#4339).
1535
+ # Move the finish to the first working day after the snapped start (the
1536
+ # shortest window the CHECK admits: two working days inclusive) rather
1537
+ # than fail the whole import — a demo install, or any user's seed —
1538
+ # over an authored window the calendar happened to swallow.
1539
+ # Only a collapse the snap *caused* is repaired: a window authored
1540
+ # inverted or zero-length is an authoring error, and still fails loudly.
1541
+ if finish <= start and self._date(
1542
+ sprint_data["finish_date"], slug, snap=False
1543
+ ) > self._date(sprint_data["start_date"], slug, snap=False):
1544
+ finish = self._wc(slug).snap_forward(start + timedelta(days=1))
1545
+ # Under replay the sprint is born PLANNED and walked to its end state
1546
+ # by activate/close beats (authored or synthesized); points are
1547
+ # snapshotted at those beats. v1 import sets the end state directly.
1548
+ sprint = Sprint(
1549
+ project=project,
1550
+ name=sprint_data["name"],
1551
+ goal=sprint_data.get("goal", ""),
1552
+ notes=sprint_data.get("notes", ""),
1553
+ start_date=start,
1554
+ finish_date=finish,
1555
+ state="PLANNED" if self.replay else sprint_data["state"],
1556
+ committed_points=None if self.replay else sprint_data.get("committed_points"),
1557
+ completed_points=None if self.replay else sprint_data.get("completed_points"),
1558
+ capacity_points=sprint_data.get("capacity_points"),
1559
+ )
1560
+ sprint_rows.append(sprint)
1561
+ sprint_dates.append(start)
1562
+ self.sprints[(slug, sprint_data["slug"])] = sprint
1563
+ if self.replay:
1564
+ self.final_sprint[(slug, sprint_data["slug"])] = {
1565
+ "state": sprint_data["state"],
1566
+ "committed_points": sprint_data.get("committed_points"),
1567
+ "completed_points": sprint_data.get("completed_points"),
1568
+ }
1569
+ for sprint, short_id in zip(
1570
+ sprint_rows, self._short_id_block(project.pk, len(sprint_rows)), strict=True
1571
+ ):
1572
+ sprint.short_id = short_id
1573
+ self._bulk_insert(
1574
+ Sprint,
1575
+ sprint_rows,
1576
+ project_ids=[project.pk],
1577
+ history_dates=sprint_dates if self.replay else None,
1578
+ )
1579
+ return sprint_rows
1580
+
1581
+ def _create_project_tasks(self, project: Project, slug: str, data: dict[str, Any]) -> None:
1582
+ """Create the project's tasks (Pass A). Runs after sprints — see above."""
1583
+ task_rows: list[Task] = []
1584
+ task_dates: list[date] = []
1585
+ planning_beat = self._project_planning_beat(project, slug, data)
1586
+ for task_data in data.get("tasks", []):
1587
+ task, created_on = self._build_task(project, slug, task_data, planning_beat)
1588
+ task_rows.append(task)
1589
+ task_dates.append(created_on)
1590
+ self._stamp_new_tasks(task_rows, project)
1591
+ # One batch per project. The three Task post_save receivers are all
1592
+ # no-ops on INSERT — two early-return on ``created``, and the milestone
1593
+ # rollup short-circuits because ``Sprint.target_milestone`` is not linked
1594
+ # until Pass B — so nothing is lost by skipping the signal.
1595
+ self._bulk_insert(
1596
+ Task,
1597
+ task_rows,
1598
+ project_ids=[project.pk],
1599
+ history_dates=task_dates if self.replay else None,
1600
+ )
1601
+
1602
+ def _stamp_new_tasks(self, rows: list[Task], project: Project) -> None:
1603
+ """Apply everything ``Task.save()`` does on INSERT that ``bulk_create`` skips.
1604
+
1605
+ Three stamps, each of which would otherwise be a silent data defect:
1606
+
1607
+ * ``short_id`` — reserved as one block (see :meth:`_short_id_block`);
1608
+ without it every row carries ``""`` and the per-project unique
1609
+ constraint rejects the second one.
1610
+ * ``status_changed_at`` — ``_stamp_status_change`` treats an INSERT as a
1611
+ transition from ``None``, so every created task is stamped. Column
1612
+ charts and the stale-task threshold read it.
1613
+ * the sign-off percent coercion — REVIEW and COMPLETE both imply 100%
1614
+ delivered work, and a v1 seed authors those states directly. Without
1615
+ it a seeded COMPLETE task reports its authored ``percent_complete``
1616
+ and the ring, strip, and SPI math disagree with the column the card
1617
+ sits in.
1618
+
1619
+ ``_stamp_blocker_transition`` is deliberately not replicated: on INSERT it
1620
+ only acts when ``blocked_reason`` is non-empty, and the seed schema has no
1621
+ blocker fields, so it is a guaranteed no-op here.
1622
+
1623
+ Seed provenance (ADR-0786, #2730) is stamped here too, and ``edited_at`` is
1624
+ deliberately left null: these rows were written by a machine and nobody has
1625
+ touched them yet, which is exactly what makes them eligible for the
1626
+ "Delete untouched rows (N)" sweep for the next seven days.
1627
+ """
1628
+ if not rows:
1629
+ return
1630
+ now = timezone.now()
1631
+ for task, short_id in zip(rows, self._short_id_block(project.pk, len(rows)), strict=True):
1632
+ task.short_id = short_id
1633
+ task.status_changed_at = now
1634
+ task.source_kind = self.provenance_kind
1635
+ task.source_id = self.provenance_source_id
1636
+ task.seeded_at = now
1637
+ if task.status in (TaskStatus.REVIEW, TaskStatus.COMPLETE):
1638
+ task.percent_complete = 100.0
1639
+
1640
+ def _refresh_active_sprint_burndown(self, sprints: list[Sprint]) -> None:
1641
+ """Upsert today's burn row once per ACTIVE sprint, not once per task.
1642
+
1643
+ ``Task.save()`` emits ``task_status_changed`` on INSERT, whose receiver
1644
+ upserts the sprint's burndown row. Batching skips the signal, so the
1645
+ effect is reproduced here — but per *sprint* rather than per task, which
1646
+ is what the receiver's own idempotent upsert was collapsing to anyway.
1647
+
1648
+ Skipped under seed replay for the same reason the receiver skips it: the
1649
+ timeline drives backdated burndown per simulated day, and stamping
1650
+ today's row would collapse the curve to a single point (ADR-0114).
1651
+ """
1652
+ from trueppm_api.apps.projects.seed.replay_ctx import is_seed_replay_active
1653
+
1654
+ if is_seed_replay_active():
1655
+ return
1656
+ from trueppm_api.apps.projects.models import SprintState
1657
+ from trueppm_api.apps.projects.services import upsert_burndown_for_sprint
1658
+
1659
+ for sprint in sprints:
1660
+ if sprint.state != SprintState.ACTIVE:
1661
+ continue
1662
+ try:
1663
+ upsert_burndown_for_sprint(sprint)
1664
+ # TODO(#4421): swallows SoftTimeLimitExceeded; the drain-loop guard
1665
+ # is ineffective until this is fixed
1666
+ except Exception:
1667
+ logger.exception("seed import: burndown upsert failed for sprint=%s", sprint.pk)
1668
+
1669
+ def _project_planning_beat(self, project: Project, slug: str, data: dict[str, Any]) -> date:
1670
+ """The creation-history anchor for the project's non-sprint tasks (#3487).
1671
+
1672
+ Prefers the project's earliest *declared* baseline capture — the moment
1673
+ the plan was first committed to paper, which in every bundled sample
1674
+ predates any comment or status beat authored against a task in it —
1675
+ falling back to the project's own start date when the seed declares no
1676
+ baseline. Reads the raw seed dict rather than ``Baseline`` rows: Pass B
1677
+ (``_capture_baselines``) hasn't run yet when Pass A builds tasks.
1678
+ """
1679
+ captured = [
1680
+ self._date_opt(bl.get("captured_at"), slug, snap=False)
1681
+ for bl in data.get("baselines", [])
1682
+ ]
1683
+ earliest = min((c for c in captured if c is not None), default=None)
1684
+ return earliest if earliest is not None else project.start_date
1685
+
1686
+ def _sprint_creation_beat(self, project_slug: str, wbs_path: str, sprint: Sprint) -> date:
1687
+ """A sprint-bound task's creation beat: a few days before the sprint starts.
1688
+
1689
+ Never the sprint's own ``start_date`` (#3487) — a story exists before
1690
+ sprint planning seats it in a sprint, so backdating creation to exactly
1691
+ kickoff still reads as "born the moment planning began". The lead time is
1692
+ derived from a stable hash of the task's own key, never wall-clock time
1693
+ or unseeded randomness, so a reload reproduces the same history
1694
+ (ADR-0114).
1695
+ """
1696
+ digest = hashlib.sha256(f"{project_slug}:{wbs_path}".encode()).digest()
1697
+ lead_days = 2 + (digest[0] % 4) # 2-5 days before kickoff
1698
+ return sprint.start_date - timedelta(days=lead_days)
1699
+
1700
+ def _derive_task_fields(self, project_slug: str, data: dict[str, Any]) -> _TaskFields:
1701
+ sprint = self.sprints.get((project_slug, data["sprint"])) if data.get("sprint") else None
1702
+ assignee = self.users.get(data["assignee"]) if data.get("assignee") else None
1703
+ is_milestone = data.get("is_milestone", False)
1704
+
1705
+ # Three-point estimate is all-or-none and never set on milestones
1706
+ # (ADR-0093). Seeded estimates are PM-authored, so they import ACCEPTED.
1707
+ # Folded into the create() so an estimated task is one INSERT, not two.
1708
+ estimate = data.get("estimate") if not is_milestone else None
1709
+ estimate_fields = (
1710
+ {
1711
+ "optimistic_duration": estimate["optimistic"],
1712
+ "most_likely_duration": estimate["most_likely"],
1713
+ "pessimistic_duration": estimate["pessimistic"],
1714
+ "estimate_status": EstimateStatus.ACCEPTED,
1715
+ }
1716
+ if estimate
1717
+ else {}
1718
+ )
1719
+
1720
+ final_status = data.get("status", "NOT_STARTED")
1721
+ planned_start = self._date_opt(data.get("planned_start"), project_slug)
1722
+ story_points = data.get("story_points")
1723
+
1724
+ # Under replay a task that ends in-flight/done is born NOT_STARTED and
1725
+ # walked forward by the timeline + synthesizer; its creation row is
1726
+ # backdated to when work could have begun (sprint/planned/project start).
1727
+ progresses = self.replay and final_status in ("IN_PROGRESS", "REVIEW", "COMPLETE")
1728
+
1729
+ # BACKLOG is off the progression spine — an ordinary backlog item is born
1730
+ # there and the synthesizer deliberately refuses to walk anything to it
1731
+ # (``replay._PROGRESSION_INDEX`` has no entry), so it must NOT be folded
1732
+ # into ``progresses``: born NOT_STARTED with nothing to move it, it would
1733
+ # strand at NOT_STARTED forever. But a sprint close that carries an
1734
+ # unfinished task back to the backlog (#3488) *is* an event-driven move
1735
+ # into BACKLOG, and the exporter reconstructs it as a dated ``task.status``
1736
+ # beat. Born already at BACKLOG that beat is a no-op (``_apply_task_status``
1737
+ # returns when the column already matches), so no history row exists and
1738
+ # the re-export drops the event — breaking the #616 byte-identical round
1739
+ # trip. The document itself distinguishes the two cases: only the carried
1740
+ # task has a ``task.status`` beat dated against it.
1741
+ decommits = (
1742
+ self.replay
1743
+ and final_status == "BACKLOG"
1744
+ and (project_slug, data["wbs_path"]) in self.authored_status_targets
1745
+ )
1746
+ base_status = "NOT_STARTED" if (progresses or decommits) else final_status
1747
+ # Progress fields stay keyed on ``progresses`` alone: a carried task walks
1748
+ # only its column, so re-basing its percent/remaining would invent numbers
1749
+ # nothing later restores (``_finalize_tasks`` only rescues IN_PROGRESS and
1750
+ # REVIEW), and the re-export would then disagree about those fields.
1751
+ base_percent = 0.0 if progresses else data.get("percent_complete", 0.0)
1752
+ base_remaining = story_points if progresses else data.get("remaining_points")
1753
+
1754
+ return _TaskFields(
1755
+ sprint=sprint,
1756
+ assignee=assignee,
1757
+ is_milestone=is_milestone,
1758
+ estimate_fields=estimate_fields,
1759
+ final_status=final_status,
1760
+ planned_start=planned_start,
1761
+ story_points=story_points,
1762
+ base_status=base_status,
1763
+ base_percent=base_percent,
1764
+ base_remaining=base_remaining,
1765
+ )
1766
+
1767
+ def _task_creation_beat(
1768
+ self, project_slug: str, data: dict[str, Any], planning_beat: date, sprint: Sprint | None
1769
+ ) -> date:
1770
+ """The date this task's creation-history row backdates to (#3487).
1771
+
1772
+ A sprint-bound task backdates to shortly before its sprint kicks off;
1773
+ everything else backdates to the project's own planning beat (earliest
1774
+ baseline capture, else project start) — never to ``planned_start``, which
1775
+ is a future schedule position, not a record of when the task came into
1776
+ being, and is exactly what put creation rows months into the future
1777
+ before this fix. Two further clamps, both required: an authored event
1778
+ against this task (comment, status move, …) is honored as an even
1779
+ earlier floor when the seed talks about the task before its plan
1780
+ existed, and the anchor is a hard ceiling so a reload never manufactures
1781
+ a future-dated creation row regardless of how far out the task or
1782
+ sprint is planned.
1783
+ """
1784
+ beat = (
1785
+ self._sprint_creation_beat(project_slug, data["wbs_path"], sprint)
1786
+ if sprint
1787
+ else planning_beat
1788
+ )
1789
+ earliest_event = self.earliest_task_event.get((project_slug, data["wbs_path"]))
1790
+ if earliest_event is not None and earliest_event < beat:
1791
+ beat = earliest_event
1792
+ return min(beat, self.anchor)
1793
+
1794
+ def _capture_replay_progress(
1795
+ self, project_slug: str, data: dict[str, Any], final_status: str
1796
+ ) -> None:
1797
+ """Record the authored progress fields a replayed task should be restored to.
1798
+
1799
+ A task born at 0%/full-points to give the timeline room to walk it
1800
+ forward never gets those numbers back on its own — COMPLETE is rescued
1801
+ by ``Task._coerce_signoff_percent``, IN_PROGRESS is not (#3486). REVIEW
1802
+ is a mixed case (#3518): the same coercion fixes ``percent_complete`` to
1803
+ 100 by design ("work done, awaiting sign-off"), but ``remaining_points``
1804
+ has no such contract — a story in review with points still open is a
1805
+ real, authorable state — so REVIEW only hands back ``remaining_points``,
1806
+ never ``percent_complete``, to avoid fighting the coercion. Hand replay
1807
+ the authored values to restore, keyed per field so a dated
1808
+ ``task.points`` beat still wins where the document declared nothing.
1809
+ """
1810
+ self.final_status[(project_slug, data["wbs_path"])] = final_status
1811
+ progress_fields: tuple[str, ...]
1812
+ if final_status == "IN_PROGRESS":
1813
+ progress_fields = ("percent_complete", "remaining_points")
1814
+ elif final_status == "REVIEW":
1815
+ progress_fields = ("remaining_points",)
1816
+ else:
1817
+ progress_fields = ()
1818
+ if not progress_fields:
1819
+ return
1820
+ authored = {key: data[key] for key in progress_fields if data.get(key) is not None}
1821
+ if authored:
1822
+ self.final_progress[(project_slug, data["wbs_path"])] = authored
1823
+
1824
+ def _build_task(
1825
+ self, project: Project, project_slug: str, data: dict[str, Any], planning_beat: date
1826
+ ) -> tuple[Task, date]:
1827
+ """Construct one unsaved ``Task`` plus the date its creation row backdates to.
1828
+
1829
+ Returns rather than saves so the caller can batch a whole project's tasks
1830
+ into one INSERT (ADR-0726 §8). The returned date is only consulted under
1831
+ v2 replay.
1832
+
1833
+ Args:
1834
+ planning_beat: the project's default creation-history anchor for a
1835
+ non-sprint task (see :meth:`_project_planning_beat`) — computed
1836
+ once per project rather than per task since it doesn't vary.
1837
+ """
1838
+ f = self._derive_task_fields(project_slug, data)
1839
+
1840
+ task = Task(
1841
+ project=project,
1842
+ name=data["name"],
1843
+ wbs_path=data["wbs_path"],
1844
+ type=data.get("type", "task"),
1845
+ status=f.base_status,
1846
+ is_milestone=f.is_milestone,
1847
+ duration=0 if f.is_milestone else data.get("duration", 1),
1848
+ planned_start=f.planned_start,
1849
+ percent_complete=f.base_percent,
1850
+ notes=data.get("notes", ""),
1851
+ story_points=f.story_points,
1852
+ remaining_points=f.base_remaining,
1853
+ assignee=f.assignee,
1854
+ sprint=f.sprint,
1855
+ sprint_rank=data.get("sprint_rank"),
1856
+ # Declared in the schema with an enum since v1 and read by nothing
1857
+ # (#3093): aurora authored 23 `ready` + 2 `refine` and every one of
1858
+ # them landed on the model default. `task.ac_met` was the only writer,
1859
+ # so the pure-scrum pack's Definition of Ready — the whole point of its
1860
+ # sprint-picker story — was uniformly `idea` on screen.
1861
+ dor=data.get("dor", DorState.IDEA),
1862
+ board_lane=data.get("board_lane", ""),
1863
+ # ``blocked_reason`` is the flag-of-record: non-empty means blocked.
1864
+ # ``blocked_since`` is stamped by ``Task.save()`` with ``timezone.now()``,
1865
+ # which would read "blocked 0 days" on a backdated seed, so it is
1866
+ # corrected to the project's own timeline in ``_backdate_blockers``.
1867
+ blocked_reason=self._blocker_reason(data.get("blocked")),
1868
+ blocker_type=(data.get("blocked") or {}).get("type", ""),
1869
+ governance_class=data.get("governance_class", "flow"),
1870
+ # Couple delivery_mode to the milestone flag (#1773) so seeded
1871
+ # milestones satisfy the canonical invariant like every other path.
1872
+ delivery_mode="milestone" if f.is_milestone else data.get("delivery_mode", "waterfall"),
1873
+ color=data.get("color"),
1874
+ # Drawer subtask (ADR-0060, #3498). Validation already held it to a
1875
+ # depth-1 leaf under a decomposable parent, as the create view does.
1876
+ is_subtask=bool(data.get("is_subtask", False)),
1877
+ **f.estimate_fields,
1878
+ )
1879
+ created_on = self._task_creation_beat(project_slug, data, planning_beat, f.sprint)
1880
+ self.tasks[(project_slug, data["wbs_path"])] = task
1881
+ if self.replay:
1882
+ self._capture_replay_progress(project_slug, data, f.final_status)
1883
+ return task, created_on
1884
+
1885
+ # --- cross-cutting links (Pass B) --------------------------------------
1886
+
1887
+ def _resolve_task_ref(self, ref: str, enclosing_project: str) -> Task:
1888
+ if ":" in ref:
1889
+ project_slug, _, wbs = ref.partition(":")
1890
+ else:
1891
+ project_slug, wbs = enclosing_project, ref
1892
+ return self.tasks[(project_slug, wbs)]
1893
+
1894
+ def _link_dependencies(self, project: Project, data: dict[str, Any]) -> None:
1895
+ """Materialize the project's CPM edges in one batch per owning project.
1896
+
1897
+ A dependency carries no project FK, so its sync cursor is drawn through
1898
+ its ``predecessor`` (the endpoint the delta scopes and orders by). Edges
1899
+ are therefore grouped by the *predecessor's* project rather than by the
1900
+ enclosing block — a seed may reference a task in any project of the
1901
+ program (the ADR-0120 D1 envelope), so the two are not the same set.
1902
+ """
1903
+ slug = data["slug"]
1904
+ by_owner: dict[Any, list[Dependency]] = defaultdict(list)
1905
+ for dep in data.get("dependencies", []):
1906
+ predecessor = self._resolve_task_ref(dep["predecessor"], slug)
1907
+ by_owner[predecessor.project_id].append(
1908
+ Dependency(
1909
+ predecessor=predecessor,
1910
+ successor=self._resolve_task_ref(dep["successor"], slug),
1911
+ dep_type=dep["dep_type"],
1912
+ lag=dep.get("lag", 0),
1913
+ )
1914
+ )
1915
+ for project_id, rows in by_owner.items():
1916
+ self._bulk_insert(Dependency, rows, project_ids=[project_id])
1917
+
1918
+ def _resolve_blockers(self, data: dict[str, Any]) -> None:
1919
+ """Fill in the blocker halves that need every task and account to exist (#3094).
1920
+
1921
+ ``blocked_reason`` / ``blocker_type`` are set at construction; the soft
1922
+ ``blocking_task`` link and the ``blocked_by`` actor are resolved here in
1923
+ Pass B, once the whole project's tasks are inserted and a forward
1924
+ reference can land.
1925
+
1926
+ ``blocked_since`` is set explicitly rather than left to the model.
1927
+ ``Task.save()`` stamps it with ``timezone.now()`` on the block transition,
1928
+ but the importer inserts through ``bulk_create_tasks`` — no ``save()``, so
1929
+ nothing stamps it at all, and an unstamped blocker renders no age. Age is
1930
+ the whole triage signal ("3 tasks blocked more than a week"), so a seeded
1931
+ blocker that reads "0d" is worse than none. Falls back to the task's own
1932
+ planned start, then the project's, so the value is never null while the
1933
+ flag is raised.
1934
+ """
1935
+ slug = data["slug"]
1936
+ project = self.projects[slug]
1937
+ touched = []
1938
+ for task_data in data.get("tasks", []):
1939
+ blocked = task_data.get("blocked")
1940
+ if not blocked:
1941
+ continue
1942
+ task = self.tasks[(slug, task_data["wbs_path"])]
1943
+ # A blocker started when it started — a Saturday stays a Saturday.
1944
+ # Snapping this forward would report the block as newer than it is.
1945
+ since = self._date_opt(blocked.get("since"), slug, snap=False)
1946
+ task.blocked_since = self._creation_dt(
1947
+ since or task.planned_start or project.start_date
1948
+ )
1949
+ if blocked.get("blocking_task"):
1950
+ task.blocking_task = self._resolve_task_ref(blocked["blocking_task"], slug)
1951
+ if blocked.get("by"):
1952
+ task.blocked_by = self.users.get(blocked["by"])
1953
+ touched.append(task)
1954
+ if touched:
1955
+ Task.objects.bulk_update(
1956
+ touched,
1957
+ ["blocked_since", "blocking_task", "blocked_by"],
1958
+ batch_size=_BULK_BATCH_SIZE,
1959
+ )
1960
+
1961
+ def _create_attachments(self, data: dict[str, Any]) -> None:
1962
+ """Create each task's external-link attachments (#3094).
1963
+
1964
+ URL-only by design: ``TaskAttachment`` enforces file XOR ``external_url``
1965
+ at the DB level, and a seed is a text document with no bytes to carry, so
1966
+ the file half is deliberately inexpressible rather than half-supported.
1967
+ """
1968
+ slug = data["slug"]
1969
+ rows = []
1970
+ for task_data in data.get("tasks", []):
1971
+ attachments = task_data.get("attachments")
1972
+ if not attachments:
1973
+ continue
1974
+ task = self.tasks[(slug, task_data["wbs_path"])]
1975
+ for entry in attachments:
1976
+ rows.append(
1977
+ TaskAttachment(
1978
+ task=task,
1979
+ external_url=entry["external_url"],
1980
+ external_title=entry.get("external_title", ""),
1981
+ is_pinned=entry.get("is_pinned", False),
1982
+ uploaded_by=(
1983
+ self.users.get(entry["uploaded_by"])
1984
+ if entry.get("uploaded_by")
1985
+ else None
1986
+ ),
1987
+ )
1988
+ )
1989
+ if rows:
1990
+ TaskAttachment.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
1991
+
1992
+ def _link_parent_epics(self, data: dict[str, Any]) -> None:
1993
+ slug = data["slug"]
1994
+ touched = []
1995
+ for task_data in data.get("tasks", []):
1996
+ parent = task_data.get("parent_epic")
1997
+ if not parent:
1998
+ continue
1999
+ task = self.tasks[(slug, task_data["wbs_path"])]
2000
+ task.parent_epic = self.tasks[(slug, parent)]
2001
+ touched.append(task)
2002
+ # bulk_update: a back-link is a structural fixup of a row this import just
2003
+ # created, not an independent edit, so it neither needs its own history
2004
+ # row nor a second cursor draw (the same reasoning ADR-0091 applies to CPM
2005
+ # output writes).
2006
+ if touched:
2007
+ Task.objects.bulk_update(touched, ["parent_epic"])
2008
+
2009
+ def _link_sprint_milestones(self, data: dict[str, Any]) -> None:
2010
+ slug = data["slug"]
2011
+ touched = []
2012
+ for sprint_data in data.get("sprints", []):
2013
+ target = sprint_data.get("target_milestone")
2014
+ if not target:
2015
+ continue
2016
+ sprint = self.sprints[(slug, sprint_data["slug"])]
2017
+ sprint.target_milestone = self.tasks[(slug, target)]
2018
+ touched.append(sprint)
2019
+ if touched:
2020
+ Sprint.objects.bulk_update(touched, ["target_milestone"])
2021
+
2022
+ def _link_task_labels(self, data: dict[str, Any]) -> None:
2023
+ """Attach each task's slugged labels via the ``TaskLabel`` through table (#1958).
2024
+
2025
+ Runs in Pass B (after all tasks exist). A task's ``labels`` is a list of
2026
+ project-local label slugs resolved against the ``self.labels`` index built
2027
+ in Pass A. Unknown slugs are skipped defensively — validation (#614) rejects
2028
+ a dangling label ref before we reach here, so this is belt-and-braces.
2029
+ """
2030
+ slug = data["slug"]
2031
+ rows = []
2032
+ seen: set[tuple[Any, Any]] = set()
2033
+ for task_data in data.get("tasks", []):
2034
+ label_slugs = task_data.get("labels", [])
2035
+ if not label_slugs:
2036
+ continue
2037
+ task = self.tasks[(slug, task_data["wbs_path"])]
2038
+ for label_slug in label_slugs:
2039
+ label = self.labels.get((slug, label_slug))
2040
+ # The de-dup that ``get_or_create`` used to provide. Validation
2041
+ # (#614) already rejects a task listing a label twice, so this
2042
+ # only guards a payload that slipped past it — but the batch has
2043
+ # no per-row conflict handling, so it has to guard it here.
2044
+ if label is not None and (task.pk, label.pk) not in seen:
2045
+ seen.add((task.pk, label.pk))
2046
+ rows.append(TaskLabel(task=task, label=label))
2047
+ # TaskLabel is outside the sync union (it has no cursor of its own —
2048
+ # attach/detach saves the Task, which allocates there instead) and
2049
+ # records no history, so a plain bulk_create is the whole story.
2050
+ self._bulk_insert(TaskLabel, rows)
2051
+
2052
+ def _link_task_relations(self, project_slug: str, data: dict[str, Any]) -> None:
2053
+ """Materialize each task's informational ``links`` as ``TaskRelation`` rows (ADR-0455).
2054
+
2055
+ Runs in Pass B (after all tasks exist) so a relation's ``target`` may resolve
2056
+ to a task in any project of the program — the ADR-0120 D1 envelope. A relation
2057
+ is inert: unlike :meth:`_link_dependencies` it drives no CPM edge, no consent
2058
+ gate, and no schedule recompute, so it is a plain ``create`` (the enclosing
2059
+ program subtree is wiped on re-import, so there is nothing to upsert against).
2060
+ Self-links are skipped defensively — validation (#614) rejects them first and
2061
+ the model's ``task_relation_no_self`` CheckConstraint is the DB backstop.
2062
+ """
2063
+ by_owner: dict[Any, list[TaskRelation]] = defaultdict(list)
2064
+ for task_data in data.get("tasks", []):
2065
+ links = task_data.get("links", [])
2066
+ if not links:
2067
+ continue
2068
+ source = self.tasks[(project_slug, task_data["wbs_path"])]
2069
+ for link in links:
2070
+ target = self._resolve_task_ref(link["target"], project_slug)
2071
+ if target.pk == source.pk:
2072
+ continue # inert self-link; rejected by the DB constraint anyway
2073
+ # Grouped by the source task's project — a relation's cursor is
2074
+ # drawn through ``source``, and a link may cross projects.
2075
+ by_owner[source.project_id].append(
2076
+ TaskRelation(
2077
+ source=source,
2078
+ target=target,
2079
+ relation_type=link["link_type"],
2080
+ note=link.get("note", ""),
2081
+ created_by=self.owner,
2082
+ )
2083
+ )
2084
+ for project_id, rows in by_owner.items():
2085
+ self._bulk_insert(TaskRelation, rows, project_ids=[project_id])
2086
+
2087
+ def _assign_resources(self, project: Project, data: dict[str, Any]) -> None:
2088
+ """Create the ``TaskResource`` rows, from ``assignments`` or from ``assignee``.
2089
+
2090
+ The fallback is the load-bearing half (#2900). Capacity, utilization and
2091
+ the heatmap sum ``TaskResource.units`` and never read ``Task.assignee``, so
2092
+ a task carrying only an assignee contributes **zero** load. Every bundled
2093
+ seed pack authors assignees and no ``assignments`` array at all, which made
2094
+ this whole method dead code for every sample that ships — an evaluator
2095
+ clicking "Load demo data" saw an owner on every board card and a completely
2096
+ empty heatmap, and concluded the capacity features do not work.
2097
+
2098
+ Synthesizing the row here rather than adding ``assignments`` to the four
2099
+ fixtures is deliberate: it also covers any future fixture that authors an
2100
+ assignee and forgets the allocation, which is the mistake all four made.
2101
+
2102
+ An explicit ``assignments`` array always wins — it can express partial
2103
+ units and several people on one task, which a bare assignee cannot. The
2104
+ fallback fires only when a task has no assignments at all, and resolves
2105
+ the assignee (an **account** slug) through the resource that declares that
2106
+ account. A task whose assignee has no corresponding resource is skipped:
2107
+ there is no defensible units figure for a person the seed never staffed.
2108
+
2109
+ Units are the resource's own availability, capped at one full unit — see
2110
+ :data:`_ASSIGNEE_MAX_UNITS` for why a flat 1.0 would misrepresent the
2111
+ part-time staffing the packs deliberately author.
2112
+ """
2113
+ slug = data["slug"]
2114
+ rows = []
2115
+ # ``ensure_project_resource`` is idempotent but costs a round-trip, so it
2116
+ # is hoisted out of the per-assignment loop and run once per distinct
2117
+ # resource rather than once per assignment.
2118
+ used: dict[Any, Any] = {}
2119
+ for task_data in data.get("tasks", []):
2120
+ task_key = (slug, task_data["wbs_path"])
2121
+ assignments = task_data.get("assignments", [])
2122
+ if assignments:
2123
+ task = self.tasks[task_key]
2124
+ for assignment in assignments:
2125
+ resource = self.resources[assignment["resource"]]
2126
+ used[resource.pk] = resource
2127
+ rows.append(
2128
+ TaskResource(
2129
+ task=task, resource=resource, units=assignment.get("units", 1.0)
2130
+ )
2131
+ )
2132
+ continue
2133
+
2134
+ assignee = task_data.get("assignee")
2135
+ if not assignee:
2136
+ continue
2137
+ owner = self.resources_by_account.get(assignee)
2138
+ if owner is None:
2139
+ continue
2140
+ # A milestone is a gate, not work somebody performs, so it carries no
2141
+ # load even when the seed names an owner on it. Giving one units would
2142
+ # inflate every heatmap cell it lands in with effort nobody spends.
2143
+ task = self.tasks[task_key]
2144
+ if task.is_milestone:
2145
+ continue
2146
+ used[owner.pk] = owner
2147
+ units = min(_ASSIGNEE_MAX_UNITS, owner.max_units)
2148
+ rows.append(TaskResource(task=task, resource=owner, units=units))
2149
+ self._bulk_insert(TaskResource, rows)
2150
+ for resource in used.values():
2151
+ ensure_project_resource(project, resource)
2152
+
2153
+ def _apply_team(self, project: Project, data: dict[str, Any]) -> None:
2154
+ """Name the project's default team and set its SM/PO facets (ADR-0078, #3498).
2155
+
2156
+ Membership itself comes from the ``ProjectMembership`` mirror
2157
+ (``teams.signals``), which the grants just before this call triggered. This
2158
+ only names the team and sets the facets, which the mirror never infers. A
2159
+ member whose account resolved to nobody is skipped, like every other account
2160
+ reference on the generic path (#1057).
2161
+ """
2162
+ spec = data.get("team")
2163
+ if not spec:
2164
+ return
2165
+ from trueppm_api.apps.teams.models import TeamMembership, TeamRole
2166
+ from trueppm_api.apps.teams.services import ensure_default_team
2167
+
2168
+ team = ensure_default_team(project, created_by=self.owner)
2169
+ if spec.get("name") and team.name != spec["name"]:
2170
+ team.name = spec["name"]
2171
+ team.save(update_fields=["name"])
2172
+ for member in spec.get("members", []):
2173
+ user = self.users.get(member["account"])
2174
+ if user is None:
2175
+ continue
2176
+ membership, _ = TeamMembership.objects.get_or_create(
2177
+ team=team, user=user, is_deleted=False, defaults={"role": TeamRole.MEMBER}
2178
+ )
2179
+ membership.is_scrum_master = bool(member.get("scrum_master", False))
2180
+ membership.is_product_owner = bool(member.get("product_owner", False))
2181
+ membership.save(update_fields=["is_scrum_master", "is_product_owner"])
2182
+
2183
+ def _create_recurrence_rules(self, data: dict[str, Any]) -> None:
2184
+ """Turn each template task's ``recurrence`` into a rule (ADR-0090, #3498).
2185
+
2186
+ Only the rule is written; occurrences spawn on the generator's rolling horizon,
2187
+ exactly as after the API creates one. ``is_recurring`` is set on the template
2188
+ the way ``TaskRecurrenceRuleViewSet.perform_create`` sets it, which takes the
2189
+ template out of CPM and every committed-delivery aggregate.
2190
+ """
2191
+ slug = data["slug"]
2192
+ project = self.projects[slug]
2193
+ tz = project.calendar.timezone if project.calendar is not None else "UTC"
2194
+ for task_data in data.get("tasks", []):
2195
+ spec = task_data.get("recurrence")
2196
+ if not spec:
2197
+ continue
2198
+ task = self.tasks[(slug, task_data["wbs_path"])]
2199
+ end_date = self._date_opt(spec.get("end_date"), slug, snap=False)
2200
+ end_count = spec.get("end_count")
2201
+ if end_date is not None:
2202
+ end_type = "ON_DATE"
2203
+ elif end_count:
2204
+ end_type = "AFTER_N"
2205
+ else:
2206
+ end_type = "NEVER"
2207
+ rule = TaskRecurrenceRule(
2208
+ task=task,
2209
+ frequency=spec["frequency"],
2210
+ interval=spec.get("interval", 1),
2211
+ weekdays=sum(_WEEKDAY_BIT[day] for day in spec.get("weekdays", [])),
2212
+ day_of_month=spec.get("day_of_month"),
2213
+ time_of_day=dt_time.fromisoformat(spec.get("time_of_day", "09:00")),
2214
+ timezone=tz or "UTC",
2215
+ end_type=end_type,
2216
+ end_date=end_date,
2217
+ end_count=end_count,
2218
+ )
2219
+ # The same invariants the serializer enforces; validation already
2220
+ # checked them, so a failure here means the two drifted.
2221
+ rule.clean()
2222
+ rule.save()
2223
+ task.is_recurring = True
2224
+ Task.objects.filter(pk=task.pk).update(is_recurring=True)
2225
+
2226
+ def _create_skill_requirements(self, data: dict[str, Any]) -> None:
2227
+ """Attach each task's skill requirements from the workspace catalog (#3498).
2228
+
2229
+ Matched onto ``Skill`` by case-folded name, so a requirement naming a skill a
2230
+ resource already declared lands on that same catalog row and the assignment
2231
+ fit can compare them.
2232
+ """
2233
+ slug = data["slug"]
2234
+ for task_data in data.get("tasks", []):
2235
+ for entry in task_data.get("skill_requirements", []):
2236
+ name = entry["skill"].strip()
2237
+ skill, _ = Skill.objects.get_or_create(
2238
+ normalized_name=name.casefold(), defaults={"name": name}
2239
+ )
2240
+ TaskSkillRequirement.objects.get_or_create(
2241
+ task=self.tasks[(slug, task_data["wbs_path"])],
2242
+ skill=skill,
2243
+ is_deleted=False,
2244
+ defaults={
2245
+ "min_proficiency": _PROFICIENCY_BY_NAME[
2246
+ entry.get("min_proficiency", "beginner")
2247
+ ]
2248
+ },
2249
+ )
2250
+
2251
+ def _create_board_config(self, project: Project, data: dict[str, Any]) -> None:
2252
+ """Materialize the project's Kanban board configuration (#3093).
2253
+
2254
+ ``board_columns`` was declared in the schema as a bare list of label
2255
+ strings and read by nothing, so a pack could author "Backlog / To Do /
2256
+ In Progress / In Review / Done" and get the API's hardcoded defaults —
2257
+ no renamed column, no WIP limit, no lane. The old shape could not have
2258
+ worked even if it had been read: ``BoardColumnConfig.columns`` is keyed
2259
+ on the canonical ``status``, and a label list carries none.
2260
+
2261
+ Entries are stored as authored. The board serializer requires all five
2262
+ canonical statuses exactly once and the JSON Schema enforces the same
2263
+ five-value enum, so a seed cannot express a config the board API would
2264
+ reject — but the serializer is the authority at read time, not this.
2265
+ """
2266
+ columns = data.get("board_columns")
2267
+ if not columns:
2268
+ return
2269
+ BoardColumnConfig.objects.update_or_create(
2270
+ project=project,
2271
+ defaults={
2272
+ "columns": [
2273
+ {
2274
+ "status": column["status"],
2275
+ "label": column["label"],
2276
+ "visible": column.get("visible", True),
2277
+ "color": column.get("color"),
2278
+ "wip_limit": column.get("wip_limit"),
2279
+ "age_threshold_days": column.get("age_threshold_days"),
2280
+ "lanes": [
2281
+ {
2282
+ "key": lane["key"],
2283
+ "label": lane["label"],
2284
+ "wip_limit": lane.get("wip_limit"),
2285
+ }
2286
+ for lane in column.get("lanes", [])
2287
+ ],
2288
+ }
2289
+ for column in columns
2290
+ ]
2291
+ },
2292
+ )
2293
+
2294
+ def _capture_baselines(self, project: Project, data: dict[str, Any]) -> None:
2295
+ slug = data["slug"]
2296
+ for bl_data in data.get("baselines", []):
2297
+ baseline = Baseline.objects.create(
2298
+ project=project,
2299
+ name=bl_data["name"],
2300
+ is_active=bl_data.get("is_active", False),
2301
+ )
2302
+ # ``captured_at`` has been in the schema and authored by every bundled
2303
+ # pack since v1, and nothing read it (#3093). ``Baseline.created_at`` is
2304
+ # auto_now_add, so each declared baseline recorded the moment somebody
2305
+ # clicked "Load demo data" — collapsing Bayside's 75-day gap between its
2306
+ # contract baseline and its change-order rebaseline onto one afternoon,
2307
+ # which is exactly the interval every planned-vs-actual view reads.
2308
+ # The replay path (``_apply_baseline_capture``) already backdates this
2309
+ # way; the declarative path simply never did.
2310
+ # A capture happened when it happened, so this must not weekend-snap:
2311
+ # two baselines whose offsets snap differently render an interval that
2312
+ # is not the one the pack authored. The replay path backdates from a
2313
+ # `resolve_timestamp`, which never snaps -- this keeps the two agreeing.
2314
+ captured = self._date_opt(bl_data.get("captured_at"), slug, snap=False)
2315
+ if captured is not None:
2316
+ Baseline.objects.filter(pk=baseline.pk).update(
2317
+ created_at=self._creation_dt(captured)
2318
+ )
2319
+ rows = []
2320
+ has_dates = True
2321
+ for bt in bl_data.get("tasks", []):
2322
+ task = self.tasks[(slug, bt["task"])]
2323
+ start = self._date_opt(bt.get("start"), slug)
2324
+ finish = self._date_opt(bt.get("finish"), slug)
2325
+ has_dates = has_dates and start is not None
2326
+ rows.append(
2327
+ BaselineTask(
2328
+ baseline=baseline,
2329
+ task_id=task.pk,
2330
+ task_name=task.name,
2331
+ start=start,
2332
+ finish=finish,
2333
+ duration=bt.get("duration", task.duration),
2334
+ story_points=bt.get("story_points", task.story_points),
2335
+ )
2336
+ )
2337
+ BaselineTask.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
2338
+ if rows and has_dates:
2339
+ Baseline.objects.filter(pk=baseline.pk).update(has_cpm_dates=True)
2340
+
2341
+ def _backfill_forecast_history(self) -> None:
2342
+ """Synthesize ProjectForecastSnapshot + MonteCarloRun history for projects
2343
+ that author a ``forecast_history`` block.
2344
+
2345
+ Delegates the drift math to ``forecast_backfill`` (ADR-0211). Keyed off the
2346
+ seed's ``forecast_history`` block so it is opt-in and generic — only the
2347
+ history-aware samples carry it, but any v2 seed may. The MonteCarloRun
2348
+ history (#3494) is derived from the same snapshot rows, not recomputed, so
2349
+ the MC history panel and the forecast-trend chart can never disagree.
2350
+ """
2351
+ for project_data in self.payload["projects"]:
2352
+ spec = project_data.get("forecast_history")
2353
+ if not spec:
2354
+ continue
2355
+ project = self.projects[project_data["slug"]]
2356
+ # Must match the population capture_forecast_snapshot aggregates over
2357
+ # (Task.committed, #3539), not every non-deleted task: the synthesized
2358
+ # history and the sample's first *real* capture sit next to each other on
2359
+ # the forecast-trend chart, and a task_count that steps at the join is a
2360
+ # discontinuity in demo data with no cause a viewer can find.
2361
+ task_count = Task.committed.filter(project=project).count()
2362
+ snapshots = backfill_forecast_history(
2363
+ project,
2364
+ spec,
2365
+ anchor=self.anchor,
2366
+ program_code=self.payload["program"]["slug"],
2367
+ project_slug=project_data["slug"],
2368
+ task_count=task_count,
2369
+ )
2370
+ backfill_monte_carlo_run_history(
2371
+ project,
2372
+ snapshots,
2373
+ triggered_by=self._scheduler_persona(project_data),
2374
+ )
2375
+
2376
+ def _scheduler_persona(self, project_data: dict[str, Any]) -> Any:
2377
+ """Resolve the user MonteCarloRun history (#3494) attributes seeded runs to.
2378
+
2379
+ A run's ``triggered_by`` is display/audit metadata (ADR-0175), never
2380
+ access control, so this need not be exact — but it should read as a real
2381
+ person, not the importing owner, when the seed casts one. Only a
2382
+ Scheduler+ role may actually persist an attributed run (``run_monte_carlo``
2383
+ docstring), so preference goes to whichever of that ladder the project
2384
+ casts, project scope first: the project's own ``members`` override (or
2385
+ the program-roster fallback ``_grant_project_memberships`` would apply)
2386
+ for SCHEDULER, then ADMIN, then OWNER; then the same three at program
2387
+ scope; then the importing owner as a last resort (every program has one).
2388
+ """
2389
+ declared = project_data.get("members")
2390
+ project_pairs = (
2391
+ [(entry["account"], entry["role"]) for entry in declared]
2392
+ if declared is not None
2393
+ else [
2394
+ (account["slug"], account.get("role", ""))
2395
+ for account in self.payload.get("accounts", [])
2396
+ ]
2397
+ )
2398
+ program_pairs = [
2399
+ (account["slug"], account.get("role", ""))
2400
+ for account in self.payload.get("accounts", [])
2401
+ ]
2402
+ for role_name in ("SCHEDULER", "ADMIN", "OWNER"):
2403
+ for pairs in (project_pairs, program_pairs):
2404
+ for slug, pair_role in pairs:
2405
+ if pair_role == role_name and self.users.get(slug) is not None:
2406
+ return self.users[slug]
2407
+ return self.owner
2408
+
2409
+ # --- v2.1 collaboration sections (#3603) --------------------------------
2410
+
2411
+ def _ts(self, value: str | None) -> datetime | None:
2412
+ """Resolve an optional seed timestamp. Timestamps are never weekend-snapped."""
2413
+ return resolve_timestamp(value, anchor=self.anchor) if value else None
2414
+
2415
+ def _create_acceptance_criteria(self, data: dict[str, Any]) -> None:
2416
+ """Materialize each task's acceptance criteria in authored order (#3492).
2417
+
2418
+ A criterion authored ``met`` carries its own trail. One ticked later, on a
2419
+ dated ``task.ac_met`` beat, is born unmet and replay flips it — so the rows
2420
+ are also indexed per task for the replay context to address by position.
2421
+ """
2422
+ slug = data["slug"]
2423
+ rows: list[AcceptanceCriterion] = []
2424
+ for task_data in data.get("tasks", []):
2425
+ authored = task_data.get("acceptance_criteria")
2426
+ if not authored:
2427
+ continue
2428
+ key = (slug, task_data["wbs_path"])
2429
+ per_task = self.criteria.setdefault(key, [])
2430
+ for position, entry in enumerate(authored):
2431
+ met = bool(entry.get("met", False))
2432
+ row = AcceptanceCriterion(
2433
+ task=self.tasks[key],
2434
+ text=entry["text"],
2435
+ given=entry.get("given", ""),
2436
+ when=entry.get("when", ""),
2437
+ then=entry.get("then", ""),
2438
+ met=met,
2439
+ position=position,
2440
+ met_by=(
2441
+ self.users.get(entry["met_by"]) if met and entry.get("met_by") else None
2442
+ ),
2443
+ met_at=self._ts(entry.get("met_at")) if met else None,
2444
+ )
2445
+ rows.append(row)
2446
+ per_task.append(row)
2447
+ self._bulk_insert(AcceptanceCriterion, rows, project_ids=[self.projects[slug].pk])
2448
+
2449
+ def _create_backlog_items(self, program: Program) -> None:
2450
+ """Seed the program backlog — the intake pool (ADR-0069, #3491).
2451
+
2452
+ Per-row saves rather than the batched path: a program carries a handful of
2453
+ items, and ``BacklogItem`` is program-scoped, so there is no project cursor
2454
+ to draw. A pulled item links the task it became, which is what the
2455
+ backlog's "pulled into …" chip and the task's source link both read.
2456
+ """
2457
+ for data in self.payload["program"].get("backlog_items", []):
2458
+ target = data.get("pulled_to")
2459
+ item = BacklogItem(
2460
+ program=program,
2461
+ title=data["title"],
2462
+ description=data.get("description", ""),
2463
+ item_type=data.get("item_type", "task"),
2464
+ status=data.get("status", "proposed"),
2465
+ tags=list(data.get("tags", [])),
2466
+ priority_rank=data.get("priority_rank"),
2467
+ story_points=data.get("story_points"),
2468
+ pulled_task=self._resolve_task_ref(target, "") if target else None,
2469
+ pulled_at=self._ts(data.get("pulled_at")) if target else None,
2470
+ pulled_by=(
2471
+ self.users.get(data["pulled_by"]) if target and data.get("pulled_by") else None
2472
+ ),
2473
+ created_by=(
2474
+ self.users.get(data["created_by"]) if data.get("created_by") else self.owner
2475
+ ),
2476
+ )
2477
+ item.save()
2478
+ created = self._ts(data.get("created_at"))
2479
+ if created is not None:
2480
+ # auto_now_add stamped import time; an intake item was raised when
2481
+ # the seed says it was.
2482
+ BacklogItem.objects.filter(pk=item.pk).update(created_at=created)
2483
+
2484
+ def _create_ceremonies(self, program: Program) -> None:
2485
+ """Seed the program's recurring ceremonies (ADR-0079, #3603).
2486
+
2487
+ Validation already refused sprint-event names and a missing clock anchor,
2488
+ mirroring ``CeremonyTemplateSerializer``; an on-milestone ceremony drops
2489
+ any day/time exactly as the serializer strips them.
2490
+ """
2491
+ for data in self.payload["program"].get("ceremonies", []):
2492
+ on_milestone = data["cadence_type"] == "on_milestone"
2493
+ CeremonyTemplate(
2494
+ program=program,
2495
+ name=data["name"].strip(),
2496
+ cadence_type=data["cadence_type"],
2497
+ cadence_day="" if on_milestone else data.get("cadence_day", ""),
2498
+ cadence_time=None if on_milestone else dt_time.fromisoformat(data["cadence_time"]),
2499
+ duration_minutes=data.get("duration_minutes", 60),
2500
+ owner_role=data.get("owner_role", ""),
2501
+ enabled=data.get("enabled", True),
2502
+ created_by=self.owner,
2503
+ ).save()
2504
+
2505
+ def _create_share_links(self) -> None:
2506
+ """Mint each sample project's share links (ADR-0245, #3603). Sample path only.
2507
+
2508
+ The token comes from ``mint_share_link`` here, at load; nothing in any file
2509
+ can choose it, and the raw value is discarded — only its hash is stored, as
2510
+ for every link. The link is live and revocable from the project's share
2511
+ settings, so the share surface is populated; ``create_demo_share_link`` is
2512
+ still how an operator obtains an openable URL.
2513
+ """
2514
+ if not self.is_sample:
2515
+ return
2516
+ from trueppm_api.apps.projects.share_services import mint_share_link
2517
+
2518
+ for project_data in self.payload["projects"]:
2519
+ project = self.projects[project_data["slug"]]
2520
+ for data in project_data.get("share_links", []):
2521
+ days = data.get("expires_in_days")
2522
+ creator = self.users.get(data["created_by"]) if data.get("created_by") else None
2523
+ mint_share_link(
2524
+ project,
2525
+ creator or self.owner,
2526
+ label=data.get("label", ""),
2527
+ show_assignees=data.get("show_assignees", False),
2528
+ show_milestone_dates=data.get("show_milestone_dates", True),
2529
+ content_kind=data.get("content_kind", "board"),
2530
+ expires_at=timezone.now() + timedelta(days=days) if days else None,
2531
+ )
2532
+
2533
+ def _agent_action_refs(self, data: dict[str, Any]) -> tuple[Task | None, Project | None]:
2534
+ """Resolve the task and project an authored agent action points at."""
2535
+ obj = data.get("object")
2536
+ task = self._resolve_task_ref(obj, "") if obj else None
2537
+ project = self.projects.get(data["project"]) if data.get("project") else None
2538
+ if project is None and task is not None:
2539
+ project = task.project
2540
+ return task, project
2541
+
2542
+ def _record_sample_agent_actions(self) -> None:
2543
+ """Write the sample's agent trail through the real audit chain (#3603). Sample only.
2544
+
2545
+ ``record_agent_action(sample=True)`` marks each row in hashed fields, because
2546
+ the row outlives its project: a reload hard-deletes the sample and SET_NULLs
2547
+ ``project``, after which only the marker says the row is demo data. Appended
2548
+ in ``at`` order so the chain's sequence and the narrated timeline agree.
2549
+ """
2550
+ if not self.is_sample:
2551
+ return
2552
+ from trueppm_api.apps.agents.services import record_agent_action
2553
+
2554
+ authored = self.payload["program"].get("agent_actions", [])
2555
+ ordered = sorted(
2556
+ range(len(authored)),
2557
+ key=lambda i: (resolve_timestamp(authored[i]["at"], anchor=self.anchor), i),
2558
+ )
2559
+ for i in ordered:
2560
+ data = authored[i]
2561
+ task, project = self._agent_action_refs(data)
2562
+ record_agent_action(
2563
+ actor_token=None,
2564
+ principal=self.users.get(data["principal"]) if data.get("principal") else None,
2565
+ action=data["action"],
2566
+ method=data.get("method", "GET"),
2567
+ capability_used=data.get("capability", "mcp:read"),
2568
+ verdict=data["verdict"],
2569
+ payload_hash=hashlib.sha256(
2570
+ json.dumps(data, sort_keys=True).encode("utf-8")
2571
+ ).hexdigest(),
2572
+ refusal_reason=data.get("refusal_reason", ""),
2573
+ refusal_constraint=data.get("refusal_constraint", ""),
2574
+ projected_impact=data.get("projected_impact"),
2575
+ object_type=data.get("object_type", "task" if task is not None else ""),
2576
+ object_id=str(task.pk) if task is not None else "",
2577
+ project_id=project.pk if project is not None else None,
2578
+ summary=data.get("summary", ""),
2579
+ occurred_at=resolve_timestamp(data["at"], anchor=self.anchor),
2580
+ sample=True,
2581
+ )
2582
+
2583
+ def _synthesize_time(self) -> None:
2584
+ """Fill logged time on sample work the timeline left unlogged (#3490). Sample only.
2585
+
2586
+ On a real import this would invent actuals in somebody's program, so it is
2587
+ confined to the bundled samples. For each COMPLETE or IN_PROGRESS work task
2588
+ with no authored ``time.log`` beat, every allocated person who has a login
2589
+ gets entries on most working days of the task's *actual* window (the dates
2590
+ replay stamped), sized ``hours_per_day × units`` with jitter — so the
2591
+ Timesheet page, the log-time popover and actual-vs-estimate read as a team
2592
+ that has been working.
2593
+
2594
+ Three bounds keep it honest: no entry on or after the anchor ("today"); a
2595
+ person's synthesized day never exceeds their calendar day, however many
2596
+ tasks overlap; and a task contributes at most ``_TIME_FILL_MAX_DAYS``. The
2597
+ RNG is seeded on program, task and username — never a UUID or the clock —
2598
+ so a reload reproduces the same hours.
2599
+ """
2600
+ if not (self.is_sample and self.replay):
2601
+ return
2602
+ authored = self._authored_time_log_keys()
2603
+ allocations = self._task_resource_allocations()
2604
+
2605
+ program_code = self.payload["program"]["slug"]
2606
+ last_day = self.anchor - timedelta(days=1)
2607
+ used: dict[tuple[Any, date], int] = defaultdict(int)
2608
+ by_project: dict[Any, list[TimeEntry]] = defaultdict(list)
2609
+ for key, task in self.tasks.items():
2610
+ if key in authored or self.final_status.get(key) not in ("COMPLETE", "IN_PROGRESS"):
2611
+ continue
2612
+ entries = self._synthesize_task_time_entries(
2613
+ key, task, allocations, program_code, last_day, used
2614
+ )
2615
+ if entries:
2616
+ by_project[self.projects[key[0]].pk].extend(entries)
2617
+ for project_id, rows in by_project.items():
2618
+ self._bulk_insert(TimeEntry, rows, project_ids=[project_id])
2619
+
2620
+ def _authored_time_log_keys(self) -> set[tuple[str, str]]:
2621
+ """(project_slug, wbs) pairs already carrying an authored ``time.log`` beat."""
2622
+ authored: set[tuple[str, str]] = set()
2623
+ for event in self.payload.get("events", []):
2624
+ if event.get("action") == "time.log":
2625
+ _, _, ref = event.get("target", "").partition(":")
2626
+ project_slug, _, wbs = ref.partition(":")
2627
+ authored.add((project_slug, wbs))
2628
+ return authored
2629
+
2630
+ def _task_resource_allocations(self) -> dict[Any, list[TaskResource]]:
2631
+ allocations: dict[Any, list[TaskResource]] = defaultdict(list)
2632
+ for row in TaskResource.objects.filter(
2633
+ task_id__in=[task.pk for task in self.tasks.values()],
2634
+ resource__user__isnull=False,
2635
+ ).select_related("resource__user"):
2636
+ allocations[row.task_id].append(row)
2637
+ return allocations
2638
+
2639
+ def _synthesize_task_time_entries(
2640
+ self,
2641
+ key: tuple[str, str],
2642
+ task: Task,
2643
+ allocations: dict[Any, list[TaskResource]],
2644
+ program_code: str,
2645
+ last_day: date,
2646
+ used: dict[tuple[Any, date], int],
2647
+ ) -> list[TimeEntry]:
2648
+ """The ``TimeEntry`` rows synthesized for one task, or ``[]`` if it's out of scope.
2649
+
2650
+ Mutates `used` with every minute it allocates, so later tasks/allocations
2651
+ sharing a (user, day) see the day already partly spent.
2652
+ """
2653
+ if task.actual_start is None or getattr(task, "structure_role", "work") != "work":
2654
+ return []
2655
+ end = min(task.actual_finish or last_day, last_day)
2656
+ if end < task.actual_start:
2657
+ return []
2658
+ project = self.projects[key[0]]
2659
+ calendar = self._wc(key[0])
2660
+ hours = float(project.calendar.hours_per_day) if project.calendar is not None else 8.0
2661
+ day_cap = int(hours * 60 * 1.1)
2662
+ entries: list[TimeEntry] = []
2663
+ for allocation in allocations.get(task.pk, []):
2664
+ user = allocation.resource.user
2665
+ if user is None: # filtered out by the query; narrows the type
2666
+ continue
2667
+ rng = random.Random(f"{program_code}:{key[0]}:{key[1]}:{user.get_username()}")
2668
+ day = max(task.actual_start, end - timedelta(days=_TIME_FILL_MAX_DAYS))
2669
+ while day <= end:
2670
+ if calendar.is_working_day(day) and rng.random() < 0.85:
2671
+ wanted = hours * 60 * float(allocation.units) * rng.uniform(0.6, 1.1)
2672
+ minutes = min(round(wanted / 15) * 15, day_cap - used[(user.pk, day)])
2673
+ if minutes >= 15:
2674
+ used[(user.pk, day)] += minutes
2675
+ entries.append(
2676
+ TimeEntry(task=task, user=user, minutes=minutes, entry_date=day)
2677
+ )
2678
+ day += timedelta(days=1)
2679
+ return entries
2680
+
2681
+ def _synthesize_timesheet_submissions(self) -> None:
2682
+ """Submit every fully elapsed week a sample persona logged time in (#3490). Sample only.
2683
+
2684
+ ``TimesheetSubmission`` is per user and week, not per program, and personas
2685
+ persist across reloads — so an existing row is kept, never duplicated. The
2686
+ week containing the anchor is never submitted: it has not ended.
2687
+ """
2688
+ if not self.is_sample:
2689
+ return
2690
+ this_monday = self.anchor - timedelta(days=self.anchor.weekday())
2691
+ logged = TimeEntry.objects.filter(
2692
+ task__project__in=list(self.projects.values()),
2693
+ entry_date__lt=this_monday,
2694
+ is_deleted=False,
2695
+ ).values_list("user_id", "entry_date")
2696
+ weeks = {(user_id, day - timedelta(days=day.weekday())) for user_id, day in logged}
2697
+ existing = set(
2698
+ TimesheetSubmission.objects.filter(
2699
+ user_id__in={user_id for user_id, _ in weeks}
2700
+ ).values_list("user_id", "week_start")
2701
+ )
2702
+ rows = [
2703
+ TimesheetSubmission(
2704
+ user_id=user_id,
2705
+ week_start=monday,
2706
+ # Friday 17:00 UTC of that week — submitted when the week closed.
2707
+ submitted_at=datetime(monday.year, monday.month, monday.day, 17, 0, tzinfo=_UTC)
2708
+ + timedelta(days=4),
2709
+ )
2710
+ for user_id, monday in sorted(weeks, key=lambda pair: (str(pair[0]), pair[1]))
2711
+ if (user_id, monday) not in existing
2712
+ ]
2713
+ TimesheetSubmission.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
2714
+
2715
+ def _create_program_risks(self, program: Program) -> None:
2716
+ """Program-scoped risks attach to the first project (Risk is project-FK).
2717
+
2718
+ TruePPM has no program-level Risk model; a program-scoped seed risk is
2719
+ materialized on the lead project but may link tasks across projects.
2720
+ """
2721
+ risks = self.payload.get("risks", [])
2722
+ if not risks:
2723
+ return
2724
+ lead_project = next(iter(self.projects.values()))
2725
+ self._create_risks(lead_project, risks, enclosing_project=None)
2726
+
2727
+ def _create_risks(
2728
+ self, project: Project, risks: list[dict[str, Any]], enclosing_project: str | None
2729
+ ) -> None:
2730
+ for data in risks:
2731
+ risk = Risk(
2732
+ project=project,
2733
+ title=data["title"],
2734
+ description=data.get("description", ""),
2735
+ status=data["status"],
2736
+ probability=data["probability"],
2737
+ impact=data["impact"],
2738
+ category=data.get("category"),
2739
+ response=data.get("response"),
2740
+ mitigation_due_date=self._date_opt(
2741
+ data.get("mitigation_due_date"), enclosing_project
2742
+ ),
2743
+ trigger=data.get("trigger", ""),
2744
+ contingency=data.get("contingency", ""),
2745
+ notes=data.get("notes", ""),
2746
+ owner=self.users.get(data["owner"]) if data.get("owner") else None,
2747
+ )
2748
+ # Backdate the creation history row to the project start under replay
2749
+ # (risks are identified at kickoff), so a risk that the events timeline
2750
+ # walks through a status lifecycle reads chronologically — the "opened"
2751
+ # row precedes its dated transitions rather than landing at import time.
2752
+ self._save_new(risk, project.start_date)
2753
+ # Slug map lets risk.status replay beats resolve their target.
2754
+ if data.get("slug"):
2755
+ self.risks_by_slug[data["slug"]] = risk
2756
+ # Risk itself stays a per-row save so the ``risk_changed`` signal
2757
+ # fires; risk counts are in the tens, so there is nothing to win.
2758
+ risk_task_rows = [
2759
+ RiskTask(risk=risk, task=self._resolve_task_ref(ref, enclosing_project or ""))
2760
+ # Program-scoped risks (enclosing_project is None) always carry
2761
+ # qualified refs, so the enclosing fallback is never consulted.
2762
+ for ref in data.get("tasks", [])
2763
+ ]
2764
+ self._bulk_insert(RiskTask, risk_task_rows)