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,1644 @@
1
+ """Export a live program to the canonical JSON seed format (ADR-0109, issue #616).
2
+
3
+ ``export_program`` is the inverse of the importer: it walks a program and emits
4
+ a seed document that re-validates and re-imports. The exporter is deterministic
5
+ — arrays are emitted in a stable order and UUIDs are replaced with derived
6
+ slugs — so the round-trip ``export(import(export(p)))`` is identical to
7
+ ``export(import(p))`` (the #616 guarantee). It strips every derived field
8
+ (server_version, CPM outputs, short_id) by simply never emitting them.
9
+
10
+ By default it emits **v1** (``schema_version: "1.0"``, final-state) so the #616
11
+ byte-identical round-trip is preserved unchanged. Passing ``with_events=True``
12
+ emits **v2** (ADR-0114 §7 / #1109): dates are rewritten as ``anchor``-relative
13
+ offsets and an ordered ``events`` timeline is reconstructed from the history
14
+ tables so a shared program re-imports as the *life* it lived, not a snapshot.
15
+ The v2 path is a self-consistent fixpoint — ``export→import→export`` is
16
+ byte-identical — because every reconstructed event replays to a state that
17
+ reconstructs to the same event, and the importer's synthesizer is naturally
18
+ suppressed (every task with history already carries authored status events).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass, field
27
+ from datetime import date, datetime
28
+ from typing import Any
29
+ from zoneinfo import ZoneInfo
30
+
31
+ from trueppm_api.apps.access.models import ProgramMembership, ProjectMembership, Role
32
+ from trueppm_api.apps.projects.models import (
33
+ AcceptanceCriterion,
34
+ BacklogItem,
35
+ Baseline,
36
+ BaselineTask,
37
+ BoardColumnConfig,
38
+ CeremonyTemplate,
39
+ CommentAcknowledgement,
40
+ CommentReaction,
41
+ Dependency,
42
+ Label,
43
+ Program,
44
+ Project,
45
+ RetroActionItem,
46
+ Risk,
47
+ RiskComment,
48
+ ScopeChangeStatus,
49
+ Sprint,
50
+ SprintRetro,
51
+ SprintScopeChange,
52
+ Task,
53
+ TaskComment,
54
+ TaskLabel,
55
+ TaskNote,
56
+ TaskRecurrenceRule,
57
+ TaskRelation,
58
+ )
59
+ from trueppm_api.apps.resources.models import (
60
+ ProjectResource,
61
+ Resource,
62
+ ResourceSkill,
63
+ TaskResource,
64
+ TaskSkillRequirement,
65
+ )
66
+
67
+ _UTC = ZoneInfo("UTC")
68
+
69
+ # Sentinel distinguishing "no HTTP caller at all" (a management command, run by
70
+ # an operator with direct DB/shell access — already a higher trust tier than any
71
+ # API role) from "an HTTP caller whose user object is falsy" (an async export
72
+ # job's ``requested_by`` can go ``NULL`` via ``on_delete=SET_NULL`` if the
73
+ # requesting account is deleted between enqueue and build). Only the sentinel is
74
+ # treated as trusted; a literal ``None`` from a real request lineage is not
75
+ # (#3627) — see ``_Exporter._resolve_email_visibility``.
76
+ _NO_HTTP_CALLER = object()
77
+
78
+
79
+ @dataclass(order=True)
80
+ class _RawEvent:
81
+ """A reconstructed event before serialization, sortable into replay order.
82
+
83
+ Sort key is ``(when, target, action, seq)``: chronological, with a fully
84
+ deterministic tiebreak so re-export is byte-identical. ``data`` (actor +
85
+ action-specific keys) is excluded from the ordering.
86
+ """
87
+
88
+ when: datetime
89
+ target: str
90
+ action: str
91
+ seq: int
92
+ data: dict[str, Any] = field(compare=False, default_factory=dict)
93
+
94
+
95
+ # Signature of the ``emit`` callback the reconstruction helpers are handed.
96
+ _Emit = Callable[[datetime, str, str, dict[str, Any]], None]
97
+
98
+ # seedDate / seedTimestamp cap a relative offset at 4 digits (~27 years); a date
99
+ # further from the anchor than this falls back to an ISO literal (still a valid
100
+ # seedDate/seedTimestamp) so a very long-lived program still exports cleanly.
101
+ _MAX_REL_OFFSET_DAYS = 9999
102
+
103
+ _ROLE_NAME: dict[int, str] = {
104
+ Role.VIEWER: "VIEWER",
105
+ Role.MEMBER: "MEMBER",
106
+ Role.SCHEDULER: "SCHEDULER",
107
+ Role.ADMIN: "ADMIN",
108
+ Role.OWNER: "OWNER",
109
+ }
110
+
111
+
112
+ #: Event actions that only exist from schema 2.1 (#3603).
113
+ _V21_EVENT_ACTIONS = frozenset({"task.note", "task.react", "task.ack", "time.log"})
114
+
115
+
116
+ #: Weekday names in bitmask order — the authoring shape of a recurrence rule (#3498).
117
+ _WEEKDAY_BITS = (
118
+ ("mon", 1),
119
+ ("tue", 2),
120
+ ("wed", 4),
121
+ ("thu", 8),
122
+ ("fri", 16),
123
+ ("sat", 32),
124
+ ("sun", 64),
125
+ )
126
+
127
+ #: ``Proficiency`` integer -> the seed's authoring name (#3498).
128
+ _PROFICIENCY_NAME = {1: "beginner", 2: "intermediate", 3: "expert"}
129
+
130
+ #: Task keys that only exist from schema 2.1.
131
+ _V21_TASK_KEYS = ("acceptance_criteria", "is_subtask", "recurrence", "skill_requirements")
132
+
133
+
134
+ def _uses_v21(doc: dict[str, Any]) -> bool:
135
+ """Whether an exported document carries any construct introduced in 2.1."""
136
+ program = doc.get("program", {})
137
+ if program.get("backlog_items") or program.get("ceremonies"):
138
+ return True
139
+ if any(resource.get("skills") for resource in doc.get("resources", [])):
140
+ return True
141
+ for project in doc.get("projects", []):
142
+ if project.get("team"):
143
+ return True
144
+ for task in project.get("tasks", []):
145
+ if task.get("type") == "tech_debt" or any(task.get(k) for k in _V21_TASK_KEYS):
146
+ return True
147
+ return any(
148
+ event.get("action") in _V21_EVENT_ACTIONS or "slug" in event or "reply_to" in event
149
+ for event in doc.get("events", [])
150
+ )
151
+
152
+
153
+ def dump_seed(payload: dict[str, Any]) -> str:
154
+ """Canonical serialization: sorted keys, 2-space indent, trailing newline.
155
+
156
+ Centralised so the management command, the REST endpoint, and the round-trip
157
+ test all emit byte-identical output.
158
+ """
159
+ return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
160
+
161
+
162
+ # The seed schema caps every ``slug`` at 40 chars (so a program slug still fits
163
+ # ``Program.code``). Slugs are derived from free-text titles/names, so the
164
+ # exporter must enforce the cap itself — a risk titled longer than ~40 chars
165
+ # would otherwise emit a slug the importer's own schema rejects (#2006).
166
+ _MAX_SLUG_LEN = 40
167
+
168
+
169
+ def _slugify(text: str) -> str:
170
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
171
+ # Truncate to the schema cap, then re-strip so a cut mid-separator can't
172
+ # leave a trailing "-" that violates the kebab-case pattern.
173
+ return slug[:_MAX_SLUG_LEN].strip("-") or "x"
174
+
175
+
176
+ class _SlugAllocator:
177
+ """Assigns unique kebab slugs deterministically (suffix on collision)."""
178
+
179
+ def __init__(self) -> None:
180
+ self._used: set[str] = set()
181
+
182
+ def take(self, text: str) -> str:
183
+ base = _slugify(text)
184
+ candidate = base
185
+ i = 2
186
+ while candidate in self._used:
187
+ suffix = f"-{i}"
188
+ # Keep the disambiguated slug within the 40-char cap by trimming the
189
+ # base to make room for the suffix (and re-stripping any dangling "-").
190
+ candidate = base[: _MAX_SLUG_LEN - len(suffix)].strip("-") + suffix
191
+ i += 1
192
+ self._used.add(candidate)
193
+ return candidate
194
+
195
+
196
+ def _put(target: dict[str, Any], key: str, value: Any) -> None:
197
+ """Emit a key only when meaningful — skips None and empty strings."""
198
+ if value is None or value == "":
199
+ return
200
+ target[key] = value
201
+
202
+
203
+ def export_program(
204
+ program: Program,
205
+ *,
206
+ with_events: bool = False,
207
+ requesting_user: Any = _NO_HTTP_CALLER,
208
+ ) -> dict[str, Any]:
209
+ """Serialize ``program`` to a canonical seed document (a ``dict``).
210
+
211
+ Args:
212
+ program: the program to export.
213
+ with_events: when ``False`` (default) emit a v1 final-state document,
214
+ preserving the #616 byte-identical round-trip. When ``True`` emit a
215
+ v2 document (ADR-0114 §7 / #1109) with ``anchor``-relative dates and
216
+ a reconstructed ``events`` timeline, so the export re-imports through
217
+ the replay engine as the program's dated life rather than a snapshot.
218
+ requesting_user: the account the export is being produced *for* (#3627).
219
+ ``Resource.email`` and account ``email`` are withheld unless this
220
+ user holds workspace Admin+ — see
221
+ ``_Exporter._resolve_email_visibility``. Omit entirely for a caller
222
+ with no HTTP request context (e.g. the management command, run by an
223
+ operator with shell/DB access already); that is treated as trusted
224
+ and email is included unredacted. A literal ``None`` (an async
225
+ export job whose ``requested_by`` went ``NULL``) is deliberately
226
+ *not* trusted and redacts.
227
+ """
228
+ projects = list(
229
+ Project.objects.filter(program=program, is_deleted=False).order_by("code", "name", "pk")
230
+ )
231
+ return _Exporter(
232
+ program=program,
233
+ projects=projects,
234
+ with_events=with_events,
235
+ requesting_user=requesting_user,
236
+ ).build()
237
+
238
+
239
+ def export_project(project: Project, *, requesting_user: Any = _NO_HTTP_CALLER) -> dict[str, Any]:
240
+ """Serialize a single ``project`` to a canonical seed document (#967).
241
+
242
+ The canonical schema requires a top-level ``program`` block (ADR-0109) but
243
+ ``Project.program`` is nullable (ADR-0070 — standalone projects are
244
+ first-class), so the project is emitted inside a minimal program *synthesized
245
+ from the project itself* (slug/name/methodology). This is uniform whether or
246
+ not the live project belongs to a program, and because the synthesized slug
247
+ is project-derived rather than the parent program's ``code``, re-importing a
248
+ project export creates a fresh program instead of clobbering the live parent
249
+ program's subtree. The #616 round-trip guarantee still holds. See the #967
250
+ addendum in ADR-0109.
251
+
252
+ Args:
253
+ project: the project to export.
254
+ requesting_user: see :func:`export_program` — controls whether
255
+ ``Resource.email`` / account ``email`` are withheld (#3627).
256
+ """
257
+ return _Exporter(
258
+ program=None,
259
+ projects=[project],
260
+ synthetic_program=project,
261
+ requesting_user=requesting_user,
262
+ ).build()
263
+
264
+
265
+ class _Exporter:
266
+ def __init__(
267
+ self,
268
+ *,
269
+ program: Program | None,
270
+ projects: list[Project],
271
+ synthetic_program: Project | None = None,
272
+ with_events: bool = False,
273
+ requesting_user: Any = _NO_HTTP_CALLER,
274
+ ) -> None:
275
+ # ``program`` is the live parent program for a program export, or None
276
+ # for a single-project export (#967), in which case ``synthetic_program``
277
+ # (the project) sources the synthesized program-wrapper block.
278
+ self.program = program
279
+ self.synthetic_program = synthetic_program
280
+ self.projects = projects
281
+ # #3627: whether this export includes real Resource/account email.
282
+ # ``_resources_block``/``_account_entry`` never reach ``ResourceSerializer``
283
+ # (the #891 harvest control), so the exporter enforces the same invariant
284
+ # itself rather than inheriting a serializer it does not use. The
285
+ # endpoint-level gate (IsProjectAdmin/IsProgramAdmin) stays project- or
286
+ # program-scoped and self-grantable by design (creating a project makes
287
+ # you its Owner) — that is what makes the field a workspace-wide harvest
288
+ # once any project Admin can pull the whole catalog into an export. So
289
+ # email is workspace-Admin-only *content*, independent of who can reach
290
+ # the export action at all. See docs/adr/0034 (#3569 amendment) and #3627.
291
+ self._email_visible = self._resolve_email_visibility(requesting_user)
292
+ # v2 event-timeline export (#1109). When on, dates become anchor-relative
293
+ # and an ``events`` array is reconstructed from the history tables.
294
+ self.with_events = with_events
295
+ # The reference date for relative offsets (v2 only). The earliest project
296
+ # start is a deterministic, always-present choice (projects require a
297
+ # start_date), and it round-trips: re-import resolves the same absolute
298
+ # dates, so re-export recomputes the same anchor.
299
+ self.anchor: date = min(
300
+ (p.start_date for p in projects if p.start_date is not None), default=date.today()
301
+ )
302
+ # Account identity, captured once accounts are built, so an event actor
303
+ # always resolves to a real account slug (never a dangling ref).
304
+ self._account_user_pks: set[Any] = set()
305
+ self._fallback_actor_slug: str = ""
306
+ self.project_slug = _SlugAllocator()
307
+ self.account_slug = _SlugAllocator()
308
+ self.calendar_slug = _SlugAllocator()
309
+ self.resource_slug = _SlugAllocator()
310
+ # v2.1 comment slugs (#3493), minted only for a comment something refers to
311
+ # (a reply, reaction or acknowledgement). Document-wide, so two tasks'
312
+ # comments can never collide after the 40-char truncation.
313
+ self.comment_slug = _SlugAllocator()
314
+
315
+ # pk -> slug maps, built once so cross-references resolve consistently.
316
+ self.user_slugs: dict[Any, str] = {}
317
+ self.calendar_slugs: dict[Any, str] = {}
318
+ self.resource_slugs: dict[Any, str] = {}
319
+ self.project_slugs: dict[Any, str] = {}
320
+ # task pk -> (project_slug, wbs_path) for global task refs.
321
+ self.task_ref: dict[Any, tuple[str, str]] = {}
322
+ # sprint pk -> slug, scoped per project.
323
+ self.sprint_slugs: dict[Any, str] = {}
324
+ # Risk slugs are allocated per project inside ``_risk_blocks``; recorded
325
+ # here so ``risk.note`` events can target them. Projects are serialized
326
+ # before events, so this is populated by the time the timeline is built.
327
+ self.risk_slugs: dict[Any, str] = {}
328
+ # task pk -> [label slug, …], populated per project in ``_project_block``
329
+ # (ADR-0400 labels folded into the seed, #1958). Label slugs are
330
+ # project-scoped, matching the slug-based (no-UUID) seed contract.
331
+ self.task_label_slugs: dict[Any, list[str]] = {}
332
+ # memoized — _all_resources is consulted by three blocks.
333
+ self._resources_cache: list[Resource] | None = None
334
+
335
+ @staticmethod
336
+ def _resolve_email_visibility(requesting_user: Any) -> bool:
337
+ """True when ``requesting_user`` may see real ``email`` values (#3627).
338
+
339
+ The ``_NO_HTTP_CALLER`` sentinel (the parameter default) means the caller
340
+ has no HTTP request context at all — the management command, run by an
341
+ operator with direct DB/shell access, already a higher trust tier than
342
+ any API role — and is treated as trusted. Every other value, including a
343
+ literal ``None``, goes through the real check: an HTTP-driven export
344
+ passes ``request.user`` (always a real, authenticated user), while an
345
+ async export-job builder passes ``job.requested_by``, which can be
346
+ ``None`` if the requesting account was deleted between enqueue and
347
+ build — and a gone account must not fall back to trusted. Email is
348
+ visible only at workspace Admin+ (``WorkspaceRole.ADMIN``), matching the
349
+ invariant the #3569 amendment records for the other three email-bearing
350
+ routes: the endpoint stays reachable at project/program Admin+
351
+ (self-grantable by creating a project), so the field itself — not the
352
+ endpoint — has to be the workspace-wide gate.
353
+ """
354
+ if requesting_user is _NO_HTTP_CALLER:
355
+ return True
356
+ # Imported locally (not at module scope) to match the existing
357
+ # cross-app-import convention in this package (see attachment_policy.py,
358
+ # calendar_settings.py, etc.) and avoid a load-order dependency on the
359
+ # workspace app from the projects app.
360
+ from trueppm_api.apps.workspace.models import WorkspaceRole
361
+ from trueppm_api.apps.workspace.permissions import workspace_role_for_user
362
+
363
+ role = workspace_role_for_user(requesting_user)
364
+ return role is not None and role >= WorkspaceRole.ADMIN
365
+
366
+ # --- public ------------------------------------------------------------
367
+
368
+ def build(self) -> dict[str, Any]:
369
+ for project in self.projects:
370
+ self.project_slugs[project.pk] = self.project_slug.take(project.code or project.name)
371
+
372
+ # Pre-pass: index every task and sprint across ALL projects before
373
+ # emitting any block, so cross-project dependency and risk refs resolve
374
+ # regardless of project ordering.
375
+ self._project_tasks: dict[Any, list[Task]] = {}
376
+ self._project_sprints: dict[Any, list[Sprint]] = {}
377
+ for project in self.projects:
378
+ self._index_project(project)
379
+
380
+ doc: dict[str, Any] = {"schema_version": "2.0" if self.with_events else "1.0"}
381
+ if self.with_events:
382
+ # An explicit anchor pins the offsets so re-import resolves the exact
383
+ # same absolute dates (round-trip determinism), rather than drifting
384
+ # to the next import day.
385
+ doc["anchor"] = self.anchor.isoformat()
386
+ doc["program"] = self._program_block()
387
+ accounts = self._accounts_block()
388
+ # Capture account identity before events so an event actor resolves to a
389
+ # real account slug and the deterministic fallback (OWNER) is known.
390
+ self._capture_account_identity(accounts)
391
+ calendars = self._calendars_block()
392
+ resources = self._resources_block()
393
+ if accounts:
394
+ doc["accounts"] = accounts
395
+ if calendars:
396
+ doc["calendars"] = calendars
397
+ if resources:
398
+ doc["resources"] = resources
399
+ doc["projects"] = [self._project_block(p) for p in self.projects]
400
+ if self.with_events:
401
+ self._put_program_v21(doc["program"])
402
+ events = self._events_block()
403
+ if events:
404
+ doc["events"] = events
405
+ # Declare 2.1 only when the document uses a 2.1 construct, so a program
406
+ # with none still exports as 2.0 and stays importable by a 2.0 build.
407
+ if _uses_v21(doc):
408
+ doc["schema_version"] = "2.1"
409
+ return doc
410
+
411
+ def _put_program_v21(self, block: dict[str, Any]) -> None:
412
+ """Program backlog and ceremonies (v2.1, #3491/#3603). v2 export only.
413
+
414
+ Built after account identity is captured, so ``pulled_by``/``created_by``
415
+ can be restricted to users that already hold an account slug. Agent actions
416
+ and share links are deliberately never exported: one is audit evidence and
417
+ the other a credential, and every non-sample import rejects both.
418
+ """
419
+ if self.program is None:
420
+ return
421
+ items = sorted(
422
+ BacklogItem.objects.filter(program=self.program, is_deleted=False),
423
+ key=lambda i: (i.priority_rank is None, i.priority_rank or 0, i.created_at, i.title),
424
+ )
425
+ allocator = _SlugAllocator()
426
+ backlog: list[dict[str, Any]] = []
427
+ for item in items:
428
+ entry: dict[str, Any] = {"slug": allocator.take(item.title), "title": item.title}
429
+ _put(entry, "description", item.description)
430
+ entry["item_type"] = item.item_type
431
+ entry["status"] = item.status
432
+ if item.tags:
433
+ entry["tags"] = list(item.tags)
434
+ _put(entry, "priority_rank", item.priority_rank)
435
+ _put(entry, "story_points", item.story_points)
436
+ ref = self.task_ref.get(item.pulled_task_id) if item.pulled_task_id else None
437
+ if ref is not None:
438
+ entry["pulled_to"] = f"{ref[0]}:{ref[1]}"
439
+ if item.pulled_at is not None:
440
+ entry["pulled_at"] = self._rel_ts(item.pulled_at)
441
+ if item.pulled_by_id in self._account_user_pks:
442
+ entry["pulled_by"] = self._user_slug(item.pulled_by)
443
+ elif item.status == "pulled":
444
+ # The task it became is gone or unexportable; a "pulled" item that
445
+ # names no task would fail validation, so it re-imports as archived.
446
+ entry["status"] = "archived"
447
+ if item.created_by_id in self._account_user_pks:
448
+ entry["created_by"] = self._user_slug(item.created_by)
449
+ entry["created_at"] = self._rel_ts(item.created_at)
450
+ backlog.append(entry)
451
+ if backlog:
452
+ block["backlog_items"] = backlog
453
+
454
+ ceremonies: list[dict[str, Any]] = []
455
+ for ceremony in CeremonyTemplate.objects.filter(
456
+ program=self.program, is_deleted=False
457
+ ).order_by("name", "pk"):
458
+ entry = {"name": ceremony.name, "cadence_type": ceremony.cadence_type}
459
+ _put(entry, "cadence_day", ceremony.cadence_day)
460
+ if ceremony.cadence_time is not None:
461
+ entry["cadence_time"] = ceremony.cadence_time.strftime("%H:%M")
462
+ entry["duration_minutes"] = ceremony.duration_minutes
463
+ _put(entry, "owner_role", ceremony.owner_role)
464
+ if not ceremony.enabled:
465
+ entry["enabled"] = False
466
+ ceremonies.append(entry)
467
+ if ceremonies:
468
+ block["ceremonies"] = ceremonies
469
+
470
+ # --- top-level blocks --------------------------------------------------
471
+
472
+ def _program_block(self) -> dict[str, Any]:
473
+ if self.program is None:
474
+ # Single-project export (#967): synthesize a minimal program wrapper
475
+ # from the project so the seed satisfies the schema's required
476
+ # ``program`` block while staying decoupled from any live parent
477
+ # program (re-import won't clobber it). The slug is slugified to keep
478
+ # it schema-valid (kebab-case) since Project.code is free-form.
479
+ proj = self.synthetic_program
480
+ assert proj is not None # always set when self.program is None
481
+ block: dict[str, Any] = {
482
+ "slug": _slugify(proj.code or proj.name),
483
+ "name": proj.name,
484
+ "methodology": proj.methodology,
485
+ }
486
+ _put(block, "description", proj.description)
487
+ return block
488
+ block = {
489
+ "slug": self.program.code or self.project_slug.take(self.program.name),
490
+ "name": self.program.name,
491
+ "methodology": self.program.methodology,
492
+ }
493
+ _put(block, "description", self.program.description)
494
+ _put(block, "color", self.program.color)
495
+ if self.program.lead_id is not None:
496
+ block["lead"] = self._user_slug(self.program.lead)
497
+ return block
498
+
499
+ def _accounts_block(self) -> list[dict[str, Any]]:
500
+ # Every user referenced anywhere in the export needs an account so it
501
+ # re-imports. Membership roles come from ProgramMembership. A single-
502
+ # project export (#967) has no parent program, so the roster is empty and
503
+ # no account carries a program-level role — but the project's own live
504
+ # members are still emitted (#3457), alongside the users it references
505
+ # through task assignments, resource accounts and risk ownership.
506
+ roles, users = self._collect_account_users()
507
+ accounts = [self._account_entry(uid, user, roles) for uid, user in users.items()]
508
+ return sorted(accounts, key=lambda a: a["slug"])
509
+
510
+ def _collect_account_users(self) -> tuple[dict[Any, int], dict[Any, Any]]:
511
+ """Gather every user referenced by the export, in a stable order.
512
+
513
+ Insertion order (program roster, then task assignees, risk owners,
514
+ resource accounts, then live project members) fixes slug-allocation
515
+ order, so the roster must be walked in exactly this sequence for the
516
+ round-trip to stay byte-stable.
517
+ """
518
+ users: dict[Any, Any] = {}
519
+ roles = self._program_roster(users)
520
+ # Project leads (#3098) come before task assignees so a lead who is
521
+ # referenced nowhere else still gets an account entry — without one the
522
+ # exported ``project.lead`` slug would dangle and the document would not
523
+ # re-import. Inserted here rather than appended because insertion order
524
+ # fixes slug allocation, and the round-trip is asserted byte-identical.
525
+ for proj in self.projects:
526
+ if proj.lead_id is not None:
527
+ users[proj.lead_id] = proj.lead
528
+ for task in self._all_tasks():
529
+ if task.assignee_id is not None:
530
+ users[task.assignee_id] = task.assignee
531
+ for risk in Risk.objects.filter(project__in=self.projects).select_related("owner"):
532
+ if risk.owner_id is not None:
533
+ users[risk.owner_id] = risk.owner
534
+ for res in self._all_resources():
535
+ if res.user_id is not None:
536
+ users[res.user_id] = res.user
537
+ self._add_project_member_users(users)
538
+ return roles, users
539
+
540
+ def _add_project_member_users(self, users: dict[Any, Any]) -> None:
541
+ """Add every live project member not already present, walking the projects in order."""
542
+ # Live *project* members, last (#3457). Project and program membership are
543
+ # independent grants (ADR-0070 §RBAC), so a user revoked from the program
544
+ # can still hold a live ProjectMembership. Now that the roster no longer
545
+ # carries revoked members, such a user would reach _member_blocks with no
546
+ # slug and their project grant would be silently dropped from the
547
+ # document — access lost rather than access leaked, but lost all the same.
548
+ # Appended after every existing source rather than inserted beside the
549
+ # roster: this can only add users that would otherwise carry no slug at
550
+ # all, so no already-allocated slug moves and the byte-stable fixpoint is
551
+ # untouched. One query, then regrouped and walked in ``self.projects``
552
+ # order and by username, because insertion order fixes slug allocation
553
+ # and a database's natural row order does not.
554
+ #
555
+ # Ordered by ``user__username`` and NOT by ``user_id``: the slug is
556
+ # derived from the username and two usernames can slugify to the same
557
+ # base, in which case ``_SlugAllocator`` suffixes them ``-2``/``-3`` in
558
+ # *call* order. ``username`` round-trips verbatim through the document;
559
+ # the user PK does not — a re-import mints new users in ``accounts[]``
560
+ # order — so ordering on the id would let a colliding pair swap suffixes
561
+ # on re-export and break the byte-identical fixpoint (#616).
562
+ members_by_project: dict[Any, list[Any]] = {}
563
+ for pm in (
564
+ ProjectMembership.objects.filter(project__in=self.projects, is_deleted=False)
565
+ .select_related("user")
566
+ .order_by("user__username")
567
+ ):
568
+ members_by_project.setdefault(pm.project_id, []).append(pm)
569
+ for proj in self.projects:
570
+ for pm in members_by_project.get(proj.pk, ()):
571
+ users.setdefault(pm.user_id, pm.user)
572
+
573
+ def _program_roster(self, users: dict[Any, Any]) -> dict[Any, int]:
574
+ """Seed ``users`` with the program lead + members; return their roles.
575
+
576
+ A single-project export (#967) has no parent program, so the roster is
577
+ empty and no program-level roles apply.
578
+
579
+ Both ``ProgramMembership`` reads floor on ``is_deleted=False`` (#3457),
580
+ matching the project-side twin in ``_member_blocks``. A revoked membership
581
+ is a tombstone that still owns the ``(program, user)`` slot, and the
582
+ importer's grant deliberately clears that tombstone (``is_deleted: False``)
583
+ so a seed grant always confers access (#3410) — so any revoked member left
584
+ in the roster is handed their role back on import. The floor has to be
585
+ here, on the export side: a revoked member must not be in the document at
586
+ all.
587
+
588
+ The ``lead`` read below is **deliberately not floored**, and must not be
589
+ "fixed" for symmetry. ``Program.lead`` is a display FK distinct from the
590
+ OWNER membership, ``_program_block`` emits its slug unconditionally, and
591
+ ``validate_seed`` requires ``$.program.lead`` to resolve to an
592
+ ``accounts[]`` entry — so dropping a lead whose *membership* was revoked
593
+ would emit a document that fails the exporter's own validator. It confers
594
+ nothing either way: the account is emitted without a ``role`` key, and the
595
+ importer's grant skips a roleless account.
596
+ """
597
+ if self.program is None:
598
+ return {}
599
+ roles = {
600
+ m.user_id: m.role
601
+ for m in ProgramMembership.objects.filter(program=self.program, is_deleted=False)
602
+ }
603
+ if self.program.lead_id is not None:
604
+ users[self.program.lead_id] = self.program.lead
605
+ for m in ProgramMembership.objects.filter(
606
+ program=self.program, is_deleted=False
607
+ ).select_related("user"):
608
+ users[m.user_id] = m.user
609
+ return roles
610
+
611
+ def _account_entry(self, uid: Any, user: Any, roles: dict[Any, int]) -> dict[str, Any]:
612
+ block: dict[str, Any] = {"slug": self._user_slug(user), "username": user.get_username()}
613
+ # #3627: withheld for a non-workspace-Admin caller. Safe for the
614
+ # generic re-import path — accounts are matched by ``username``, never
615
+ # ``email`` (see importer._resolve_accounts) — so dropping the key here
616
+ # never breaks round-trip. ``_put`` already drops falsy values, so
617
+ # passing "" simply omits the key, matching the #3599 precedent of
618
+ # dropping rather than nulling a withheld email.
619
+ _put(block, "email", getattr(user, "email", "") if self._email_visible else "")
620
+ display = f"{user.first_name} {user.last_name}".strip()
621
+ _put(block, "display_name", display)
622
+ if uid in roles:
623
+ block["role"] = _ROLE_NAME[roles[uid]]
624
+ return block
625
+
626
+ def _calendars_block(self) -> list[dict[str, Any]]:
627
+ calendars: dict[Any, Any] = {}
628
+ for project in self.projects:
629
+ if project.calendar is not None:
630
+ calendars[project.calendar.pk] = project.calendar
631
+ for res in self._all_resources():
632
+ if res.calendar is not None:
633
+ calendars[res.calendar.pk] = res.calendar
634
+ blocks = []
635
+ for cal in calendars.values():
636
+ slug = self._calendar_slug(cal)
637
+ block: dict[str, Any] = {"slug": slug, "name": cal.name}
638
+ _put(block, "working_days", cal.working_days)
639
+ _put(block, "hours_per_day", cal.hours_per_day)
640
+ _put(block, "timezone", cal.timezone)
641
+ # Non-working ranges (holidays / PTO, #376). Exported as ISO literals so
642
+ # the export→import→export fixpoint is byte-stable; ordered for
643
+ # determinism. Forecast-snapshot history is deliberately NOT exported —
644
+ # like SprintBurnSnapshot it is derived observational history, not
645
+ # authored structure, so a round-trip drops it (its parameters are not
646
+ # recoverable from the materialized rows).
647
+ exceptions = [
648
+ {
649
+ "exc_start": exc.exc_start.isoformat(),
650
+ "exc_end": exc.exc_end.isoformat(),
651
+ **({"description": exc.description} if exc.description else {}),
652
+ }
653
+ for exc in cal.exceptions.all().order_by("exc_start", "exc_end")
654
+ ]
655
+ if exceptions:
656
+ block["exceptions"] = exceptions
657
+ blocks.append(block)
658
+ return sorted(blocks, key=lambda c: c["slug"])
659
+
660
+ def _resources_block(self) -> list[dict[str, Any]]:
661
+ blocks = []
662
+ for res in self._all_resources():
663
+ slug = self._resource_slug(res)
664
+ block: dict[str, Any] = {"slug": slug, "name": res.name}
665
+ # #3627: withheld for a non-workspace-Admin caller. Safe for the
666
+ # generic re-import path — ``_resolve_resources`` never matches an
667
+ # existing catalog Resource by email on that path (it always creates
668
+ # a fresh row, #1004) — only the server-curated sample loader does,
669
+ # and that path is never fed a redacted export.
670
+ _put(block, "email", res.email if self._email_visible else "")
671
+ _put(block, "job_role", res.job_role)
672
+ _put(block, "max_units", float(res.max_units))
673
+ if res.calendar_id is not None:
674
+ block["calendar"] = self._calendar_slug(res.calendar)
675
+ if res.user_id is not None:
676
+ block["account"] = self._user_slug(res.user)
677
+ if self.with_events:
678
+ # v2.1 (#3498). Ordered on the normalized name so the export is stable.
679
+ skills = [
680
+ {
681
+ "name": rs.skill.name,
682
+ "proficiency": _PROFICIENCY_NAME[rs.proficiency],
683
+ **({"category": rs.skill.category} if rs.skill.category else {}),
684
+ }
685
+ for rs in ResourceSkill.objects.filter(resource=res, is_deleted=False)
686
+ .select_related("skill")
687
+ .order_by("skill__normalized_name", "pk")
688
+ ]
689
+ if skills:
690
+ block["skills"] = skills
691
+ blocks.append(block)
692
+ return sorted(blocks, key=lambda r: r["slug"])
693
+
694
+ # --- per-project -------------------------------------------------------
695
+
696
+ def _index_project(self, project: Project) -> None:
697
+ """Populate global task/sprint slug maps for one project (pre-pass)."""
698
+ pslug = self.project_slugs[project.pk]
699
+ # A task with no wbs_path (e.g. a promoted retro-action backlog item) has
700
+ # no WBS identity and cannot be represented in a seed — the schema requires
701
+ # a wbs_path. Exclude it from both tasks[] and the ref map so refs to it are
702
+ # dropped rather than emitting an invalid "None" path.
703
+ tasks = list(
704
+ Task.objects.filter(project=project, is_deleted=False, wbs_path__isnull=False).order_by(
705
+ "wbs_path", "pk"
706
+ )
707
+ )
708
+ self._project_tasks[project.pk] = tasks
709
+ for task in tasks:
710
+ self.task_ref[task.pk] = (pslug, str(task.wbs_path))
711
+
712
+ sprints = list(
713
+ Sprint.objects.filter(project=project, is_deleted=False).order_by("start_date", "pk")
714
+ )
715
+ self._project_sprints[project.pk] = sprints
716
+ sprint_alloc = _SlugAllocator()
717
+ for sprint in sprints:
718
+ self.sprint_slugs[sprint.pk] = sprint_alloc.take(sprint.name)
719
+
720
+ def _project_block(self, project: Project) -> dict[str, Any]:
721
+ pslug = self.project_slugs[project.pk]
722
+ tasks = self._project_tasks[project.pk]
723
+ sprints = self._project_sprints[project.pk]
724
+
725
+ block: dict[str, Any] = {
726
+ "slug": pslug,
727
+ "name": project.name,
728
+ "methodology": project.methodology,
729
+ "start_date": self._date_str(project.start_date) if project.start_date else None,
730
+ }
731
+ self._put_project_scalars(block, project)
732
+
733
+ board_columns = self._board_column_blocks(project)
734
+ member_blocks = self._member_blocks(project)
735
+
736
+ # Project-scoped labels (ADR-0400, #1089) folded into the seed (#1958) so a
737
+ # re-seed round-trips board-card labels. Slugs are allocated per project
738
+ # (deterministic, kebab-cased) and referenced by ``labels`` on each task
739
+ # block; label identity in the seed is slug-based (no UUID), matching the
740
+ # existing seed contract (UUID-preserving round-trip is 0.5, #1959).
741
+ label_blocks = self._label_blocks(project)
742
+
743
+ sprint_blocks = [self._sprint_block(s) for s in sprints]
744
+ task_blocks = [self._task_block(t) for t in tasks]
745
+ dep_blocks = self._dependency_blocks(project, pslug)
746
+ baseline_blocks = self._baseline_blocks(project, pslug)
747
+ risk_blocks = self._risk_blocks(project, pslug)
748
+ # Insertion order IS the emitted key order, and the export contract is a
749
+ # byte-identical export -> import -> export fixpoint (see
750
+ # ``_member_blocks``), so this sequence is not cosmetic. Note it differs
751
+ # from the computation order above: the blocks are built sprints-first
752
+ # because slug allocation is order-sensitive, but emitted tasks-first.
753
+ for key, value in (
754
+ ("board_columns", board_columns),
755
+ ("members", member_blocks),
756
+ ("labels", label_blocks),
757
+ ("tasks", task_blocks),
758
+ ("dependencies", dep_blocks),
759
+ ("sprints", sprint_blocks),
760
+ ("baselines", baseline_blocks),
761
+ ("risks", risk_blocks),
762
+ ("team", self._team_block(project) if self.with_events else {}),
763
+ ):
764
+ if value:
765
+ block[key] = value
766
+ return block
767
+
768
+ def _team_block(self, project: Project) -> dict[str, Any]:
769
+ """The default team's name and SM/PO facets (v2.1, #3498). v2 export only.
770
+
771
+ Only facet holders are listed: membership itself re-derives from the
772
+ project's members on import, so listing everyone would restate it. The
773
+ default name is omitted, so a project nobody configured exports as before.
774
+ """
775
+ from trueppm_api.apps.teams.models import Team, TeamMembership
776
+
777
+ team = Team.objects.filter(project=project, is_default=True, is_deleted=False).first()
778
+ if team is None:
779
+ return {}
780
+ members: list[dict[str, Any]] = []
781
+ for membership in (
782
+ TeamMembership.objects.filter(team=team, is_deleted=False)
783
+ .exclude(is_scrum_master=False, is_product_owner=False)
784
+ .select_related("user")
785
+ ):
786
+ if membership.user_id not in self._account_user_pks:
787
+ continue
788
+ entry: dict[str, Any] = {"account": self._user_slug(membership.user)}
789
+ if membership.is_scrum_master:
790
+ entry["scrum_master"] = True
791
+ if membership.is_product_owner:
792
+ entry["product_owner"] = True
793
+ members.append(entry)
794
+ if not members and team.name == "Default Team":
795
+ return {}
796
+ block: dict[str, Any] = {"members": sorted(members, key=lambda e: e["account"])}
797
+ if team.name != "Default Team":
798
+ block["name"] = team.name
799
+ return block
800
+
801
+ def _put_project_scalars(self, block: dict[str, Any], project: Project) -> None:
802
+ """Add the project's optional scalar fields, each omitted at its default.
803
+
804
+ A field equal to its default is left out rather than emitted, so a seed
805
+ stays readable and a later change of default does not silently pin every
806
+ exported project to the old value.
807
+ """
808
+ _put(block, "description", project.description)
809
+ _put(block, "code", project.code)
810
+ if project.lead_id is not None:
811
+ block["lead"] = self._user_slug(project.lead)
812
+ if project.calendar_id is not None:
813
+ block["calendar"] = self._calendar_slug(project.calendar)
814
+ if project.default_view and project.default_view != "SCHEDULE":
815
+ block["default_view"] = project.default_view
816
+ if project.estimation_mode and project.estimation_mode != "open":
817
+ block["estimation_mode"] = project.estimation_mode
818
+ if project.health and project.health != "AUTO":
819
+ block["health"] = project.health
820
+
821
+ def _member_blocks(self, project: Project) -> list[dict[str, Any]]:
822
+ """Emit this project's memberships so project-scoped roles round-trip (#3092).
823
+
824
+ Emitted unconditionally rather than only when the roles differ from the
825
+ program roster. A "same as the program" shortcut would make the export
826
+ lossy the moment a role was later changed to match by coincidence, and the
827
+ fixpoint (export -> import -> export) is byte-identical either way because
828
+ a declared ``members`` list re-imports to exactly itself.
829
+
830
+ Only users already carrying an account slug are emitted. ``_user_slug``
831
+ *allocates* on first call, and allocation order is what keeps the export
832
+ byte-stable (see ``_collect_account_users``), so minting one here would
833
+ reorder the roster; a member without an account entry could not be
834
+ re-imported anyway.
835
+
836
+ This used to lean on "every project member also holds a
837
+ ``ProgramMembership``, so is already slugged". That premise is false —
838
+ the two grants are independent (ADR-0070 §RBAC), and once the roster
839
+ stopped carrying revoked members (#3457) a program-revoked, still-live
840
+ project member had no slug and was dropped here in silence. So
841
+ ``_collect_account_users`` now slugs live project members explicitly, as
842
+ its last source; do not restore the assumption.
843
+ """
844
+ rows = ProjectMembership.objects.filter(project=project, is_deleted=False).select_related(
845
+ "user"
846
+ )
847
+ blocks = [
848
+ {"account": self.user_slugs[m.user_id], "role": _ROLE_NAME[m.role]}
849
+ for m in rows
850
+ if m.user_id in self.user_slugs
851
+ ]
852
+ return sorted(blocks, key=lambda b: b["account"])
853
+
854
+ def _board_column_blocks(self, project: Project) -> list[dict[str, Any]]:
855
+ """Emit the project's board configuration, if it has one (#3093).
856
+
857
+ Absent when the project never configured a board — the API serves
858
+ hardcoded defaults in that case, and emitting them would turn "this
859
+ project uses the defaults" into "this project pinned today's defaults",
860
+ which is a different and worse claim on re-import.
861
+
862
+ Optional per-column metadata is emitted only when set, so a plain
863
+ rename round-trips as a rename rather than as a config that also pins
864
+ every null.
865
+ """
866
+ config = BoardColumnConfig.objects.filter(project=project).first()
867
+ if config is None or not config.columns:
868
+ return []
869
+ blocks: list[dict[str, Any]] = []
870
+ for column in config.columns:
871
+ entry: dict[str, Any] = {
872
+ "status": column["status"],
873
+ "label": column.get("label", ""),
874
+ }
875
+ if column.get("visible") is not None:
876
+ entry["visible"] = column["visible"]
877
+ for key in ("color", "wip_limit", "age_threshold_days"):
878
+ if column.get(key) is not None:
879
+ entry[key] = column[key]
880
+ lanes = [
881
+ {
882
+ "key": lane["key"],
883
+ "label": lane["label"],
884
+ **({"wip_limit": lane["wip_limit"]} if lane.get("wip_limit") else {}),
885
+ }
886
+ for lane in (column.get("lanes") or [])
887
+ ]
888
+ if lanes:
889
+ entry["lanes"] = lanes
890
+ blocks.append(entry)
891
+ return blocks
892
+
893
+ def _label_blocks(self, project: Project) -> list[dict[str, Any]]:
894
+ """Emit the project's labels and index each task's label slugs (#1958).
895
+
896
+ Labels are ordered by ``(position, name)`` — their stable display order —
897
+ so the slug allocation is deterministic and the round-trip is byte-stable.
898
+ The per-task slug list is stashed in ``self.task_label_slugs`` for
899
+ ``_task_block`` to read; a label with no attached tasks still round-trips
900
+ (it is a curated catalog entry, so the block is emitted regardless).
901
+ """
902
+ labels = list(Label.objects.filter(project=project).order_by("position", "name", "pk"))
903
+ if not labels:
904
+ return []
905
+ allocator = _SlugAllocator()
906
+ slug_by_label: dict[Any, str] = {}
907
+ blocks: list[dict[str, Any]] = []
908
+ for label in labels:
909
+ slug = allocator.take(label.name)
910
+ slug_by_label[label.pk] = slug
911
+ block: dict[str, Any] = {"slug": slug, "name": label.name, "color": label.color}
912
+ if label.position:
913
+ block["position"] = label.position
914
+ blocks.append(block)
915
+ # Index each task's labels in (position, name) order — the pill-row order —
916
+ # so a task's ``labels`` slug list is deterministic too.
917
+ for tl in (
918
+ TaskLabel.objects.filter(label__project=project)
919
+ .select_related("label")
920
+ .order_by("label__position", "label__name", "label__pk")
921
+ ):
922
+ label_slug = slug_by_label.get(tl.label_id)
923
+ if label_slug is not None:
924
+ self.task_label_slugs.setdefault(tl.task_id, []).append(label_slug)
925
+ return blocks
926
+
927
+ def _task_block(self, task: Task) -> dict[str, Any]:
928
+ block: dict[str, Any] = {"wbs_path": str(task.wbs_path), "name": task.name}
929
+ self._put_task_core(block, task)
930
+ self._put_task_agile(block, task)
931
+ self._put_task_blocker(block, task)
932
+ self._put_task_attachments(block, task)
933
+ self._put_task_estimate(block, task)
934
+ self._put_task_assignments(block, task)
935
+ self._put_task_labels_and_links(block, task)
936
+ if self.with_events:
937
+ self._put_task_criteria(block, task)
938
+ self._put_task_depth(block, task)
939
+ return block
940
+
941
+ def _put_task_depth(self, block: dict[str, Any], task: Task) -> None:
942
+ """Subtask flag, recurrence rule and skill requirements (v2.1, #3498). v2 only.
943
+
944
+ The rule is exported as its authoring shape (weekday names, not the bitmask)
945
+ and ``end_type`` is implied by which end key is present, so a re-import
946
+ reconstructs exactly the row it came from.
947
+ """
948
+ if task.is_subtask:
949
+ block["is_subtask"] = True
950
+ rule = TaskRecurrenceRule.objects.filter(task=task, is_deleted=False).first()
951
+ if rule is not None:
952
+ recurrence: dict[str, Any] = {"frequency": rule.frequency}
953
+ if rule.interval != 1:
954
+ recurrence["interval"] = rule.interval
955
+ days = [name for name, bit in _WEEKDAY_BITS if rule.weekdays & bit]
956
+ if days:
957
+ recurrence["weekdays"] = days
958
+ _put(recurrence, "day_of_month", rule.day_of_month)
959
+ recurrence["time_of_day"] = rule.time_of_day.strftime("%H:%M")
960
+ if rule.end_date is not None:
961
+ recurrence["end_date"] = self._date_str(rule.end_date)
962
+ _put(recurrence, "end_count", rule.end_count)
963
+ block["recurrence"] = recurrence
964
+ requirements = [
965
+ {"skill": req.skill.name, "min_proficiency": _PROFICIENCY_NAME[req.min_proficiency]}
966
+ for req in TaskSkillRequirement.objects.filter(task=task, is_deleted=False)
967
+ .select_related("skill")
968
+ .order_by("skill__normalized_name", "pk")
969
+ ]
970
+ if requirements:
971
+ block["skill_requirements"] = requirements
972
+
973
+ def _put_task_criteria(self, block: dict[str, Any], task: Task) -> None:
974
+ """Acceptance criteria in position order (v2.1, #3492). v2 export only.
975
+
976
+ Emitted as final state — ``met`` with its ``met_by``/``met_at`` trail — and
977
+ never as ``task.ac_met`` beats, the same reason the exporter already skips
978
+ that action: the end value rides the row, so a beat would apply it twice.
979
+ ``met_by`` is emitted only for a user who is already an account; minting a
980
+ slug here would reorder allocation and break the byte-stable round-trip.
981
+ """
982
+ rows = AcceptanceCriterion.objects.filter(task=task, is_deleted=False).order_by(
983
+ "position", "pk"
984
+ )
985
+ criteria: list[dict[str, Any]] = []
986
+ for row in rows:
987
+ entry: dict[str, Any] = {"text": row.text}
988
+ for key in ("given", "when", "then"):
989
+ _put(entry, key, getattr(row, key))
990
+ if row.met:
991
+ entry["met"] = True
992
+ if row.met_by_id is not None and row.met_by_id in self._account_user_pks:
993
+ entry["met_by"] = self._user_slug(row.met_by)
994
+ if row.met_at is not None:
995
+ entry["met_at"] = self._rel_ts(row.met_at)
996
+ criteria.append(entry)
997
+ if criteria:
998
+ block["acceptance_criteria"] = criteria
999
+
1000
+ def _put_task_core(self, block: dict[str, Any], task: Task) -> None:
1001
+ """Scalar schedule/status fields, each emitted only when non-default."""
1002
+ if task.type and task.type != "task":
1003
+ block["type"] = task.type
1004
+ if task.status and task.status != "NOT_STARTED":
1005
+ block["status"] = task.status
1006
+ if task.is_milestone:
1007
+ block["is_milestone"] = True
1008
+ if task.duration:
1009
+ block["duration"] = task.duration
1010
+ if task.planned_start is not None:
1011
+ block["planned_start"] = self._date_str(task.planned_start)
1012
+ if task.percent_complete:
1013
+ block["percent_complete"] = task.percent_complete
1014
+ _put(block, "notes", task.notes)
1015
+ _put(block, "story_points", task.story_points)
1016
+ _put(block, "remaining_points", task.remaining_points)
1017
+
1018
+ def _put_task_agile(self, block: dict[str, Any], task: Task) -> None:
1019
+ """Assignee, sprint linkage, and agile/governance classification fields."""
1020
+ if task.assignee_id is not None:
1021
+ block["assignee"] = self._user_slug(task.assignee)
1022
+ if task.sprint_id is not None:
1023
+ block["sprint"] = self.sprint_slugs[task.sprint_id]
1024
+ if task.parent_epic_id is not None and task.parent_epic_id in self.task_ref:
1025
+ block["parent_epic"] = self.task_ref[task.parent_epic_id][1]
1026
+ _put(block, "sprint_rank", task.sprint_rank)
1027
+ if task.dor and task.dor != "idea":
1028
+ block["dor"] = task.dor
1029
+ _put(block, "board_lane", task.board_lane)
1030
+ if task.governance_class and task.governance_class != "flow":
1031
+ block["governance_class"] = task.governance_class
1032
+ if task.delivery_mode and task.delivery_mode != "waterfall":
1033
+ block["delivery_mode"] = task.delivery_mode
1034
+ _put(block, "color", task.color)
1035
+
1036
+ def _put_task_blocker(self, block: dict[str, Any], task: Task) -> None:
1037
+ """Emit the blocker cluster when the flag is raised (#3094).
1038
+
1039
+ ``blocked_reason`` is the flag-of-record, so its emptiness decides. The
1040
+ derived companions are emitted only when set, and ``blocked_since`` rides
1041
+ along as ``since`` — an unstamped blocker renders no age, and age is the
1042
+ triage signal the flag exists to carry.
1043
+ """
1044
+ reason = (task.blocked_reason or "").strip()
1045
+ if not reason:
1046
+ return
1047
+ blocked: dict[str, Any] = {"reason": task.blocked_reason}
1048
+ if task.blocked_since is not None:
1049
+ blocked["since"] = self._date_str(task.blocked_since.date())
1050
+ _put(blocked, "type", task.blocker_type)
1051
+ if task.blocking_task_id is not None and task.blocking_task_id in self.task_ref:
1052
+ blocked["blocking_task"] = self.task_ref[task.blocking_task_id][1]
1053
+ if task.blocked_by_id is not None:
1054
+ blocked["by"] = self._user_slug(task.blocked_by)
1055
+ block["blocked"] = blocked
1056
+
1057
+ def _put_task_attachments(self, block: dict[str, Any], task: Task) -> None:
1058
+ """Emit external-link attachments, ordered for a stable round-trip.
1059
+
1060
+ File attachments are skipped rather than half-emitted: a seed is a text
1061
+ document and ``TaskAttachment`` is file XOR ``external_url``, so a file
1062
+ row has nothing expressible. Soft-deleted rows are skipped too — they
1063
+ exist only to keep ``[[attachment:uuid]]`` comment references resolvable.
1064
+ """
1065
+ rows = [
1066
+ row
1067
+ for row in task.attachments.filter(is_deleted=False).order_by("created_at", "pk")
1068
+ if row.external_url
1069
+ ]
1070
+ if not rows:
1071
+ return
1072
+ entries: list[dict[str, Any]] = []
1073
+ for row in rows:
1074
+ entry: dict[str, Any] = {"external_url": row.external_url}
1075
+ _put(entry, "external_title", row.external_title)
1076
+ if row.is_pinned:
1077
+ entry["is_pinned"] = True
1078
+ if row.uploaded_by_id is not None:
1079
+ entry["uploaded_by"] = self._user_slug(row.uploaded_by)
1080
+ entries.append(entry)
1081
+ block["attachments"] = entries
1082
+
1083
+ def _put_task_estimate(self, block: dict[str, Any], task: Task) -> None:
1084
+ if task.optimistic_duration is not None:
1085
+ block["estimate"] = {
1086
+ "optimistic": task.optimistic_duration,
1087
+ "most_likely": task.most_likely_duration,
1088
+ "pessimistic": task.pessimistic_duration,
1089
+ }
1090
+
1091
+ def _put_task_assignments(self, block: dict[str, Any], task: Task) -> None:
1092
+ assignments = [
1093
+ {"resource": self._resource_slug(tr.resource), "units": float(tr.units)}
1094
+ for tr in TaskResource.objects.filter(task=task)
1095
+ .select_related("resource")
1096
+ .order_by("resource__name")
1097
+ ]
1098
+ if assignments:
1099
+ block["assignments"] = assignments
1100
+
1101
+ def _put_task_labels_and_links(self, block: dict[str, Any], task: Task) -> None:
1102
+ label_slugs = self.task_label_slugs.get(task.pk)
1103
+ if label_slugs:
1104
+ block["labels"] = label_slugs
1105
+ links = self._task_relation_blocks(task)
1106
+ if links:
1107
+ block["links"] = links
1108
+
1109
+ def _task_relation_blocks(self, task: Task) -> list[dict[str, Any]]:
1110
+ """Emit this task's outgoing informational relations (ADR-0455).
1111
+
1112
+ Mirrors :meth:`_dependency_blocks`: one block per live ``TaskRelation`` where
1113
+ the task is the *source*, ordered ``(relation_type, target wbs)`` so the
1114
+ round-trip is byte-stable. A relation whose target task is excluded from the
1115
+ export (e.g. a wbs-less promoted task) is dropped, mirroring how a dangling
1116
+ dependency edge is skipped. The target ref is project-qualified only when it
1117
+ crosses a project boundary — the same convention as a dependency predecessor.
1118
+ """
1119
+ src = self.task_ref.get(task.pk)
1120
+ if src is None:
1121
+ return []
1122
+ src_pslug = src[0]
1123
+ blocks: list[dict[str, Any]] = []
1124
+ for rel in (
1125
+ TaskRelation.objects.filter(source=task, is_deleted=False)
1126
+ .select_related("target")
1127
+ .order_by("relation_type", "target__wbs_path", "pk")
1128
+ ):
1129
+ tref = self.task_ref.get(rel.target_id)
1130
+ if tref is None:
1131
+ continue
1132
+ target_ref = tref[1] if tref[0] == src_pslug else f"{tref[0]}:{tref[1]}"
1133
+ block: dict[str, Any] = {"target": target_ref, "link_type": rel.relation_type}
1134
+ _put(block, "note", rel.note)
1135
+ blocks.append(block)
1136
+ return blocks
1137
+
1138
+ def _sprint_block(self, sprint: Sprint) -> dict[str, Any]:
1139
+ block: dict[str, Any] = {
1140
+ "slug": self.sprint_slugs[sprint.pk],
1141
+ "name": sprint.name,
1142
+ "state": sprint.state,
1143
+ "start_date": self._date_str(sprint.start_date) if sprint.start_date else None,
1144
+ "finish_date": self._date_str(sprint.finish_date) if sprint.finish_date else None,
1145
+ }
1146
+ _put(block, "goal", sprint.goal)
1147
+ _put(block, "notes", sprint.notes)
1148
+ _put(block, "committed_points", sprint.committed_points)
1149
+ _put(block, "completed_points", sprint.completed_points)
1150
+ _put(block, "capacity_points", sprint.capacity_points)
1151
+ if sprint.target_milestone_id is not None and sprint.target_milestone_id in self.task_ref:
1152
+ block["target_milestone"] = self.task_ref[sprint.target_milestone_id][1]
1153
+ return block
1154
+
1155
+ def _dependency_blocks(self, project: Project, pslug: str) -> list[dict[str, Any]]:
1156
+ deps = (
1157
+ Dependency.objects.filter(successor__project=project, is_deleted=False)
1158
+ .select_related("predecessor", "successor")
1159
+ .order_by("predecessor__wbs_path", "successor__wbs_path")
1160
+ )
1161
+ blocks = []
1162
+ for dep in deps:
1163
+ pred = self.task_ref.get(dep.predecessor_id)
1164
+ succ = self.task_ref.get(dep.successor_id)
1165
+ if pred is None or succ is None:
1166
+ continue
1167
+ pred_ref = pred[1] if pred[0] == pslug else f"{pred[0]}:{pred[1]}"
1168
+ block: dict[str, Any] = {
1169
+ "predecessor": pred_ref,
1170
+ "successor": succ[1],
1171
+ "dep_type": dep.dep_type,
1172
+ }
1173
+ if dep.lag:
1174
+ block["lag"] = dep.lag
1175
+ blocks.append(block)
1176
+ return blocks
1177
+
1178
+ def _baseline_blocks(self, project: Project, pslug: str) -> list[dict[str, Any]]:
1179
+ blocks = []
1180
+ for baseline in Baseline.objects.filter(project=project).order_by("created_at", "pk"):
1181
+ task_rows = []
1182
+ for bt in BaselineTask.objects.filter(baseline=baseline).order_by("task_name", "pk"):
1183
+ ref = self.task_ref.get(bt.task_id)
1184
+ if ref is None:
1185
+ continue
1186
+ row: dict[str, Any] = {"task": ref[1]}
1187
+ if bt.start is not None:
1188
+ row["start"] = self._date_str(bt.start)
1189
+ if bt.finish is not None:
1190
+ row["finish"] = self._date_str(bt.finish)
1191
+ _put(row, "duration", bt.duration)
1192
+ _put(row, "story_points", bt.story_points)
1193
+ task_rows.append(row)
1194
+ block: dict[str, Any] = {"name": baseline.name, "tasks": task_rows}
1195
+ # Without this the round-trip silently flattens every baseline's
1196
+ # capture date onto import day (#3093), which is the interval
1197
+ # planned-vs-actual is measured over.
1198
+ block["captured_at"] = self._date_str(baseline.created_at.date())
1199
+ if baseline.is_active:
1200
+ block["is_active"] = True
1201
+ blocks.append(block)
1202
+ return blocks
1203
+
1204
+ def _risk_blocks(self, project: Project, pslug: str) -> list[dict[str, Any]]:
1205
+ blocks = []
1206
+ # select_related("owner") + prefetch_related("tasks") keep the per-risk
1207
+ # owner-slug and task-linkage lookups from firing a query inside the loop
1208
+ # (the M2M through rows are batched into a single prefetch query, #2351).
1209
+ risks = (
1210
+ Risk.objects.filter(project=project, is_deleted=False)
1211
+ .select_related("owner")
1212
+ .order_by("title", "pk")
1213
+ .prefetch_related("tasks")
1214
+ )
1215
+ slug_alloc = _SlugAllocator()
1216
+ for risk in risks:
1217
+ risk_slug = slug_alloc.take(risk.title)
1218
+ self.risk_slugs[risk.pk] = risk_slug
1219
+ block: dict[str, Any] = {
1220
+ "slug": risk_slug,
1221
+ "title": risk.title,
1222
+ "status": risk.status,
1223
+ "probability": risk.probability,
1224
+ "impact": risk.impact,
1225
+ }
1226
+ _put(block, "description", risk.description)
1227
+ _put(block, "category", risk.category)
1228
+ _put(block, "response", risk.response)
1229
+ if risk.mitigation_due_date is not None:
1230
+ block["mitigation_due_date"] = self._date_str(risk.mitigation_due_date)
1231
+ _put(block, "trigger", risk.trigger)
1232
+ _put(block, "contingency", risk.contingency)
1233
+ _put(block, "notes", risk.notes)
1234
+ if risk.owner_id is not None:
1235
+ block["owner"] = self._user_slug(risk.owner)
1236
+ refs = []
1237
+ for linked in risk.tasks.all():
1238
+ ref = self.task_ref.get(linked.pk)
1239
+ if ref is None:
1240
+ continue
1241
+ refs.append(ref[1] if ref[0] == pslug else f"{ref[0]}:{ref[1]}")
1242
+ if refs:
1243
+ block["tasks"] = sorted(refs)
1244
+ blocks.append(block)
1245
+ return blocks
1246
+
1247
+ # --- v2 date + event reconstruction ------------------------------------
1248
+
1249
+ def _date_str(self, d: date) -> str:
1250
+ """Emit a date: ISO literal (v1) or an ``anchor``-relative offset (v2)."""
1251
+ return self._rel_date(d) if self.with_events else d.isoformat()
1252
+
1253
+ def _rel_date(self, d: date) -> str:
1254
+ """A ``seedDate`` anchor offset that lands exactly (``!`` = no snap).
1255
+
1256
+ The ``!`` suffix opts out of weekend-snapping so the resolved date is
1257
+ exactly ``anchor + offset`` — essential for round-trip determinism, since
1258
+ a snapped date would drift on re-import.
1259
+ """
1260
+ offset = (d - self.anchor).days
1261
+ if abs(offset) > _MAX_REL_OFFSET_DAYS:
1262
+ return d.isoformat()
1263
+ return f"A{offset:+d}!"
1264
+
1265
+ def _rel_ts(self, dt: datetime) -> str:
1266
+ """A ``seedTimestamp`` anchor offset (event time; never weekend-snapped).
1267
+
1268
+ Replay builds authored event datetimes in UTC (``resolve_timestamp``
1269
+ defaults to UTC), so we normalize to UTC and emit UTC components; a
1270
+ re-import rebuilds the identical instant, closing the round-trip.
1271
+ """
1272
+ u = dt.astimezone(_UTC)
1273
+ offset = (u.date() - self.anchor).days
1274
+ if abs(offset) > _MAX_REL_OFFSET_DAYS:
1275
+ return u.replace(second=0, microsecond=0, tzinfo=None).isoformat()
1276
+ return f"A{offset:+d}T{u.hour:02d}:{u.minute:02d}"
1277
+
1278
+ def _capture_account_identity(self, accounts: list[dict[str, Any]]) -> None:
1279
+ """Record which users are accounts + the deterministic fallback actor.
1280
+
1281
+ Every user emitted into ``accounts[]`` has a slug in ``self.user_slugs``
1282
+ by the time this runs. The fallback actor — used when a history row is
1283
+ attributed to a user who is not (or is no longer) an account — is the
1284
+ lexically-first OWNER account, else the first account. Resolving every
1285
+ event actor to a real account keeps the emitted ``actor`` from dangling
1286
+ and makes it round-trip (replay re-attributes it, re-export reads it).
1287
+ """
1288
+ self._account_user_pks = set(self.user_slugs.keys())
1289
+ if not accounts:
1290
+ return
1291
+ owners = sorted(a["slug"] for a in accounts if a.get("role") == "OWNER")
1292
+ self._fallback_actor_slug = owners[0] if owners else accounts[0]["slug"]
1293
+
1294
+ def _actor_slug(self, user: Any) -> str:
1295
+ """Resolve a history/audit user to an account slug (fallback if not one)."""
1296
+ if user is not None and user.pk in self._account_user_pks:
1297
+ return self._user_slug(user)
1298
+ return self._fallback_actor_slug
1299
+
1300
+ def _events_block(self) -> list[dict[str, Any]]:
1301
+ """Reconstruct the ordered ``events`` timeline from the history tables.
1302
+
1303
+ Emitted event kinds (each a clean export→import→export fixpoint):
1304
+ ``task.status`` (Task history transitions), ``task.points`` (mid-sprint
1305
+ remaining-point changes — the importer births a progressing task's
1306
+ ``remaining_points`` at ``story_points``, so a burned-down value only
1307
+ survives as an event), ``task.comment`` (``TaskComment`` rows),
1308
+ ``sprint.activate`` / ``sprint.close`` (sprint lifecycle timestamps),
1309
+ ``sprint.scope_inject`` (still-PENDING scope rows), and ``retro.action``
1310
+ (retro action items).
1311
+
1312
+ Deliberately *not* reconstructed (documented fidelity gaps, not
1313
+ determinism breaks): ``task.assign`` / ``task.estimate`` / ``task.ac_met``
1314
+ (their end value is already carried by the task's final-state fields set
1315
+ at creation, so re-emitting them as events would double-apply and break
1316
+ the fixpoint); ``risk.status`` (the importer births risks at their final
1317
+ status, so a status event would be a no-op that does not survive a
1318
+ round-trip); ``sprint.scope_resolve`` and ``retro.promote`` (their target
1319
+ row's final state — task dropped from the sprint, or a wbs-less promoted
1320
+ task — cannot replay from the exported snapshot). The task's/sprint's
1321
+ *final* state is always preserved via its fields; only the intermediate
1322
+ audit rows for those specific transitions are not round-tripped.
1323
+ """
1324
+ collected: list[_RawEvent] = []
1325
+ seq = 0
1326
+
1327
+ def emit(when: datetime, action: str, target: str, data: dict[str, Any]) -> None:
1328
+ nonlocal seq
1329
+ collected.append(_RawEvent(when=when, target=target, action=action, seq=seq, data=data))
1330
+ seq += 1
1331
+
1332
+ for project in self.projects:
1333
+ pslug = self.project_slugs[project.pk]
1334
+ for task in self._project_tasks[project.pk]:
1335
+ self._emit_task_history(task, pslug, emit)
1336
+ self._emit_task_comments(task, pslug, emit)
1337
+ for sprint in self._project_sprints[project.pk]:
1338
+ self._emit_sprint_lifecycle(sprint, pslug, emit)
1339
+ self._emit_retro_actions(sprint, pslug, emit)
1340
+ self._emit_scope_injections(project, emit)
1341
+ self._emit_risk_notes(project, emit)
1342
+
1343
+ # Chronological order; ties broken deterministically so re-export is
1344
+ # byte-identical. The array index is the replay tiebreak for same-instant
1345
+ # events (matching the importer's `_resolve_authored` order semantics).
1346
+ collected.sort(key=lambda e: (e.when, e.target, e.action, e.seq))
1347
+ out: list[dict[str, Any]] = []
1348
+ for e in collected:
1349
+ event: dict[str, Any] = {"at": self._rel_ts(e.when), "action": e.action}
1350
+ event.update(e.data)
1351
+ event["target"] = e.target
1352
+ out.append(event)
1353
+ return out
1354
+
1355
+ def _emit_task_history(self, task: Task, pslug: str, emit: _Emit) -> None:
1356
+ """Emit ``task.status`` + ``task.points`` events from Task history.
1357
+
1358
+ The earliest (creation) row establishes the base status/remaining; each
1359
+ later row is inspected for two independent changes:
1360
+
1361
+ - **status** changed → ``task.status`` (from/to). Replay re-creates the
1362
+ task at NOT_STARTED and walks it forward, reproducing the same rows.
1363
+ - **remaining_points** changed *without* a status change → ``task.points``.
1364
+ A remaining change that coincides with a status change is the COMPLETE
1365
+ handler zeroing remaining_points (``_apply_task_status``), which replay
1366
+ re-applies on its own, so emitting a ``task.points`` for it would be a
1367
+ redundant, non-round-tripping write — hence the ``not status_changed``
1368
+ guard.
1369
+ """
1370
+ target = f"task:{pslug}:{task.wbs_path}"
1371
+ prev_status: str | None = None
1372
+ prev_remaining: int | None = None
1373
+ rows = task.history.select_related("history_user").order_by("history_date", "history_id")
1374
+ for row in rows:
1375
+ if prev_status is None:
1376
+ prev_status = row.status
1377
+ prev_remaining = row.remaining_points
1378
+ continue
1379
+ status_changed = row.status != prev_status
1380
+ if status_changed:
1381
+ emit(
1382
+ row.history_date,
1383
+ "task.status",
1384
+ target,
1385
+ {
1386
+ "actor": self._actor_slug(row.history_user),
1387
+ "from": prev_status,
1388
+ "to": row.status,
1389
+ },
1390
+ )
1391
+ prev_status = row.status
1392
+ if (
1393
+ not status_changed
1394
+ and row.remaining_points is not None
1395
+ and row.remaining_points != prev_remaining
1396
+ ):
1397
+ emit(
1398
+ row.history_date,
1399
+ "task.points",
1400
+ target,
1401
+ {
1402
+ "actor": self._actor_slug(row.history_user),
1403
+ "remaining_points": row.remaining_points,
1404
+ },
1405
+ )
1406
+ prev_remaining = row.remaining_points
1407
+
1408
+ def _emit_task_comments(self, task: Task, pslug: str, emit: _Emit) -> None:
1409
+ """Emit comments — and under v2.1, their threads, reactions, acks, and notes.
1410
+
1411
+ A comment gets a ``slug`` only when something refers to it (a reply, a
1412
+ reaction or an acknowledgement), so a program with flat comments exports
1413
+ exactly as it did under 2.0. Reactions and acknowledgements are emitted
1414
+ only for users who already hold an account: attributing them to the
1415
+ fallback actor would invent a reaction nobody gave.
1416
+ """
1417
+ target = f"task:{pslug}:{task.wbs_path}"
1418
+ comments = list(
1419
+ TaskComment.objects.filter(task=task)
1420
+ .select_related("author")
1421
+ .order_by("created_at", "pk")
1422
+ )
1423
+ if not self.with_events or not comments:
1424
+ for c in comments:
1425
+ emit(
1426
+ c.created_at,
1427
+ "task.comment",
1428
+ target,
1429
+ {"actor": self._actor_slug(c.author), "body": c.body},
1430
+ )
1431
+ self._emit_task_notes(task, target, emit)
1432
+ return
1433
+
1434
+ reactions = list(
1435
+ CommentReaction.objects.filter(comment__in=comments, user_id__in=self._account_user_pks)
1436
+ .select_related("user")
1437
+ .order_by("created_at", "pk")
1438
+ )
1439
+ acks = list(
1440
+ CommentAcknowledgement.objects.filter(
1441
+ comment__in=comments, user_id__in=self._account_user_pks
1442
+ )
1443
+ .select_related("user")
1444
+ .order_by("created_at", "pk")
1445
+ )
1446
+ referenced = {c.parent_id for c in comments if c.parent_id is not None}
1447
+ referenced |= {r.comment_id for r in reactions} | {a.comment_id for a in acks}
1448
+ slugs: dict[Any, str] = {}
1449
+ for n, c in enumerate(comments, start=1):
1450
+ if c.pk in referenced:
1451
+ slugs[c.pk] = self.comment_slug.take(f"{pslug}-{task.wbs_path}-comment-{n}")
1452
+
1453
+ for c in comments:
1454
+ data: dict[str, Any] = {"actor": self._actor_slug(c.author), "body": c.body}
1455
+ if c.pk in slugs:
1456
+ data["slug"] = slugs[c.pk]
1457
+ if c.parent_id in slugs:
1458
+ data["reply_to"] = slugs[c.parent_id]
1459
+ emit(c.created_at, "task.comment", target, data)
1460
+ for r in reactions:
1461
+ emit(
1462
+ r.created_at,
1463
+ "task.react",
1464
+ f"comment:{slugs[r.comment_id]}",
1465
+ {"actor": self._user_slug(r.user), "emoji": r.emoji},
1466
+ )
1467
+ for a in acks:
1468
+ emit(
1469
+ a.created_at,
1470
+ "task.ack",
1471
+ f"comment:{slugs[a.comment_id]}",
1472
+ {"actor": self._user_slug(a.user)},
1473
+ )
1474
+ self._emit_task_notes(task, target, emit)
1475
+
1476
+ def _emit_task_notes(self, task: Task, target: str, emit: _Emit) -> None:
1477
+ """Emit ``task.note`` beats from live ``TaskNote`` rows (v2.1, #3492). v2 only.
1478
+
1479
+ A note is append-only like a comment, so replaying it reproduces the row it
1480
+ came from. Per-person logged time is deliberately *not* reconstructed the
1481
+ same way: exporting ``time.log`` would hand every program Admin who can run
1482
+ an export each contributor's hours, which ADR-0104 keeps off management
1483
+ surfaces.
1484
+ """
1485
+ if not self.with_events:
1486
+ return
1487
+ for note in (
1488
+ TaskNote.objects.filter(task=task, is_deleted=False)
1489
+ .select_related("author")
1490
+ .order_by("created_at", "pk")
1491
+ ):
1492
+ data: dict[str, Any] = {"actor": self._actor_slug(note.author), "body": note.body}
1493
+ if note.pinned:
1494
+ data["pinned"] = True
1495
+ if note.decision:
1496
+ data["decision"] = True
1497
+ emit(note.created_at, "task.note", target, data)
1498
+
1499
+ def _emit_risk_notes(self, project: Project, emit: _Emit) -> None:
1500
+ """Emit ``risk.note`` events from RiskComment rows (#3094).
1501
+
1502
+ Safe to reconstruct, unlike ``risk.status``: a comment is an append-only
1503
+ artifact that does not depend on the risk's current status, so replaying
1504
+ it reproduces exactly the rows it came from — the same reason
1505
+ ``task.comment`` round-trips.
1506
+ """
1507
+ for comment in (
1508
+ RiskComment.objects.filter(risk__project=project, risk__is_deleted=False)
1509
+ .select_related("author", "risk")
1510
+ .order_by("created_at", "pk")
1511
+ ):
1512
+ slug = self.risk_slugs.get(comment.risk_id)
1513
+ if slug is None:
1514
+ continue
1515
+ emit(
1516
+ comment.created_at,
1517
+ "risk.note",
1518
+ f"risk:{slug}",
1519
+ {"actor": self._actor_slug(comment.author), "body": comment.message},
1520
+ )
1521
+
1522
+ def _emit_sprint_lifecycle(self, sprint: Sprint, pslug: str, emit: _Emit) -> None:
1523
+ """Emit activate/close from the sprint's lifecycle timestamps.
1524
+
1525
+ ``activated_at`` / ``closed_at`` carry the beat time; the actor is not
1526
+ persisted on the sprint, so the deterministic fallback (OWNER) is used —
1527
+ the sprint's committed/completed points ride their fields (snapshotted at
1528
+ activate/close on replay), and ``goal_outcome`` rides the close event.
1529
+ """
1530
+ target = f"sprint:{pslug}:{self.sprint_slugs[sprint.pk]}"
1531
+ if sprint.activated_at is not None:
1532
+ emit(
1533
+ sprint.activated_at,
1534
+ "sprint.activate",
1535
+ target,
1536
+ {"actor": self._fallback_actor_slug},
1537
+ )
1538
+ if sprint.closed_at is not None:
1539
+ data: dict[str, Any] = {"actor": self._fallback_actor_slug}
1540
+ if sprint.goal_outcome:
1541
+ data["goal_outcome"] = sprint.goal_outcome
1542
+ emit(sprint.closed_at, "sprint.close", target, data)
1543
+
1544
+ def _emit_scope_injections(self, project: Project, emit: _Emit) -> None:
1545
+ """Emit ``sprint.scope_inject`` for still-PENDING scope-change rows.
1546
+
1547
+ Only PENDING rows round-trip cleanly: the injected task is still in its
1548
+ sprint (so replay's ``_apply_scope_inject`` re-creates the audit row), and
1549
+ ``added_at`` is backdated by replay so the timestamp is stable. ACCEPTED /
1550
+ REJECTED rows are skipped — the resolve timestamp is not persisted and a
1551
+ rejected injection removed the task from its sprint.
1552
+ """
1553
+ rows = (
1554
+ SprintScopeChange.objects.filter(
1555
+ sprint__project=project, status=ScopeChangeStatus.PENDING
1556
+ )
1557
+ .select_related("task", "added_by")
1558
+ .order_by("added_at", "pk")
1559
+ )
1560
+ for sc in rows:
1561
+ ref = self.task_ref.get(sc.task_id)
1562
+ if ref is None:
1563
+ continue # task excluded from export (e.g. no wbs_path)
1564
+ data: dict[str, Any] = {"actor": self._actor_slug(sc.added_by)}
1565
+ if sc.goal_impact:
1566
+ data["goal_impact"] = True
1567
+ emit(sc.added_at, "sprint.scope_inject", f"task:{ref[0]}:{ref[1]}", data)
1568
+
1569
+ def _emit_retro_actions(self, sprint: Sprint, pslug: str, emit: _Emit) -> None:
1570
+ """Emit a ``retro.action`` per retro action item on the sprint's retro.
1571
+
1572
+ A non-empty ``SprintRetro.notes`` rides on the last item's beat (as
1573
+ ``notes``) rather than its own event — there is no standalone "open
1574
+ retro" action, so a retro's summary text can only round-trip attached
1575
+ to an action item that already exists (#3497). A retro with notes but
1576
+ no action items cannot be reconstructed as v2 events; that gap predates
1577
+ this change (the retro itself couldn't be created via replay either).
1578
+ """
1579
+ retro = SprintRetro.objects.filter(sprint=sprint).first()
1580
+ if retro is None:
1581
+ return
1582
+ target = f"sprint:{pslug}:{self.sprint_slugs[sprint.pk]}"
1583
+ actor = self._actor_slug(retro.created_by)
1584
+ items = list(
1585
+ RetroActionItem.objects.filter(retro=retro)
1586
+ .select_related("assignee")
1587
+ .order_by("created_at", "pk")
1588
+ )
1589
+ for i, item in enumerate(items):
1590
+ data: dict[str, Any] = {"actor": actor, "body": item.text}
1591
+ if item.assignee_id is not None and item.assignee_id in self._account_user_pks:
1592
+ data["assignee"] = self._user_slug(item.assignee)
1593
+ if item.story_points is not None:
1594
+ data["points"] = item.story_points
1595
+ if i == len(items) - 1 and retro.notes:
1596
+ data["notes"] = retro.notes
1597
+ emit(item.created_at, "retro.action", target, data)
1598
+
1599
+ # --- slug helpers ------------------------------------------------------
1600
+
1601
+ def _user_slug(self, user: Any) -> str:
1602
+ if user.pk not in self.user_slugs:
1603
+ self.user_slugs[user.pk] = self.account_slug.take(user.get_username())
1604
+ return self.user_slugs[user.pk]
1605
+
1606
+ def _calendar_slug(self, calendar: Any) -> str:
1607
+ if calendar.pk not in self.calendar_slugs:
1608
+ self.calendar_slugs[calendar.pk] = self.calendar_slug.take(calendar.name)
1609
+ return self.calendar_slugs[calendar.pk]
1610
+
1611
+ def _resource_slug(self, resource: Resource) -> str:
1612
+ if resource.pk not in self.resource_slugs:
1613
+ self.resource_slugs[resource.pk] = self.resource_slug.take(resource.name)
1614
+ return self.resource_slugs[resource.pk]
1615
+
1616
+ # --- collection helpers ------------------------------------------------
1617
+
1618
+ def _all_tasks(self) -> list[Task]:
1619
+ return list(
1620
+ Task.objects.filter(project__in=self.projects, is_deleted=False).select_related(
1621
+ "assignee"
1622
+ )
1623
+ )
1624
+
1625
+ def _all_resources(self) -> list[Resource]:
1626
+ # Resources referenced by either a task assignment or the project roster.
1627
+ if self._resources_cache is not None:
1628
+ return self._resources_cache
1629
+ ids = set(
1630
+ TaskResource.objects.filter(task__project__in=self.projects).values_list(
1631
+ "resource_id", flat=True
1632
+ )
1633
+ )
1634
+ ids |= set(
1635
+ ProjectResource.objects.filter(project__in=self.projects, is_deleted=False).values_list(
1636
+ "resource_id", flat=True
1637
+ )
1638
+ )
1639
+ self._resources_cache = list(
1640
+ Resource.objects.filter(pk__in=ids)
1641
+ .select_related("calendar", "user")
1642
+ .order_by("name", "pk")
1643
+ )
1644
+ return self._resources_cache