trueppm-api 0.4.0b2__py3-none-any.whl

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