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,1152 @@
1
+ """Per-row application for ``POST /projects/{pk}/tasks/bulk/`` (#2723, ADR-0772).
2
+
3
+ The endpoint used to apply operations in request order and stop at the first
4
+ rejection. That was not the clean all-or-nothing rollback it looked like:
5
+ ``_apply_bulk_operations`` **returned** the error Response rather than raising, so
6
+ the enclosing ``transaction.atomic()`` block exited *normally* and every operation
7
+ before the rejection committed — while the ``transaction.on_commit`` registrations
8
+ for CPM recalculation and the board broadcast were skipped, because the view had
9
+ already returned. A partially-applied batch therefore left a silently stale
10
+ schedule and no notification to collaborators (#2746).
11
+
12
+ This module replaces that with per-row application and a 207 response:
13
+
14
+ {
15
+ "applied": [{index, id, op, outcome, task}],
16
+ "rejected": [{index, id, code, message}],
17
+ "skipped": [{index, id, code, message}],
18
+ "dependencies": {"applied": [...], "rejected": [...]}
19
+ }
20
+
21
+ ``index`` — not ``id`` — is the correlation handle (ADR-0772 §2). A row rejected
22
+ because its id is unparseable has no usable id to echo back, and a create that
23
+ omitted one has none at all.
24
+
25
+ Two structural requirements the 207 contract introduces, neither of which the sync
26
+ path needs because it aborts wholesale:
27
+
28
+ - **Per-row savepoints.** A database-level failure (an ``IntegrityError`` the
29
+ serializer did not catch) poisons the enclosing transaction, and any subsequent
30
+ query then raises ``TransactionManagementError``. Continuing to the next row is
31
+ therefore impossible inside a single flat ``atomic()``: each row runs in its own
32
+ savepoint so one row's failure rolls back only that row.
33
+ - **Create ids join the lock set.** Under the upsert branch a create id may resolve
34
+ to an existing row, so create ids must be locked with the update/delete targets —
35
+ otherwise the existence check and the write are a TOCTOU window and two concurrent
36
+ batches minting the same id can interleave.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import dataclasses
42
+ import uuid
43
+ from typing import TYPE_CHECKING, Any
44
+
45
+ from django.db import DatabaseError, connection, transaction
46
+ from rest_framework.exceptions import PermissionDenied
47
+ from rest_framework.exceptions import ValidationError as DRFValidationError
48
+ from rest_framework.settings import api_settings
49
+
50
+ from trueppm_api.apps.access.models import Role
51
+ from trueppm_api.apps.access.permissions import can_user_edit_task
52
+ from trueppm_api.apps.projects.models import TaskSource
53
+ from trueppm_api.apps.projects.refusal_codes import BulkRefusalCode
54
+ from trueppm_api.apps.projects.row_identity import (
55
+ is_tombstoned,
56
+ parse_row_uuid,
57
+ )
58
+
59
+ if TYPE_CHECKING:
60
+ from rest_framework.request import Request
61
+
62
+ from trueppm_api.apps.projects.models import Project, Task
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Reject / skip vocabulary
66
+ # ---------------------------------------------------------------------------
67
+ # The codes themselves — and the notes on what each one means and whether
68
+ # retrying is worth the caller's time — live in ``refusal_codes.BulkRefusalCode``,
69
+ # which is also what the response serializer publishes as an ``enum`` into the
70
+ # OpenAPI schema (#3037). The names below are kept as module constants so the ~46
71
+ # call sites in this file read as prose rather than as attribute chains, and are
72
+ # bound to ``.value`` so what reaches ``json.dumps`` is a plain ``str`` and not an
73
+ # enum member whose repr could drift with a Django version.
74
+ #
75
+ # Add a code by adding a member there. Writing a bare string literal at a call
76
+ # site here puts a code on the wire that the schema does not publish, which is the
77
+ # exact defect #3037 was filed about.
78
+
79
+ CODE_MALFORMED_ID = BulkRefusalCode.MALFORMED_ID.value
80
+ CODE_ID_UNAVAILABLE = BulkRefusalCode.ID_UNAVAILABLE.value
81
+ CODE_NOT_FOUND = BulkRefusalCode.NOT_FOUND.value
82
+ CODE_FORBIDDEN = BulkRefusalCode.FORBIDDEN.value
83
+ CODE_INVALID = BulkRefusalCode.INVALID.value
84
+ CODE_CONFLICT = BulkRefusalCode.CONFLICT.value
85
+ CODE_CYCLIC_DEPENDENCY = BulkRefusalCode.CYCLIC_DEPENDENCY.value
86
+ CODE_SELF_REFERENCE = BulkRefusalCode.SELF_REFERENCE.value
87
+ CODE_UNRESOLVED_ENDPOINT = BulkRefusalCode.UNRESOLVED_ENDPOINT.value
88
+ CODE_TOMBSTONED = BulkRefusalCode.TOMBSTONED.value
89
+ CODE_MILESTONE_GATE = BulkRefusalCode.MILESTONE_GATE.value
90
+
91
+ #: Rejection text for a progress write with nothing to anchor the work to.
92
+ #:
93
+ #: Emitted from all three op handlers (create, update, delete-cascade), so it lives
94
+ #: here rather than being repeated — the three must stay identical, since clients
95
+ #: match on the pair (code, message) and a drifted copy reads as a different error.
96
+ MSG_PROGRESS_NEEDS_ANCHOR = (
97
+ "Cannot record progress without a planned start date or sprint assignment."
98
+ )
99
+
100
+ #: Rejection text for a zero duration written onto a row that is not a milestone.
101
+ #:
102
+ #: Same reason as the constant above: emitted from the bulk row handler AND from
103
+ #: ``TaskViewSet._handle_task_write``, and clients match on the pair
104
+ #: (code, message), so a drifted copy reads as a different error (#3265).
105
+ MSG_ZERO_DURATION_NOT_MILESTONE = (
106
+ "A task with zero duration is a milestone — send is_milestone=true (or "
107
+ "delivery_mode='milestone') to make one. To record work with no estimate, use "
108
+ "a duration of at least 1."
109
+ )
110
+
111
+ #: One resolved edge awaiting the graph guard: (row index, raw row, predecessor, successor).
112
+ _EdgeEntry = tuple[int, dict[str, Any], str, str]
113
+
114
+ #: Maximum operations in one batch.
115
+ #:
116
+ #: ADR-0772 decides that a cap is *required* and leaves the value to #2723. 500
117
+ #: matches ``sync/upload.py``'s ``DEFAULT_MAX_BATCH_ROWS``, so the two write paths
118
+ #: bound a batch identically, and it sits comfortably above the largest realistic
119
+ #: paste-many. Before this, the endpoint had no cap at all: ``operations`` was a
120
+ #: ``ListField(min_length=1)`` with no ``max_length`` and the view declared no
121
+ #: throttles, so the only real bounds were ``DATA_UPLOAD_MAX_MEMORY_SIZE`` and the
122
+ #: global per-user throttle.
123
+ TASK_BULK_MAX_OPERATIONS = 500
124
+
125
+ #: Maximum dependency edges in one batch. Separate budget from ``operations``: a
126
+ #: paste-many of N rows can legitimately carry up to N-1 edges chaining them, so
127
+ #: sharing one budget would make the edge count silently eat the row count.
128
+ TASK_BULK_MAX_DEPENDENCIES = 500
129
+
130
+ #: Maximum inline ``owners`` entries summed across an ENTIRE batch (#3643).
131
+ #:
132
+ #: ``MAX_TASK_OWNERS_PER_WRITE`` (serializers.py, #3596) caps ``owners`` at 100 per
133
+ #: ``TaskSerializer`` instance, but ``TaskBulkView`` builds one serializer per
134
+ #: operation — up to ``TASK_BULK_MAX_OPERATIONS`` of them — so the per-row cap alone
135
+ #: still lets one request compose 500 x 100 = 50,000 owner entries, each minting a
136
+ #: ``TaskActivityEvent`` and (absent the ``broadcast=False`` fix below) a post-commit
137
+ #: ``broadcast_board_event``. That is a ~34x reduction from uncapped, not a closure,
138
+ #: and the same order of magnitude the per-row cap was filed to remove.
139
+ #:
140
+ #: Bounded batch-wide rather than per row, mirroring ``TASK_BULK_MAX_DEPENDENCIES``:
141
+ #: ``dependencies`` is the other nested collection on this endpoint whose budget
142
+ #: could otherwise multiply against ``operations``, and it is capped once for the
143
+ #: whole request rather than once per edge-bearing row. 500 matches
144
+ #: ``TASK_BULK_MAX_OPERATIONS`` and ``TASK_BULK_MAX_DEPENDENCIES`` — a batch naming
145
+ #: more distinct owner assignments than it has rows, or an edge for every row, is
146
+ #: already at the outer edge of what a paste-many legitimately produces.
147
+ TASK_BULK_MAX_OWNERS = 500
148
+
149
+ #: Emitted verbatim when a batch's summed ``owners`` entries exceed the cap. Mirrors
150
+ #: ``MSG_TOO_MANY_OWNERS`` (serializers.py) but states the batch-wide framing so a
151
+ #: caller who already reads the per-row message is not confused by the difference.
152
+ MSG_TOO_MANY_OWNERS_IN_BATCH = (
153
+ "This batch names {actual} owner entries across its operations, more than the "
154
+ "batch-wide limit of {max_length}. Split the paste into smaller batches, or send "
155
+ "fewer owners per row — owners are upserted, so a later write never removes an "
156
+ "owner named by an earlier one."
157
+ )
158
+
159
+
160
+ @dataclasses.dataclass
161
+ class BulkOutcome:
162
+ """Accumulator for one batch's per-row results."""
163
+
164
+ applied: list[dict[str, Any]] = dataclasses.field(default_factory=list)
165
+ rejected: list[dict[str, Any]] = dataclasses.field(default_factory=list)
166
+ skipped: list[dict[str, Any]] = dataclasses.field(default_factory=list)
167
+ dep_applied: list[dict[str, Any]] = dataclasses.field(default_factory=list)
168
+ dep_rejected: list[dict[str, Any]] = dataclasses.field(default_factory=list)
169
+
170
+ #: Task ids that actually changed, in application order — the set the post-commit
171
+ #: recalculation and the ``tasks_bulk_mutated`` broadcast are computed over. A
172
+ #: partial 207 must never broadcast an id it rejected.
173
+ created_ids: list[uuid.UUID] = dataclasses.field(default_factory=list)
174
+ updated_ids: list[uuid.UUID] = dataclasses.field(default_factory=list)
175
+ deleted_ids: list[str] = dataclasses.field(default_factory=list)
176
+ project_start_shifted: bool = False
177
+
178
+ def reject(self, index: int, row_id: Any, code: str, message: str) -> None:
179
+ self.rejected.append(
180
+ {
181
+ "index": index,
182
+ "id": str(row_id) if row_id else None,
183
+ "code": code,
184
+ "message": message,
185
+ }
186
+ )
187
+
188
+ def skip(self, index: int, row_id: Any, code: str, message: str) -> None:
189
+ self.skipped.append(
190
+ {
191
+ "index": index,
192
+ "id": str(row_id) if row_id else None,
193
+ "code": code,
194
+ "message": message,
195
+ }
196
+ )
197
+
198
+ def dep_reject(self, index: int, code: str, message: str, **extra: Any) -> None:
199
+ """Reject one dependency-edge row.
200
+
201
+ ``id`` is always ``null`` — dependency ids are server-assigned (ADR-0772), so
202
+ there is never a client-resolvable id to echo back. Still required in the
203
+ dict: ``TaskBulkProblemEntrySerializer`` (reused for both ``rejected`` and
204
+ ``dependencies.rejected``) declares ``id`` as a required, nullable field, and
205
+ the view returns this dict directly rather than through the serializer, so an
206
+ omitted key reaches the client as a schema violation instead of being caught.
207
+ """
208
+ self.dep_rejected.append(
209
+ {"index": index, "id": None, "code": code, "message": message, **extra}
210
+ )
211
+
212
+ @property
213
+ def mutated_task_ids(self) -> list[str]:
214
+ """Every task id this batch actually changed."""
215
+ return [str(t) for t in self.created_ids + self.updated_ids] + list(self.deleted_ids)
216
+
217
+ @property
218
+ def touched_anything(self) -> bool:
219
+ """Whether any write committed — the condition for recalculating and broadcasting."""
220
+ return bool(self.created_ids or self.updated_ids or self.deleted_ids or self.dep_applied)
221
+
222
+
223
+ @dataclasses.dataclass
224
+ class _CachedParent:
225
+ """A placement-cache entry (#2724 perf): a parent resolved and guard-checked once."""
226
+
227
+ task: Task
228
+ #: 1-based WBS position the NEXT child placed under this parent should get.
229
+ next_child_index: int
230
+
231
+
232
+ @dataclasses.dataclass
233
+ class _PlacementCache:
234
+ """Batch-local memo for :func:`_resolve_bulk_create_placement` (#2724).
235
+
236
+ Without this, a paste of N children under one new phase re-queries that phase's
237
+ sibling count on every row via ``_get_siblings`` — a query whose result set grows
238
+ each time, so N rows cost O(N) queries scanning O(N) cumulative locked rows. Each
239
+ distinct parent is resolved and guard-checked (milestone/depth/phase) exactly ONCE
240
+ per batch; every later row targeting the same parent reuses the cached, already-
241
+ validated parent and increments an in-memory counter instead. Root-level rows get
242
+ the same treatment via a single counter, replacing N calls to
243
+ ``_next_root_wbs_path``'s locked ``.count()`` with one.
244
+ """
245
+
246
+ parents: dict[str, _CachedParent] = dataclasses.field(default_factory=dict)
247
+ #: Next root-level WBS position, or ``None`` until the first root row asks.
248
+ next_root_index: int | None = None
249
+
250
+
251
+ @dataclasses.dataclass(frozen=True)
252
+ class BulkContext:
253
+ """Per-request inputs every row in a batch shares."""
254
+
255
+ project: Project
256
+ request: Request
257
+ caller_role: int
258
+ caller: Any
259
+ #: Live rows this batch references, locked FOR UPDATE, keyed by ``str(pk)``.
260
+ #: Includes create-op ids (ADR-0772): under the upsert branch a create id may
261
+ #: resolve to an existing row, so it must be locked with the rest or the
262
+ #: existence check and the write are a TOCTOU window.
263
+ locked_by_id: dict[str, Task]
264
+ #: Ids that resolve to a task in another project (guard 2).
265
+ foreign_ids: set[str]
266
+ #: Provenance to stamp on rows this batch *creates* (#3038, ADR-0786). Defaults
267
+ #: to ``TaskSource.HAND`` — a caller that declares nothing keeps today's
268
+ #: behavior. Never applied to a recreate-as-edit (``_apply_recreate_as_edit``):
269
+ #: that path edits a row that already exists, so its provenance was set when
270
+ #: the row was first created and is not this write's to overwrite.
271
+ source_kind: str = TaskSource.HAND
272
+ #: Mutated in place across rows (#2724) — frozen only blocks reassigning the
273
+ #: attribute itself, not mutating what it points to.
274
+ placement_cache: _PlacementCache = dataclasses.field(default_factory=_PlacementCache)
275
+
276
+
277
+ def _row_serializer_context(ctx: BulkContext) -> dict[str, Any]:
278
+ """Context every per-row ``TaskSerializer``/``DependencySerializer`` shares.
279
+
280
+ ``suppress_owner_broadcast`` (#3643): this endpoint already fires one coarse
281
+ ``tasks_bulk_mutated`` covering every id the batch touched
282
+ (``_register_bulk_commit_hooks``), unconditionally including every row an
283
+ ``owners`` write applies to — ``_apply_create``/``_apply_update`` add the row's
284
+ id to ``created_ids``/``updated_ids`` regardless of whether it also carried
285
+ ``owners``. A per-entry ``assignment_*`` broadcast from ``apply_task_owners``
286
+ would be pure duplicate load on top of that: both event families funnel to the
287
+ same ``scheduleInvalidate('tasks')`` client-side. Always ``True`` here, not
288
+ conditional — every caller of this helper is inside the batch transaction that
289
+ makes the coarse event true, so there is no branch where the per-entry event
290
+ would be the only signal reaching a client.
291
+ """
292
+ return {
293
+ "request": ctx.request,
294
+ "caller_role": ctx.caller_role,
295
+ "suppress_owner_broadcast": True,
296
+ }
297
+
298
+
299
+ def _first_leaf_message(node: Any) -> str:
300
+ """The first message string under ``node``, descending lists and dicts (#3596).
301
+
302
+ DRF nests a validation error as deep as the payload and only the **leaves** are
303
+ message strings: a scalar field holds a list of them, a nested object holds a dict
304
+ keyed by subfield, a list of objects holds a list with ``{}`` in the slots that
305
+ validated, and a constraint on a *list as a whole* holds a dict keyed
306
+ ``non_field_errors``. Taking ``str()`` of an intermediate node therefore puts a Python
307
+ ``dict``/``ErrorDetail`` repr on the wire as the caller's error message — which is
308
+ what the ``owners`` cap produced before this descended.
309
+
310
+ ``non_field_errors`` wins when present: it carries the constraint that was violated,
311
+ which is the sentence worth showing, rather than an arbitrarily-ordered sibling.
312
+ """
313
+ if isinstance(node, dict):
314
+ key = (
315
+ api_settings.NON_FIELD_ERRORS_KEY
316
+ if api_settings.NON_FIELD_ERRORS_KEY in node
317
+ else next((k for k, v in node.items() if v), None)
318
+ )
319
+ if key is not None:
320
+ return _first_leaf_message(node[key])
321
+ return str(node)
322
+ if isinstance(node, list):
323
+ # Skip the `{}` slots a list-of-objects error uses for the items that validated.
324
+ populated = next((item for item in node if item), None)
325
+ if populated is not None:
326
+ return _first_leaf_message(populated)
327
+ return str(node)
328
+ return str(node)
329
+
330
+
331
+ def _first_error_message(exc: DRFValidationError) -> str:
332
+ """Flatten a DRF validation error to one human sentence for ``rejected[].message``."""
333
+ detail = exc.detail
334
+ if isinstance(detail, dict):
335
+ for field, errors in detail.items():
336
+ return f"{field}: {_first_leaf_message(errors)}"
337
+ if isinstance(detail, list) and detail:
338
+ return _first_leaf_message(detail)
339
+ return str(detail)
340
+
341
+
342
+ def _validate_or_reject(ser: Any, index: int, row_id: Any, out: BulkOutcome) -> bool:
343
+ """Validate one row's serializer, converting a known failure to a rejection.
344
+
345
+ Returns ``True`` when the row validated and the caller may save.
346
+
347
+ The failures are caught separately rather than as one ``Exception`` arm
348
+ because clients match on the pair ``(code, message)`` and each carries a
349
+ different remediation. ``ProgressAnchorError``, ``MilestoneRollupLockedError``
350
+ and ``ZeroDurationNotMilestoneError`` all derive from plain ``Exception``, not
351
+ from ``DRFValidationError``, so the order of the arms is not load-bearing —
352
+ but omitting an arm is: the refusal would escape the row's savepoint and 500
353
+ the whole batch rather than rejecting one row.
354
+
355
+ Safe to use on the create path even though ``MilestoneRollupLockedError`` cannot
356
+ arise there: ``_enforce_milestone_rollup_lock`` short-circuits on
357
+ ``self.instance is None``, so that arm is dead for an unbound serializer rather
358
+ than newly-reachable behavior.
359
+ """
360
+ from trueppm_api.apps.projects.serializers import (
361
+ MilestoneRollupLockedError,
362
+ ProgressAnchorError,
363
+ ZeroDurationNotMilestoneError,
364
+ )
365
+
366
+ try:
367
+ ser.is_valid(raise_exception=True)
368
+ except ProgressAnchorError:
369
+ out.reject(index, row_id, CODE_INVALID, MSG_PROGRESS_NEEDS_ANCHOR)
370
+ return False
371
+ except ZeroDurationNotMilestoneError:
372
+ # Needs its own arm for a reason the other two do not illustrate: this
373
+ # exception does NOT derive from DRFValidationError, so without it the
374
+ # refusal escapes the row's savepoint and 500s the whole batch instead of
375
+ # rejecting one row (#3265).
376
+ out.reject(index, row_id, CODE_INVALID, MSG_ZERO_DURATION_NOT_MILESTONE)
377
+ return False
378
+ except MilestoneRollupLockedError:
379
+ out.reject(
380
+ index, row_id, CODE_INVALID, "This milestone's progress is rolled up from a sprint."
381
+ )
382
+ return False
383
+ except DRFValidationError as exc:
384
+ out.reject(index, row_id, CODE_INVALID, _first_error_message(exc))
385
+ return False
386
+ return True
387
+
388
+
389
+ def _milestone_gate_applies(task: Task, data: dict[str, Any]) -> bool:
390
+ """Whether a classification op must skip this row rather than re-type it (#2723 §4).
391
+
392
+ ``delivery_mode`` and ``is_milestone`` are two encodings of one fact, coupled by
393
+ ``TaskSerializer._reconcile_milestone_signals``. So a classification that sets
394
+ ``delivery_mode`` on a milestone row would silently *un-milestone* it — quietly
395
+ dissolving a gate that the plan's structure depends on.
396
+
397
+ A milestone is therefore skipped, not rejected: a classification crossing a gate
398
+ is a documented no-op, never a failure, so one milestone inside a pasted subtree
399
+ cannot fail the rows around it.
400
+
401
+ An **explicit** ``is_milestone`` in the same op is a deliberate act by an author
402
+ who can see the row, so it proceeds. The gate only catches the case where the
403
+ caller classified a range and a milestone happened to be inside it.
404
+ """
405
+ from trueppm_api.apps.projects.models import DeliveryMode
406
+
407
+ if "delivery_mode" not in data or "is_milestone" in data:
408
+ return False
409
+ if data["delivery_mode"] == DeliveryMode.MILESTONE:
410
+ return False
411
+ return bool(task.is_milestone)
412
+
413
+
414
+ def _resolve_bulk_create_placement(
415
+ index: int,
416
+ row_id: uuid.UUID | None,
417
+ parent_id_raw: Any,
418
+ is_subtask: bool,
419
+ ctx: BulkContext,
420
+ out: BulkOutcome,
421
+ ) -> tuple[Task | None, str] | None:
422
+ """Resolve a bulk create's WBS placement, or record its rejection and return ``None``.
423
+
424
+ Mirrors ``TaskViewSet.perform_create`` rather than duplicating its guards, and
425
+ caches per parent for the batch's duration (``ctx.placement_cache`` — see
426
+ ``_PlacementCache``) so an N-child paste costs O(distinct parents), not O(N),
427
+ in placement queries. Imported locally — ``views`` imports ``task_bulk`` at
428
+ call time (``TaskBulkView.post``), so a module-level import here would be
429
+ circular.
430
+ """
431
+ from trueppm_api.apps.projects.views import (
432
+ _build_wbs_path,
433
+ _next_root_wbs_path,
434
+ _resolve_create_parent,
435
+ )
436
+
437
+ cache = ctx.placement_cache
438
+
439
+ if not parent_id_raw:
440
+ if cache.next_root_index is None:
441
+ cache.next_root_index = int(_next_root_wbs_path(ctx.project))
442
+ wbs_path = str(cache.next_root_index)
443
+ cache.next_root_index += 1
444
+ return None, wbs_path
445
+
446
+ # Unlike `id`, `parent_id` reaches here straight from a raw, unvalidated
447
+ # `DictField` (`data`) — a non-UUID value passed to `.get(pk=...)` raises
448
+ # Django's core `ValidationError`, not DRF's. That type is NOT one of the
449
+ # exceptions `apply_task_operations`'s per-row savepoint catches, so an
450
+ # unparseable `parent_id` would 500 the WHOLE request and roll back every row
451
+ # already applied in this batch — exactly the "one bad row" failure #2723 was
452
+ # written to prevent. Parsed the same way `id` already is (row_identity).
453
+ parent_id = parse_row_uuid(parent_id_raw)
454
+ if parent_id is None:
455
+ out.reject(index, row_id, CODE_MALFORMED_ID, "'parent_id' is not a valid UUID.")
456
+ return None
457
+
458
+ cache_key = str(parent_id)
459
+ cached = cache.parents.get(cache_key)
460
+ if cached is not None:
461
+ wbs_path = _build_wbs_path(str(cached.task.wbs_path), cached.next_child_index)
462
+ cached.next_child_index += 1
463
+ return cached.task, wbs_path
464
+
465
+ try:
466
+ parent, wbs_path = _resolve_create_parent(ctx.project, parent_id, is_subtask=is_subtask)
467
+ except DRFValidationError as exc:
468
+ out.reject(index, row_id, CODE_INVALID, _first_error_message(exc))
469
+ return None
470
+ next_child_index = int(wbs_path.rsplit(".", 1)[-1]) + 1
471
+ cache.parents[cache_key] = _CachedParent(task=parent, next_child_index=next_child_index)
472
+ return parent, wbs_path
473
+
474
+
475
+ def _spawn_subtask_under(parent_task: Task, task: Task, ctx: BulkContext, out: BulkOutcome) -> None:
476
+ """Record a subtask's spawn and fold the parent's mutation into the batch outcome.
477
+
478
+ ``_record_subtask_spawn`` bumps the parent's ``server_version`` — a real mutation
479
+ ``mutated_task_ids`` must carry, or the ``tasks_bulk_mutated`` broadcast silently
480
+ omits it (its own docstring promises "every task id this batch actually changed").
481
+ Skipped when the parent was itself created earlier in this same batch (already in
482
+ ``created_ids``), so the payload never carries a duplicate id.
483
+ """
484
+ from trueppm_api.apps.projects.views import _record_subtask_spawn
485
+
486
+ _record_subtask_spawn(parent_task, task, ctx.request.user)
487
+ if parent_task.pk not in out.created_ids and parent_task.pk not in out.updated_ids:
488
+ out.updated_ids.append(parent_task.pk)
489
+
490
+
491
+ def _apply_recreate_as_edit(
492
+ index: int,
493
+ row_id: uuid.UUID | None,
494
+ existing: Task,
495
+ data: dict[str, Any],
496
+ ctx: BulkContext,
497
+ out: BulkOutcome,
498
+ ) -> None:
499
+ """Apply a ``create`` whose id already exists as an in-place edit (ADR-0772 guard 4).
500
+
501
+ The row landed in a prior batch, so this is an idempotent re-create. It is applied
502
+ under the STRICTER *edit* bar, not the create bar. Getting that backwards is the
503
+ trap the guard names: it would turn a client-mintable id into a privilege-escalation
504
+ primitive, letting a Member mint the id of somebody else's task and rewrite it as a
505
+ "create".
506
+ """
507
+ from trueppm_api.apps.projects.serializers import TaskSerializer
508
+ from trueppm_api.apps.projects.services import maybe_record_scope_injection
509
+
510
+ if not can_user_edit_task(ctx.request, existing, method="PATCH"):
511
+ out.reject(index, row_id, CODE_FORBIDDEN, "You may not edit this task.")
512
+ return
513
+ # Snapshot BEFORE the serializer save: `ser.save()` mutates the instance in
514
+ # place, so reading `sprint_id` afterwards would compare the new value with
515
+ # itself and the injection would never be detected.
516
+ old_sprint_id = str(existing.sprint_id) if existing.sprint_id else None
517
+ ser = TaskSerializer(existing, data=data, partial=True, context=_row_serializer_context(ctx))
518
+ if not _validate_or_reject(ser, index, row_id, out):
519
+ return
520
+ task = ser.save()
521
+ maybe_record_scope_injection(task, old_sprint_id, ctx.request.user)
522
+ out.updated_ids.append(task.pk)
523
+ out.applied.append({"index": index, "id": str(task.pk), "op": "create", "outcome": "updated"})
524
+ _note_project_start_shift(task, out)
525
+
526
+
527
+ def _apply_create(index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome) -> None:
528
+ """Apply one ``create`` op, honoring the four client-minted-id guards."""
529
+ from trueppm_api.apps.projects.serializers import TaskSerializer
530
+ from trueppm_api.apps.projects.services import maybe_record_scope_injection
531
+
532
+ data = dict(op.get("data") or {})
533
+ raw_id = op.get("id")
534
+ row_id: uuid.UUID | None = None
535
+
536
+ if raw_id is not None:
537
+ row_id = parse_row_uuid(raw_id)
538
+ if row_id is None:
539
+ out.reject(index, None, CODE_MALFORMED_ID, "'id' is not a valid UUID.")
540
+ return
541
+ if str(row_id) in ctx.foreign_ids:
542
+ out.reject(
543
+ index, row_id, CODE_ID_UNAVAILABLE, "That id is not available — mint a new one."
544
+ )
545
+ return
546
+
547
+ existing = ctx.locked_by_id.get(str(row_id)) if row_id is not None else None
548
+ if is_tombstoned(existing):
549
+ out.skip(
550
+ index, row_id, CODE_TOMBSTONED, "That id belongs to a deleted row; not re-created."
551
+ )
552
+ return
553
+
554
+ # project is injected from the URL, never read from the row body, so a batch can
555
+ # never create into a different project.
556
+ data.pop("project", None)
557
+ data.pop("wbs_path", None)
558
+ data.pop("id", None)
559
+ # Popped here (not just on the create branch below) so a re-create-as-edit never
560
+ # re-parents an existing row through this path — same as perform_update, which
561
+ # never touches wbs_path on PATCH.
562
+ parent_id_raw = data.pop("parent_id", None)
563
+ is_subtask = str(data.pop("is_subtask", "") or "").lower() in ("true", "1")
564
+
565
+ if existing is not None:
566
+ _apply_recreate_as_edit(index, row_id, existing, data, ctx, out)
567
+ return
568
+
569
+ # Hierarchy placement (#2724). A batch's rows apply in `operations` order
570
+ # (apply_task_operations), so a child row naming a parent created earlier IN
571
+ # THIS SAME BATCH resolves: the parent's INSERT already committed to this
572
+ # transaction, just not to the outer request's response yet — client-minted
573
+ # ids (ADR-0772) make that forward reference possible without a round trip.
574
+ placement = _resolve_bulk_create_placement(index, row_id, parent_id_raw, is_subtask, ctx, out)
575
+ if placement is None:
576
+ return
577
+ parent_task, wbs_path = placement
578
+
579
+ ser = TaskSerializer(
580
+ data={**data, "project": str(ctx.project.pk)}, context=_row_serializer_context(ctx)
581
+ )
582
+ if not _validate_or_reject(ser, index, row_id, out):
583
+ return
584
+
585
+ # The client's UUID becomes the primary key verbatim — never remapped. It is
586
+ # passed as an explicit save() kwarg rather than made writable on TaskSerializer,
587
+ # which is shared by the plain REST create and the sync upload: making the field
588
+ # writable would silently hand a caller-supplied PK to every one of them.
589
+ #
590
+ # source_kind rides the same mechanism (#3038): it answers "what wrote this row"
591
+ # and TaskSource.PASTE sat declared and unwritten until this fix. Deliberately
592
+ # NOT paired with a seeded_at stamp — this path saves through TaskSerializer /
593
+ # Task.save(), which stamps edited_at on the very write that creates the row
594
+ # (task_batch_services.py's module docstring), so seeded_at is meant to stay
595
+ # null here. Setting it would pull every pasted row into
596
+ # TaskManager.untouched_seeded()'s "Delete untouched rows (N)" sweep — a
597
+ # different, already-decided question (ADR-0786 §2) that this fix does not
598
+ # reopen. Do not "fix" that by adding seeded_at alongside it.
599
+ save_kwargs: dict[str, Any] = {
600
+ "wbs_path": wbs_path,
601
+ "is_subtask": is_subtask,
602
+ "source_kind": ctx.source_kind,
603
+ }
604
+ if row_id is not None:
605
+ save_kwargs["id"] = row_id
606
+ task = ser.save(**save_kwargs)
607
+ if is_subtask and parent_task is not None:
608
+ _spawn_subtask_under(parent_task, task, ctx, out)
609
+ maybe_record_scope_injection(task, None, ctx.request.user)
610
+ out.created_ids.append(task.pk)
611
+ out.applied.append({"index": index, "id": str(task.pk), "op": "create", "outcome": "created"})
612
+ _note_project_start_shift(task, out)
613
+
614
+
615
+ def _apply_update(index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome) -> None:
616
+ """Apply one ``update`` op under the per-row edit bar.
617
+
618
+ **Sprint sovereignty is not optional on this path.** Moving a task into an
619
+ ACTIVE sprint is an *injection* (ADR-0102): it must write a
620
+ ``SprintScopeChange`` and set ``sprint_pending`` so the row is visible but
621
+ excluded from the commitment until someone accepts it. Every other write
622
+ path routes through ``maybe_record_scope_injection`` — ``perform_create`` /
623
+ ``perform_update`` on ``TaskViewSet``, both sync upload buckets, and
624
+ ``_apply_create`` right above. This branch did not, so a bulk sprint write
625
+ landed straight in the commitment and moved the burndown with nothing to
626
+ accept or reject (#3152).
627
+ """
628
+ from trueppm_api.apps.projects.serializers import TaskSerializer
629
+ from trueppm_api.apps.projects.services import maybe_record_scope_injection
630
+
631
+ task = _resolve_target(index, op, ctx, out)
632
+ if task is None:
633
+ return
634
+ if not can_user_edit_task(ctx.request, task, method="PATCH"):
635
+ out.reject(index, task.pk, CODE_FORBIDDEN, "You may not edit this task.")
636
+ return
637
+
638
+ data = dict(op.get("data") or {})
639
+ data.pop("project", None)
640
+ data.pop("wbs_path", None)
641
+ data.pop("id", None)
642
+
643
+ if _milestone_gate_applies(task, data):
644
+ out.skip(
645
+ index,
646
+ task.pk,
647
+ CODE_MILESTONE_GATE,
648
+ "Milestone left unchanged — a classification does not re-type a gate.",
649
+ )
650
+ return
651
+
652
+ # Snapshot BEFORE the save, for the same reason `_apply_recreate_as_edit` does.
653
+ old_sprint_id = str(task.sprint_id) if task.sprint_id else None
654
+
655
+ ser = TaskSerializer(task, data=data, partial=True, context=_row_serializer_context(ctx))
656
+ if not _validate_or_reject(ser, index, task.pk, out):
657
+ return
658
+
659
+ saved = ser.save()
660
+ # Unconditional: the helper self-guards subtasks, unchanged links, rows that
661
+ # are already pending, and non-ACTIVE targets, so there is no condition to
662
+ # restate here that could drift from the one the other write paths use.
663
+ maybe_record_scope_injection(saved, old_sprint_id, ctx.request.user)
664
+ out.updated_ids.append(saved.pk)
665
+ out.applied.append({"index": index, "id": str(saved.pk), "op": "update", "outcome": "updated"})
666
+ _note_project_start_shift(saved, out)
667
+
668
+
669
+ def _apply_delete(index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome) -> None:
670
+ """Soft-delete one row, mirroring ``IsProjectMemberWriteOrOwn``."""
671
+ task = _resolve_target(index, op, ctx, out)
672
+ if task is None:
673
+ return
674
+ if (
675
+ ctx.caller_role < Role.ADMIN
676
+ and not task.assignments.filter(resource__user=ctx.caller).exists()
677
+ ):
678
+ out.reject(
679
+ index,
680
+ task.pk,
681
+ CODE_FORBIDDEN,
682
+ "Only Project Managers and task assignees may delete tasks.",
683
+ )
684
+ return
685
+ task.soft_delete()
686
+ out.deleted_ids.append(str(task.pk))
687
+ out.applied.append({"index": index, "id": str(task.pk), "op": "delete", "outcome": "deleted"})
688
+
689
+
690
+ def _resolve_target(
691
+ index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome
692
+ ) -> Task | None:
693
+ """Resolve an update/delete op's ``id`` to a live, locked row in this project.
694
+
695
+ A missing id, an unparseable id, a foreign id and an unknown id are reported
696
+ with distinct *codes* but none of them asserts that the row exists elsewhere —
697
+ ``not_found`` and ``id_unavailable`` are both non-committal, matching the
698
+ sibling ``_lock_bulk_targets`` behavior and #359.
699
+ """
700
+ raw_id = op.get("id")
701
+ if raw_id is None:
702
+ out.reject(index, None, CODE_INVALID, f"'id' is required for op='{op['op']}'.")
703
+ return None
704
+ row_id = parse_row_uuid(raw_id)
705
+ if row_id is None:
706
+ out.reject(index, None, CODE_MALFORMED_ID, "'id' is not a valid UUID.")
707
+ return None
708
+ task = ctx.locked_by_id.get(str(row_id))
709
+ if task is None or task.is_deleted:
710
+ out.reject(index, row_id, CODE_NOT_FOUND, "No such task in this project.")
711
+ return None
712
+ return task
713
+
714
+
715
+ def _note_project_start_shift(task: Task, out: BulkOutcome) -> None:
716
+ """#867: a row that pulled the project start earlier needs a boundary broadcast."""
717
+ if getattr(task, "_project_start_shifted_from", None) is not None:
718
+ out.project_start_shifted = True
719
+
720
+
721
+ _OP_HANDLERS = {"create": _apply_create, "update": _apply_update, "delete": _apply_delete}
722
+
723
+
724
+ #: The deferred exclusion constraint that guards one live task per (project, wbs_path).
725
+ #:
726
+ #: Declared ``Deferrable.DEFERRED`` on ``Task.Meta`` because every WBS-rewrite path
727
+ #: (indent/outdent/reorder/group/ungroup, structural undo) renumbers a whole sibling
728
+ #: level one row at a time and necessarily passes through duplicate intermediate
729
+ #: states. See the constraint's own comment in ``models.py`` for the full reasoning.
730
+ WBS_PATH_CONSTRAINT = "unique_task_wbs_path_per_project_live"
731
+
732
+ #: The two statements :func:`_settle_wbs_path_constraint` runs.
733
+ #:
734
+ #: **Whole literals, not composed from** ``WBS_PATH_CONSTRAINT`` **above.**
735
+ #: ``SET CONSTRAINTS`` takes a constraint *name*, which is an identifier — SQL has no
736
+ #: parameter placeholder for one, so a parameterized form of these statements does not
737
+ #: exist and any composition has to be textual. Semgrep's ``formatted-sql-query`` is a
738
+ #: blocking rule and is right to be, and it follows a constant back to its f-string, so
739
+ #: moving the interpolation off the call site does not answer it. Repeating the name is
740
+ #: the honest answer: there is then no formatting anywhere to justify. The duplication
741
+ #: is pinned by ``test_the_statements_name_the_constraint_that_is_declared``.
742
+ _SET_WBS_CONSTRAINT_IMMEDIATE = 'SET CONSTRAINTS "unique_task_wbs_path_per_project_live" IMMEDIATE'
743
+ _SET_WBS_CONSTRAINT_DEFERRED = 'SET CONSTRAINTS "unique_task_wbs_path_per_project_live" DEFERRED'
744
+
745
+
746
+ def _settle_wbs_path_constraint() -> None:
747
+ """Check the deferred wbs_path constraint now, while the caller's savepoint is open.
748
+
749
+ Postgres evaluates a ``DEFERRED`` constraint at ``COMMIT``, **not** at
750
+ ``RELEASE SAVEPOINT``. Without this the per-row savepoint in
751
+ :func:`apply_task_operations` releases cleanly on a colliding row, the handler
752
+ reports success, the 207 body is assembled with that row marked applied — and the
753
+ violation then surfaces when ``ATOMIC_REQUESTS`` commits *after* the view has
754
+ returned, outside DRF's ``exception_handler``. The client gets an opaque 500 in
755
+ place of the 207 the endpoint's contract promises, and the failure names a
756
+ constraint with no row attribution (#3070).
757
+
758
+ Setting the constraint ``IMMEDIATE`` drains its pending events right here, so a
759
+ violation is raised inside the savepoint and is attributable to the op that caused
760
+ it. The mode is then restored to ``DEFERRED`` so the *next* row is free to pass
761
+ through its own transient duplicates again — the property the constraint was
762
+ deferred for in the first place. On the failure path the restore is not needed:
763
+ the savepoint rollback reverts ``SET CONSTRAINTS`` along with everything else.
764
+
765
+ Postgres-only, and a no-op elsewhere; ``SET CONSTRAINTS`` has no portable
766
+ equivalent and no other backend is supported for this deployment.
767
+ """
768
+ if connection.vendor != "postgresql":
769
+ return
770
+ with connection.cursor() as cursor:
771
+ cursor.execute(_SET_WBS_CONSTRAINT_IMMEDIATE)
772
+ cursor.execute(_SET_WBS_CONSTRAINT_DEFERRED)
773
+
774
+
775
+ def _conflict_message(exc: DatabaseError) -> str:
776
+ """Render a row-level conflict for the 207 body without leaking database text.
777
+
778
+ A wbs_path collision arrives as Postgres's exclusion-constraint message, which
779
+ names the constraint and repeats the raw key — neither of which means anything to
780
+ an API client, and the row attribution the client actually needs is already
781
+ carried by the report's ``index``. Every other ``DatabaseError`` keeps its own
782
+ text: those are unclassified by definition, and hiding them would leave the caller
783
+ with no signal at all.
784
+ """
785
+ if WBS_PATH_CONSTRAINT in str(exc):
786
+ return (
787
+ "Another live task in this project already holds this WBS position. "
788
+ "Move or reorder the row and retry."
789
+ )
790
+ return str(exc)
791
+
792
+
793
+ def apply_task_operations(
794
+ operations: list[dict[str, Any]], ctx: BulkContext, out: BulkOutcome
795
+ ) -> None:
796
+ """Phase 1 — apply every task op, each inside its own savepoint.
797
+
798
+ The savepoint is what makes per-row continuation possible at all. A row that
799
+ fails at the database level (an ``IntegrityError`` the serializer did not catch)
800
+ poisons the enclosing transaction; without a savepoint to roll back to, the very
801
+ next query would raise ``TransactionManagementError`` and the remaining rows
802
+ would fail for a reason that has nothing to do with them.
803
+
804
+ A savepoint alone is not enough for a **deferred** constraint, though: Postgres
805
+ checks those at ``COMMIT``, so the savepoint releases cleanly and the failure lands
806
+ after the view has returned. :func:`_settle_wbs_path_constraint` closes that gap
807
+ for the one deferred constraint task rows can violate (#3070).
808
+ """
809
+ for index, op in enumerate(operations):
810
+ handler = _OP_HANDLERS.get(op.get("op", ""))
811
+ if handler is None:
812
+ out.reject(index, op.get("id"), CODE_INVALID, f"Unknown op '{op.get('op')}'.")
813
+ continue
814
+ before = _outcome_lengths(out)
815
+ try:
816
+ with transaction.atomic(savepoint=True):
817
+ handler(index, op, ctx, out)
818
+ if _outcome_lengths(out) == before:
819
+ # The handler neither applied nor reported — defensive only.
820
+ out.reject(index, op.get("id"), CODE_INVALID, "Operation produced no result.")
821
+ elif op.get("op") != "delete" and len(out.applied) > before[0]:
822
+ # Only a row that actually wrote can have collided, and a delete
823
+ # only ever *frees* a path: soft-delete flips ``is_deleted``, which
824
+ # takes the row out of the constraint's partial condition. Skipping
825
+ # those two cases keeps the two extra statements off the rows that
826
+ # cannot need them, which matters on a large batch.
827
+ _settle_wbs_path_constraint()
828
+ except PermissionDenied as exc:
829
+ _rollback_bookkeeping(out, before)
830
+ out.reject(index, op.get("id"), CODE_FORBIDDEN, str(exc.detail))
831
+ except DRFValidationError as exc:
832
+ _rollback_bookkeeping(out, before)
833
+ out.reject(index, op.get("id"), CODE_INVALID, _first_error_message(exc))
834
+ except DatabaseError as exc:
835
+ # The savepoint has rolled back, so the transaction is usable again and
836
+ # the next row may proceed.
837
+ _rollback_bookkeeping(out, before)
838
+ out.reject(index, op.get("id"), CODE_CONFLICT, _conflict_message(exc))
839
+
840
+ _declare_parents_of_created_rows(ctx, out)
841
+
842
+
843
+ def _declare_parents_of_created_rows(ctx: BulkContext, out: BulkOutcome) -> None:
844
+ """Declare every parent this batch has just given a first structural child (#3036).
845
+
846
+ The single-row create path does this inline; the ``bulk_create`` importers do it
847
+ through ``declare_containers_in_batch``. This endpoint reached neither — it saves
848
+ per row through ``TaskSerializer`` — so a ``create`` naming an existing
849
+ ``parent_id`` left that parent ``structure_role='work'`` **with children**, and the
850
+ Board's rendering rule (ADR-0843) drew it as a card rather than a lane. That is the
851
+ artifact epic #2946 exists to remove, on the endpoint its own headline gesture uses.
852
+
853
+ **Derived from ``out.created_ids``, not recorded as the rows apply.** A handler runs
854
+ inside its own savepoint and ``_rollback_bookkeeping`` truncates ``created_ids`` when
855
+ that savepoint rolls back — so reading the list afterwards is automatically correct
856
+ for the partial-application case, while a parent noted during ``_apply_create`` would
857
+ survive its child's rollback and declare a container for a row that does not exist.
858
+
859
+ **One query set for the batch, not one per row.** A 500-op batch under a single
860
+ parent must not become 500 probes; ``declare_ancestors_of_paths`` sweeps the paths
861
+ and locks the ancestor rows in chunks, which is the shape ``declare_containers_in_batch``
862
+ was written to preserve. Rows created *inside* this batch that are themselves parents
863
+ are covered too — they are ancestors of their own children's paths, and by this point
864
+ every row is in the table.
865
+
866
+ **A declared parent joins ``updated_ids``, or the promotion is invisible to everyone
867
+ else.** ``tasks_bulk_mutated`` carries the batch's ids so clients refetch *those rows*
868
+ rather than the whole board (#1009), and the parent is not any op's target — so
869
+ leaving it out would broadcast a change set that omits the one row whose rendering
870
+ actually changed, and a collaborator's board would keep drawing a card where there is
871
+ now a lane. It does **not** join ``applied``: that bucket is the per-op report,
872
+ correlated by ``index``, and this row answers to no op.
873
+ """
874
+ from trueppm_api.apps.projects.models import Task, declare_ancestors_of_paths
875
+
876
+ if not out.created_ids:
877
+ return
878
+ paths = {
879
+ str(path)
880
+ for path in Task.objects.filter(
881
+ pk__in=out.created_ids, is_subtask=False, is_deleted=False
882
+ ).values_list("wbs_path", flat=True)
883
+ if path
884
+ }
885
+ if not paths:
886
+ return
887
+ for parent in declare_ancestors_of_paths(ctx.project.pk, paths):
888
+ if parent.pk not in out.created_ids and parent.pk not in out.updated_ids:
889
+ out.updated_ids.append(parent.pk)
890
+
891
+
892
+ def _outcome_lengths(out: BulkOutcome) -> tuple[int, ...]:
893
+ return (
894
+ len(out.applied),
895
+ len(out.rejected),
896
+ len(out.skipped),
897
+ len(out.created_ids),
898
+ len(out.updated_ids),
899
+ len(out.deleted_ids),
900
+ )
901
+
902
+
903
+ def _rollback_bookkeeping(out: BulkOutcome, before: tuple[int, ...]) -> None:
904
+ """Discard entries a handler recorded before its savepoint rolled back.
905
+
906
+ Without this, a row whose serializer save succeeded and whose *database flush*
907
+ then failed would stay in ``applied`` and in the broadcast id list while its
908
+ write was rolled back — reporting a mutation that did not happen.
909
+ """
910
+ (
911
+ applied,
912
+ rejected,
913
+ skipped,
914
+ created,
915
+ updated,
916
+ deleted,
917
+ ) = before
918
+ del out.applied[applied:]
919
+ del out.rejected[rejected:]
920
+ del out.skipped[skipped:]
921
+ del out.created_ids[created:]
922
+ del out.updated_ids[updated:]
923
+ del out.deleted_ids[deleted:]
924
+
925
+
926
+ class InvalidGraphInput(Exception):
927
+ """The batch's dependency graph is malformed rather than cyclic.
928
+
929
+ ``validate_task_graph`` can raise instead of returning a cycle path — a
930
+ pathological summary fan-out beyond the engine's cap, or a ``children_map`` that
931
+ is itself cyclic. That is a 400 on the batch as a whole, **not** a per-edge
932
+ ``cyclic_dependency`` rejection: there is no identified cycle path, so there is
933
+ no principled subset of edges to reject (ADR-0772 §4).
934
+ """
935
+
936
+
937
+ def _cycle_edge_pairs(cycle_path: list[str]) -> set[tuple[str, str]]:
938
+ """Consecutive ``(pred, succ)`` pairs along a reported cycle path.
939
+
940
+ ``find_cycle`` returns the path with the first id repeated at the end
941
+ (``["A", "B", "A"]``), so the edges on the cycle are its adjacent pairs.
942
+ """
943
+ return {(cycle_path[i], cycle_path[i + 1]) for i in range(len(cycle_path) - 1)}
944
+
945
+
946
+ def _resolve_edges(
947
+ edge_rows: list[dict[str, Any]], ctx: BulkContext, out: BulkOutcome
948
+ ) -> list[_EdgeEntry]:
949
+ """Resolve and role-gate every proposed edge; return the survivors.
950
+
951
+ Two gates, both per-edge rather than per-request, so a Member's task rows still
952
+ apply and only their edge rows reject (ADR-0772 §3).
953
+ """
954
+ from trueppm_api.apps.projects.models import Task
955
+
956
+ survivors: list[_EdgeEntry] = []
957
+ for index, row in enumerate(edge_rows):
958
+ # Dependency ops sit at IsProjectScheduler — one role ABOVE this view's
959
+ # IsProjectMemberWrite floor. Accepting edges under the view's own gate
960
+ # would silently hand dependency authoring to Member.
961
+ if ctx.caller_role < Role.SCHEDULER:
962
+ out.dep_reject(
963
+ index, CODE_FORBIDDEN, "Your role cannot create dependencies on this project."
964
+ )
965
+ continue
966
+ if row.get("id") is not None:
967
+ # Client-minted ids are scoped to Task only. All four guards are written
968
+ # about task ids and none would cover a caller-supplied Dependency.id, so
969
+ # a supplied edge id is rejected rather than ignored.
970
+ out.dep_reject(index, CODE_INVALID, "Dependency ids are server-assigned.")
971
+ continue
972
+
973
+ pred = parse_row_uuid(row.get("predecessor"))
974
+ succ = parse_row_uuid(row.get("successor"))
975
+ if pred is None or succ is None:
976
+ out.dep_reject(index, CODE_MALFORMED_ID, "Edge endpoints must be task UUIDs.")
977
+ continue
978
+ # Forward references are legal: an edge may name a task whose create op
979
+ # appears LATER in `operations`, because phase 1 has already materialized
980
+ # every row by the time we get here.
981
+ live = set(
982
+ Task.objects.filter(pk__in=[pred, succ], is_deleted=False).values_list("pk", flat=True)
983
+ )
984
+ if pred not in live or succ not in live:
985
+ out.dep_reject(
986
+ index, CODE_UNRESOLVED_ENDPOINT, "Edge endpoint does not resolve to a live task."
987
+ )
988
+ continue
989
+ survivors.append((index, row, str(pred), str(succ)))
990
+ return survivors
991
+
992
+
993
+ def _drop_offending_edges(
994
+ remaining: list[_EdgeEntry],
995
+ offending_pairs: set[tuple[str, str]],
996
+ out: BulkOutcome,
997
+ code: str,
998
+ message: str,
999
+ offending: list[str],
1000
+ ) -> list[_EdgeEntry] | None:
1001
+ """Reject the batch edges the guard blamed; return the survivors.
1002
+
1003
+ Returns ``None`` when nothing was dropped. That is the terminating signal for
1004
+ the caller's loop: every edge the guard named is already persisted, so this
1005
+ batch offered nothing on the offending path and rejecting more of its own rows
1006
+ cannot clear the condition.
1007
+ """
1008
+ kept = [entry for entry in remaining if (entry[2], entry[3]) not in offending_pairs]
1009
+ if len(kept) == len(remaining):
1010
+ return None
1011
+ for index, _row, pred, succ in remaining:
1012
+ if (pred, succ) in offending_pairs:
1013
+ out.dep_reject(index, code, message, offending=offending)
1014
+ return kept
1015
+
1016
+
1017
+ def _guard_edge_graph(
1018
+ survivors: list[_EdgeEntry], ctx: BulkContext, out: BulkOutcome
1019
+ ) -> list[_EdgeEntry]:
1020
+ """Run the ADR-0259 graph guard over existing ∪ batch edges, before any write.
1021
+
1022
+ The guard is absolute over what gets written, and what it refuses to write is
1023
+ exactly the edges that would close a cycle — so "one bad row out of 38 must not
1024
+ discard the other 37" stays true without weakening it. Task rows already applied
1025
+ in phase 1 are never rolled back by a cycle.
1026
+
1027
+ Iterates because rejecting one cycle can leave another: each pass drops the batch
1028
+ edges lying on the reported path and re-runs. A pass that finds a cycle composed
1029
+ entirely of *already-persisted* edges terminates the loop — that is a pre-existing
1030
+ condition this batch did not introduce and cannot fix by rejecting its own rows.
1031
+ """
1032
+ from trueppm_scheduler import InvalidScheduleInput
1033
+
1034
+ from trueppm_api.apps.projects.models import Dependency
1035
+ from trueppm_api.apps.projects.serializers import _load_project_tasks_and_children_map
1036
+ from trueppm_api.apps.scheduling.graph_guard import (
1037
+ InfeasibleGraphError,
1038
+ validate_task_graph,
1039
+ )
1040
+
1041
+ if not survivors:
1042
+ return survivors
1043
+
1044
+ task_ids, children_map = _load_project_tasks_and_children_map(ctx.project.pk)
1045
+ persisted: list[tuple[str, str]] = [
1046
+ (str(p), str(s))
1047
+ for p, s in Dependency.objects.filter(
1048
+ is_deleted=False, predecessor_id__in=[uuid.UUID(t) for t in task_ids]
1049
+ ).values_list("predecessor_id", "successor_id")
1050
+ ]
1051
+
1052
+ remaining = list(survivors)
1053
+ while remaining:
1054
+ batch_edges = [(pred, succ) for _idx, _row, pred, succ in remaining]
1055
+ try:
1056
+ validate_task_graph(persisted + batch_edges, children_map=children_map)
1057
+ except InfeasibleGraphError as exc:
1058
+ # Both verdicts reduce to the same shape — a set of offending (pred, succ)
1059
+ # pairs — so they share one rejection pass. A self-reference is the edge
1060
+ # (x, x) for each blamed node x; a cycle is the adjacent pairs along the
1061
+ # reported path.
1062
+ if exc.reason == "self_reference":
1063
+ offending_pairs = {(node, node) for node in exc.offending}
1064
+ code = CODE_SELF_REFERENCE
1065
+ message = "A task cannot depend on itself."
1066
+ else:
1067
+ offending_pairs = _cycle_edge_pairs(exc.offending)
1068
+ code = CODE_CYCLIC_DEPENDENCY
1069
+ message = "This dependency would create a cycle."
1070
+
1071
+ kept = _drop_offending_edges(
1072
+ remaining, offending_pairs, out, code, message, exc.offending
1073
+ )
1074
+ if kept is None:
1075
+ # Nothing of ours is on the offending path — it lies entirely in
1076
+ # already-persisted edges, so dropping more of our own rows cannot help.
1077
+ break
1078
+ remaining = kept
1079
+ continue
1080
+ except InvalidScheduleInput as exc:
1081
+ raise InvalidGraphInput(str(exc)) from exc
1082
+ return remaining
1083
+ return remaining
1084
+
1085
+
1086
+ def apply_dependency_edges(
1087
+ edge_rows: list[dict[str, Any]], ctx: BulkContext, out: BulkOutcome
1088
+ ) -> set[str]:
1089
+ """Phase 2 — resolve, guard, then write the batch's dependency edges.
1090
+
1091
+ Runs after phase 1 inside the SAME ``transaction.atomic()``, so a crash between
1092
+ the two cannot leave tasks without their batch's edges. Returns the set of
1093
+ project ids whose schedule the written edges invalidated — the predecessor's
1094
+ project always, plus the successor's when an accepted cross-project edge changes
1095
+ the CPM input on both sides (ADR-0120 D3).
1096
+
1097
+ Skipped entirely when the batch carries no edges: the union graph is built from
1098
+ every persisted edge in the project, so its cost scales with project size rather
1099
+ than batch size, and a paste-many of 38 rows that creates no edges must not pay
1100
+ for the rare case that does.
1101
+ """
1102
+ from trueppm_api.apps.projects.serializers import DependencySerializer
1103
+
1104
+ if not edge_rows:
1105
+ return set()
1106
+
1107
+ survivors = _guard_edge_graph(_resolve_edges(edge_rows, ctx, out), ctx, out)
1108
+ recalc_project_ids: set[str] = set()
1109
+
1110
+ for index, row, pred, succ in survivors:
1111
+ payload = {
1112
+ "predecessor": pred,
1113
+ "successor": succ,
1114
+ "dep_type": row.get("dep_type", "FS"),
1115
+ "lag": row.get("lag", 0),
1116
+ }
1117
+ before = (len(out.dep_applied), len(out.dep_rejected))
1118
+ try:
1119
+ with transaction.atomic(savepoint=True):
1120
+ ser = DependencySerializer(data=payload, context=_row_serializer_context(ctx))
1121
+ ser.is_valid(raise_exception=True)
1122
+ # Writing through the serializer rather than Dependency.objects is not
1123
+ # a style preference: it carries the ADR-0120 D2 consent gate, the
1124
+ # cross-program rejection, the merged-program cycle check and the span
1125
+ # guard. A direct write re-implements four controls.
1126
+ consent = getattr(ser, "_consent", None)
1127
+ instance = ser.save(**consent) if consent else ser.save()
1128
+ pred_pid = str(instance.predecessor.project_id)
1129
+ succ_pid = str(instance.successor.project_id)
1130
+ recalc_project_ids.add(pred_pid)
1131
+ if pred_pid != succ_pid and not instance.pending_acceptance:
1132
+ recalc_project_ids.add(succ_pid)
1133
+ out.dep_applied.append(
1134
+ {
1135
+ "index": index,
1136
+ "id": str(instance.pk),
1137
+ "predecessor": pred,
1138
+ "successor": succ,
1139
+ "pending_acceptance": instance.pending_acceptance,
1140
+ }
1141
+ )
1142
+ except PermissionDenied as exc:
1143
+ del out.dep_applied[before[0] :]
1144
+ out.dep_reject(index, CODE_FORBIDDEN, str(exc.detail))
1145
+ except DRFValidationError as exc:
1146
+ del out.dep_applied[before[0] :]
1147
+ out.dep_reject(index, CODE_INVALID, _first_error_message(exc))
1148
+ except DatabaseError as exc:
1149
+ del out.dep_applied[before[0] :]
1150
+ out.dep_reject(index, CODE_CONFLICT, str(exc))
1151
+
1152
+ return recalc_project_ids