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,1917 @@
1
+ """Celery tasks for the projects app — sprint close drain + burndown beat.
2
+
3
+ The transactional outbox pattern from ``apps/scheduling`` is mirrored here
4
+ for sprint close: the API endpoint inserts a ``SprintCloseRequest`` row in
5
+ the same DB transaction as the state change and returns 202 Accepted; this
6
+ drain task picks up PENDING rows and applies the actual close transition
7
+ under ``select_for_update`` (so concurrent drains never double-close).
8
+
9
+ See ADR-0037 for the full spec.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from datetime import timedelta
16
+ from typing import Any
17
+
18
+ from celery import shared_task
19
+ from django.db import transaction
20
+ from django.utils import timezone
21
+
22
+ from trueppm_api.core.idempotent import idempotent_task
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ EXPORT_MAX_RETRIES = 3 # ADR-0219 §Durable Execution item 8
27
+ EXPORT_ORPHAN_WINDOW_MINUTES = 5 # ADR-0219 §Durable Execution item 3
28
+ EXPORT_DRAIN_BATCH_SIZE = 10
29
+ DEFAULT_EXPORT_RETENTION_DAYS = 7 # ADR-0219 §Durable Execution item 6 (shared with ADR-0174)
30
+
31
+ # Rows one purge run may reap, shared by the export and import purges (#2615).
32
+ # Sized so a full batch of storage DELETE round-trips finishes well inside the
33
+ # 55 s soft limit those tasks carry; a larger backlog drains over several runs.
34
+ PURGE_BATCH_SIZE = 500
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Config-change notices — outbox worker, drain, purge (#3009, ADR-1174)
39
+ # ---------------------------------------------------------------------------
40
+
41
+ # Pending rows younger than this are left to their own on-commit dispatch, which
42
+ # may still be in flight; the drain only picks up what that dispatch stranded.
43
+ CONFIG_NOTICE_ORPHAN_WINDOW = timedelta(minutes=5)
44
+ # Past this a dispatched or running row is presumed lost. Well clear of the emit's
45
+ # 150 s hard time limit, so a live emit is never re-driven underneath itself.
46
+ CONFIG_NOTICE_RECOVERY_WINDOW = timedelta(minutes=10)
47
+ CONFIG_NOTICE_DRAIN_BATCH_SIZE = 100
48
+ CONFIG_NOTICE_RETENTION = timedelta(days=7)
49
+
50
+
51
+ @idempotent_task(
52
+ lock_key_template="config_notice:{0}",
53
+ lock_ttl=180,
54
+ on_contention="skip",
55
+ soft_time_limit=120,
56
+ time_limit=150,
57
+ acks_late=True,
58
+ reject_on_worker_lost=True,
59
+ name="projects.emit_config_notice",
60
+ )
61
+ def emit_config_notice(self: object, request_id: str) -> None:
62
+ """Emit one queued config-change notice.
63
+
64
+ Idempotent on the outbox row: the business logic claims it with a conditional
65
+ UPDATE and no-ops on a row that is running elsewhere or already finished, so a
66
+ drain re-dispatch racing the on-commit dispatch sends the notice once.
67
+
68
+ Args:
69
+ request_id: ConfigNoticeRequest UUID string.
70
+ """
71
+ from trueppm_api.apps.projects.config_notice import run_config_notice_request
72
+
73
+ run_config_notice_request(request_id)
74
+
75
+
76
+ @idempotent_task(
77
+ lock_key_template="drain_config_notice_requests",
78
+ lock_ttl=60,
79
+ on_contention="skip",
80
+ soft_time_limit=25,
81
+ time_limit=30,
82
+ acks_late=True,
83
+ reject_on_worker_lost=True,
84
+ name="projects.drain_config_notice_requests",
85
+ )
86
+ def drain_config_notice_requests(self: object) -> None:
87
+ """Recover lost config-notice rows and dispatch stranded pending ones.
88
+
89
+ Runs every 30 seconds via Celery Beat. The singleton lock plus
90
+ ``on_contention="skip"`` means an overlapping tick is dropped rather than
91
+ queued — the next tick covers it.
92
+ """
93
+ _do_config_notice_drain()
94
+
95
+
96
+ @idempotent_task(
97
+ lock_key_template="purge_old_config_notice_requests",
98
+ lock_ttl=120,
99
+ on_contention="skip",
100
+ soft_time_limit=55,
101
+ time_limit=90,
102
+ acks_late=True,
103
+ reject_on_worker_lost=True,
104
+ name="projects.purge_old_config_notice_requests",
105
+ )
106
+ def purge_old_config_notice_requests(self: object) -> None:
107
+ """Delete done/dead ConfigNoticeRequest rows older than seven days. Nightly."""
108
+ _do_config_notice_purge()
109
+
110
+
111
+ def _do_config_notice_drain() -> None:
112
+ """Business logic for drain_config_notice_requests — extracted for testability."""
113
+ from trueppm_api.apps.projects.config_notice import MAX_CONFIG_NOTICE_ATTEMPTS
114
+ from trueppm_api.apps.projects.models import ConfigNoticeRequest, ConfigNoticeRequestStatus
115
+
116
+ now = timezone.now()
117
+ stale = ConfigNoticeRequest.objects.filter(
118
+ status__in=[ConfigNoticeRequestStatus.DISPATCHED, ConfigNoticeRequestStatus.RUNNING],
119
+ claimed_at__lt=now - CONFIG_NOTICE_RECOVERY_WINDOW,
120
+ )
121
+ abandoned = stale.filter(attempt_count__gte=MAX_CONFIG_NOTICE_ATTEMPTS).update(
122
+ status=ConfigNoticeRequestStatus.DEAD, completed_at=now
123
+ )
124
+ recovered = stale.filter(attempt_count__lt=MAX_CONFIG_NOTICE_ATTEMPTS).update(
125
+ status=ConfigNoticeRequestStatus.PENDING, celery_task_id=""
126
+ )
127
+ if abandoned:
128
+ logger.error(
129
+ "drain_config_notice_requests: abandoned %d notice(s) after %d attempts",
130
+ abandoned,
131
+ MAX_CONFIG_NOTICE_ATTEMPTS,
132
+ )
133
+ if recovered:
134
+ logger.warning("drain_config_notice_requests: recovered %d lost notice(s)", recovered)
135
+
136
+ pending = list(
137
+ ConfigNoticeRequest.objects.filter(
138
+ status=ConfigNoticeRequestStatus.PENDING,
139
+ requested_at__lt=now - CONFIG_NOTICE_ORPHAN_WINDOW,
140
+ )
141
+ .order_by("requested_at")
142
+ .values_list("pk", flat=True)[:CONFIG_NOTICE_DRAIN_BATCH_SIZE]
143
+ )
144
+ dispatched = 0
145
+ for pk in pending:
146
+ try:
147
+ result = emit_config_notice.delay(str(pk))
148
+ except Exception:
149
+ logger.warning(
150
+ "drain_config_notice_requests: broker unavailable — %d notice(s) stay pending",
151
+ len(pending) - dispatched,
152
+ )
153
+ break
154
+ # Conditional: a concurrent on-commit dispatch may already have moved it.
155
+ ConfigNoticeRequest.objects.filter(pk=pk, status=ConfigNoticeRequestStatus.PENDING).update(
156
+ status=ConfigNoticeRequestStatus.DISPATCHED,
157
+ celery_task_id=str(result.id),
158
+ claimed_at=now,
159
+ )
160
+ dispatched += 1
161
+
162
+ if dispatched:
163
+ logger.info("drain_config_notice_requests: dispatched=%d", dispatched)
164
+
165
+
166
+ def _do_config_notice_purge() -> None:
167
+ """Business logic for purge_old_config_notice_requests — extracted for testability."""
168
+ from trueppm_api.apps.projects.models import ConfigNoticeRequest, ConfigNoticeRequestStatus
169
+
170
+ deleted, _ = ConfigNoticeRequest.objects.filter(
171
+ status__in=[ConfigNoticeRequestStatus.DONE, ConfigNoticeRequestStatus.DEAD],
172
+ requested_at__lt=timezone.now() - CONFIG_NOTICE_RETENTION,
173
+ ).delete()
174
+ logger.info("purge_old_config_notice_requests: deleted %d row(s)", deleted)
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # close_sprint — applies a single SprintCloseRequest
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ # How many times a close may be attempted before the request is abandoned, and
183
+ # how long a failed one waits before the drain re-queues it (#2894). The budget
184
+ # is enforced by the drain rather than by the Celery decorator's ``max_retries``
185
+ # — see the exception handler in close_sprint for why a broker-level retry
186
+ # cannot work here.
187
+ _MAX_CLOSE_ATTEMPTS = 3
188
+ _CLOSE_RETRY_BACKOFF = timedelta(seconds=60)
189
+ # Per-sweep caps. Fresh closes and retries draw from separate dispatch budgets so
190
+ # neither can starve the other — see the drain for why one shared, ordered slice
191
+ # stopped being safe once failed rows could re-enter the pool.
192
+ _DISPATCH_BUDGET = 50
193
+ _RETRY_DISPATCH_BUDGET = 10
194
+ _RETRY_REQUEUE_BUDGET = 200
195
+ # Deliberately far below _RETRY_REQUEUE_BUDGET (#2992). Re-queueing is one bulk
196
+ # UPDATE whose cost barely moves with the row count; abandoning now costs a
197
+ # transaction, a BoardEvent insert and a channel-layer fan-out *per row*, and it
198
+ # runs ahead of the dispatch sweeps inside a 25 s soft limit. The path fires in
199
+ # bulk precisely during a correlated outage, which is the worst moment to spend
200
+ # that budget — so it takes a small bite and leaves the rest for the next sweep.
201
+ _ABANDON_BUDGET = 50
202
+
203
+
204
+ @idempotent_task(
205
+ lock_key_template="sprint_close_lock:{0}",
206
+ lock_ttl=120,
207
+ on_contention="skip",
208
+ max_retries=3,
209
+ retry_backoff=10,
210
+ retry_backoff_max=60,
211
+ retry_jitter=True,
212
+ soft_time_limit=55,
213
+ time_limit=90,
214
+ acks_late=True,
215
+ reject_on_worker_lost=True,
216
+ )
217
+ def close_sprint(self: object, request_id: str) -> None:
218
+ """Apply a queued sprint close transition.
219
+
220
+ Idempotency: if the SprintCloseRequest is already COMPLETED or FAILED, the
221
+ task is a no-op. If the sprint is already COMPLETED for any reason, the
222
+ request is short-circuited to COMPLETED. Re-entry under broker retry
223
+ therefore never produces duplicate close side-effects.
224
+
225
+ Recovery from a failure runs through the **drain**, not through the broker
226
+ (#2894). A failure rolls the transaction back and marks the row FAILED with
227
+ a ``next_attempt_at``; ``drain_sprint_close_requests`` resets it to PENDING
228
+ once that time passes and re-dispatches, up to ``_MAX_CLOSE_ATTEMPTS``. The
229
+ decorator's ``max_retries`` cannot serve this: its retry re-enters here and
230
+ hits the FAILED short-circuit above, so it would spend the budget without
231
+ running the close. When the budget is spent ``next_attempt_at`` stays null,
232
+ the row is abandoned, and the sprint remains ACTIVE — recoverable only by
233
+ issuing a fresh close request. That abandonment is announced on the board as
234
+ ``sprint_close_failed`` (#2992) via ``_finalize_close_failure``, which is the
235
+ one place any close request is marked FAILED; an intermediate, still-
236
+ retryable failure deliberately announces nothing.
237
+
238
+ On success the function:
239
+ 1. Snapshots ``completed_*`` from current task state
240
+ 2. Transitions sprint state to COMPLETED + sets closed_at
241
+ 3. Applies carry-over (FK move on incomplete tasks)
242
+ 4. Marks the SprintCloseRequest COMPLETED
243
+ 5. Enqueues a ScheduleRequest with ``reason=SPRINT_CLOSED`` for CPM recompute
244
+ 6. Broadcasts a ``sprint_closed`` board event to connected clients
245
+
246
+ Args:
247
+ request_id: SprintCloseRequest UUID string.
248
+ """
249
+ from trueppm_api.apps.projects.models import (
250
+ SprintCloseRequest,
251
+ SprintCloseRequestFailureReason,
252
+ SprintCloseRequestStatus,
253
+ SprintState,
254
+ )
255
+ from trueppm_api.apps.projects.services import (
256
+ apply_carry_over,
257
+ apply_pending_disposition,
258
+ notify_carryover_assignees,
259
+ snapshot_completed_metrics,
260
+ snapshot_sprint_task_outcomes,
261
+ )
262
+ from trueppm_api.apps.scheduling.models import (
263
+ ScheduleRequest,
264
+ ScheduleRequestReason,
265
+ )
266
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
267
+
268
+ try:
269
+ req = SprintCloseRequest.objects.select_related("sprint", "sprint__project").get(
270
+ pk=request_id
271
+ )
272
+ except SprintCloseRequest.DoesNotExist:
273
+ logger.warning("close_sprint: request %s not found, skipping", request_id)
274
+ return
275
+
276
+ if req.status in (SprintCloseRequestStatus.COMPLETED, SprintCloseRequestStatus.FAILED):
277
+ logger.info("close_sprint: request %s already %s — short-circuit", request_id, req.status)
278
+ return
279
+
280
+ # This increment MUST stay outside the `transaction.atomic()` below, and that
281
+ # placement is load-bearing rather than incidental (#2894): the retry budget
282
+ # is the only thing bounding the drain's re-queue loop, and it is enforced by
283
+ # comparing attempt_count. Wrapping close_sprint in @transaction.atomic — or
284
+ # moving this inside the block — rolls the increment back together with the
285
+ # failed close, so attempt_count never advances, the budget is never spent,
286
+ # and a permanently failing close is re-queued forever.
287
+ SprintCloseRequest.objects.filter(pk=req.pk).update(
288
+ status=SprintCloseRequestStatus.IN_FLIGHT,
289
+ started_at=timezone.now(),
290
+ attempt_count=req.attempt_count + 1,
291
+ )
292
+
293
+ try:
294
+ with transaction.atomic():
295
+ # Lock the sprint row: prevents two concurrent close attempts from
296
+ # double-snapshotting completed_* or applying carry-over twice.
297
+ from trueppm_api.apps.projects.models import Sprint
298
+
299
+ sprint = Sprint.objects.select_for_update().get(pk=req.sprint_id)
300
+
301
+ if _finalize_non_closable_sprint(req, sprint):
302
+ return
303
+
304
+ snapshot_completed_metrics(sprint)
305
+ sprint.state = SprintState.COMPLETED
306
+ sprint.closed_at = timezone.now()
307
+ sprint.save(
308
+ update_fields=[
309
+ "completed_points",
310
+ "completed_task_count",
311
+ "goal_outcome",
312
+ "state",
313
+ "closed_at",
314
+ ]
315
+ )
316
+
317
+ # ADR-0176 §2 (#982): snapshot the closing task membership BEFORE
318
+ # apply_carry_over mutates Task.sprint — otherwise "what didn't ship"
319
+ # is destroyed (carried tasks move to the next sprint, dropped tasks
320
+ # to the backlog). NOT wrapped in try/except: the audit is part of the
321
+ # close's definition of done, so a failure rolls the whole close back
322
+ # and the drain re-queues the request.
323
+ #
324
+ # That second clause was false when written (#2894) — the handler
325
+ # marked the row FAILED and nothing ever picked a FAILED row up. It
326
+ # is true now, but bounded: _MAX_CLOSE_ATTEMPTS tries, then the
327
+ # request is abandoned and the sprint stays ACTIVE. An audit failure
328
+ # that reproduces will still strand the close; it just no longer does
329
+ # so on the first try, silently.
330
+ snapshot_sprint_task_outcomes(sprint, carry_over_to=req.carry_over_to)
331
+
332
+ _clear_sprint_ranks(sprint)
333
+
334
+ carried_task_ids = apply_carry_over(sprint, req.carry_over_to)
335
+
336
+ # ADR-0102 §7: dispose of tasks still pending acceptance at close.
337
+ # Runs AFTER carry-over (so a carried task's new sprint_id is set) and
338
+ # never blocks the close. 'carry' re-flags pending tasks in the
339
+ # incoming sprint; 'reject' removes them from the sprint.
340
+ apply_pending_disposition(sprint, req.pending_disposition, by=req.requested_by)
341
+
342
+ # ADR-0232 (#1470): tell each carried task's assignee their committed
343
+ # work crossed the close→plan seam. Sourced from the faithful moved-id
344
+ # set; defers create_event_notifications to on_commit inside the
345
+ # service. Non-blocking like the reforecast digest below: a
346
+ # notification bug must never strand or revert a sprint close.
347
+ try:
348
+ notify_carryover_assignees(
349
+ sprint,
350
+ req.carry_over_to,
351
+ carried_task_ids,
352
+ actor_id=req.requested_by_id,
353
+ )
354
+ except Exception:
355
+ logger.exception(
356
+ "close_sprint: carryover notification failed for sprint %s — continuing close",
357
+ sprint.pk,
358
+ )
359
+
360
+ _recompute_and_reforecast_milestone(sprint, req)
361
+
362
+ SprintCloseRequest.objects.filter(pk=req.pk).update(
363
+ status=SprintCloseRequestStatus.COMPLETED,
364
+ completed_at=timezone.now(),
365
+ error_message="",
366
+ failure_reason="",
367
+ # Structural, not incidental: every terminal writer clears the
368
+ # retry clock, so "null means no further attempt" holds by
369
+ # construction rather than because no live path happens to leave
370
+ # a stale value behind.
371
+ next_attempt_at=None,
372
+ )
373
+
374
+ # Enqueue downstream CPM recompute with the SPRINT_CLOSED reason
375
+ # so the audit trail records why the recalculation fired.
376
+ project_id = sprint.project_id
377
+ ScheduleRequest.objects.create(
378
+ project_id=project_id,
379
+ reason=ScheduleRequestReason.SPRINT_CLOSED,
380
+ )
381
+
382
+ _compute_velocity_suggestions_safe(sprint)
383
+
384
+ sprint_id_str = str(sprint.pk)
385
+ project_id_str = str(project_id)
386
+ transaction.on_commit(
387
+ lambda: broadcast_board_event(
388
+ project_id_str,
389
+ "sprint_closed",
390
+ {"id": sprint_id_str},
391
+ )
392
+ )
393
+ # ADR-0147: emit the sprint.closed webhook. The completion snapshot in
394
+ # the payload is team velocity and is privacy-gated by the builder
395
+ # (ADR-0104) — built now, inside the close transaction, so the gate reads
396
+ # the committed policy and the on_commit callback does no DB work.
397
+ from trueppm_api.apps.projects.views import (
398
+ _dispatch_webhooks,
399
+ _sprint_closed_webhook_payload,
400
+ )
401
+
402
+ closed_payload = _sprint_closed_webhook_payload(sprint, source="sprint_close")
403
+ transaction.on_commit(
404
+ lambda: _dispatch_webhooks(project_id_str, "sprint.closed", closed_payload)
405
+ )
406
+ # The carried-over tasks changed sprint (and possibly status). Without
407
+ # a broadcast, connected clients keep rendering them under the closed
408
+ # sprint until a manual refetch. Emit one bulk event for the batch.
409
+ # Bind the ids via a default arg (matches the backlog_services pattern)
410
+ # so closure late-binding can't swap them if this grows more branches.
411
+ if carried_task_ids:
412
+
413
+ def _broadcast_carry_over(
414
+ pid: str = project_id_str, ids: list[str] = carried_task_ids
415
+ ) -> None:
416
+ broadcast_board_event(pid, "tasks_bulk_mutated", {"task_ids": ids})
417
+
418
+ transaction.on_commit(_broadcast_carry_over)
419
+
420
+ except Exception as exc:
421
+ logger.exception("close_sprint: failed for request %s", request_id)
422
+ # The whole close rolled back, so the sprint is still ACTIVE and the row
423
+ # is safe to run again from the top. Schedule a retry until the budget is
424
+ # spent; ``next_attempt_at=None`` on the last one is what makes it
425
+ # terminal (#2894).
426
+ #
427
+ # Deliberately NOT re-raised to drive the decorator's max_retries. A
428
+ # retry there re-enters close_sprint, which short-circuits on a FAILED
429
+ # row and returns immediately — the retry would burn the budget without
430
+ # ever reaching the work. The drain is the recovery path because it is
431
+ # the thing that can reset status back to PENDING first.
432
+ attempts = req.attempt_count + 1 # this run, already applied above
433
+ exhausted = attempts >= _MAX_CLOSE_ATTEMPTS
434
+ _finalize_close_failure(
435
+ req_pk=req.pk,
436
+ sprint_id=req.sprint_id,
437
+ project_id=req.sprint.project_id,
438
+ error_message=str(exc)[:1000],
439
+ failure_reason=SprintCloseRequestFailureReason.ERROR,
440
+ attempt_count=attempts,
441
+ next_attempt_at=None if exhausted else timezone.now() + _CLOSE_RETRY_BACKOFF,
442
+ )
443
+ if exhausted:
444
+ logger.error(
445
+ "close_sprint: request %s exhausted %d attempts — sprint %s stays ACTIVE",
446
+ request_id,
447
+ attempts,
448
+ req.sprint_id,
449
+ )
450
+
451
+
452
+ def _finalize_close_failure(
453
+ *,
454
+ req_pk: Any,
455
+ sprint_id: Any,
456
+ project_id: Any,
457
+ error_message: str,
458
+ failure_reason: str,
459
+ attempt_count: int,
460
+ next_attempt_at: Any = None,
461
+ expected_status: str | None = None,
462
+ ) -> None:
463
+ """Mark a close request FAILED and, when that is terminal, tell the board (#2992).
464
+
465
+ The single writer of ``status=FAILED``. It exists because the *terminal*
466
+ failure — the one a user has to be told about, because the sprint stays
467
+ ACTIVE forever — is reached from four independent places, and a broadcast
468
+ wired into only the obvious one would miss three:
469
+
470
+ 1. ``close_sprint``'s exception handler once the attempt budget is spent,
471
+ 2. ``_finalize_non_closable_sprint`` when the sprint was cancelled,
472
+ 3. the same function when the sprint is in some other non-closable state,
473
+ 4. ``_do_drain``'s orphan sweep, for a close that never raised at all
474
+ because its worker died mid-attempt on every try.
475
+
476
+ ``next_attempt_at`` is the terminality test, not ``status``: a FAILED row
477
+ with a live retry clock is one the drain will pick up again in a minute, and
478
+ announcing *that* as a failure would alarm a user about something that
479
+ self-heals. This is the same rule ``SprintCloseRequestStateSerializer.
480
+ get_terminal`` names on the read side, kept in one place on the write side so
481
+ the two cannot drift.
482
+
483
+ The broadcast is deferred with ``transaction.on_commit`` inside an explicit
484
+ ``atomic`` block because the four call sites do **not** share an ambient
485
+ transaction: two run inside ``close_sprint``'s ``with transaction.atomic()``
486
+ and two (the exception handler, which sits outside the block that just rolled
487
+ back, and the drain) run in worker autocommit. Registering the callback
488
+ against a transaction this function opens itself makes the ordering — row
489
+ durable, then announce — identical at all four, instead of correct at two by
490
+ construction and at the other two by accident.
491
+
492
+ The payload deliberately carries no ``error_message``. That field is
493
+ role-gated on ``GET /sprints/{id}/close-request/`` — raw exception text is
494
+ Admin-only and is withheld from agent tokens entirely, because a database or
495
+ broker failure puts internal hostnames and SQL fragments in it. A board
496
+ broadcast fans out to the whole project group with no per-recipient
497
+ filtering available, so putting the text here would silently reverse that
498
+ gate. Clients learn the failure happened from the event and read the text
499
+ they are entitled to from the endpoint. Everything in the payload below is
500
+ already unconditional on that endpoint for every reader who can receive the
501
+ event.
502
+
503
+ Args:
504
+ req_pk: SprintCloseRequest primary key.
505
+ sprint_id: The sprint that failed to close.
506
+ project_id: Board group to fan out on — a ``Project`` pk, which is what
507
+ makes this event deliverable (a program pk would not be).
508
+ error_message: Stored failure text; never broadcast.
509
+ failure_reason: A ``SprintCloseRequestFailureReason`` member.
510
+ attempt_count: Attempts made including the one that just failed.
511
+ next_attempt_at: When the drain may retry, or None for terminal.
512
+ expected_status: Only write if the row is still in this status. Used by
513
+ the drain, whose select-then-write split would otherwise clobber a
514
+ row that moved on in between.
515
+
516
+ The write is a check-and-set and the announcement is conditional on it. Two
517
+ races make a blind ``filter(pk=...).update()`` wrong here, and both end in
518
+ the same user-visible lie — "your sprint didn't close" about one that did:
519
+
520
+ * Django runs non-robust ``on_commit`` hooks from ``Atomic.__exit__``,
521
+ *after* the commit. ``close_sprint``'s success path registers three, and
522
+ one of them dispatches webhooks and can raise — which lands in the very
523
+ ``except`` block that calls this function, with the COMPLETED row already
524
+ durable. Excluding COMPLETED stops a committed close being rewritten as a
525
+ failure by its own post-commit fallout.
526
+ * The drain's orphan sweep selects candidates and then writes them one at a
527
+ time, so the guard that used to live in a single ``UPDATE … WHERE`` no
528
+ longer covers the write. ``expected_status`` puts it back.
529
+
530
+ Returning early when the update matched nothing keeps the broadcast honest:
531
+ an event is sent only for a row this call actually made terminal.
532
+ """
533
+ from trueppm_api.apps.projects.models import SprintCloseRequest, SprintCloseRequestStatus
534
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
535
+
536
+ with transaction.atomic():
537
+ rows = SprintCloseRequest.objects.filter(pk=req_pk)
538
+ if expected_status is not None:
539
+ rows = rows.filter(status=expected_status)
540
+ updated = rows.exclude(status=SprintCloseRequestStatus.COMPLETED).update(
541
+ status=SprintCloseRequestStatus.FAILED,
542
+ completed_at=timezone.now(),
543
+ error_message=error_message,
544
+ failure_reason=failure_reason,
545
+ next_attempt_at=next_attempt_at,
546
+ )
547
+ if next_attempt_at is not None or not updated:
548
+ return
549
+
550
+ # Bound to locals rather than read off the closure: the callback runs
551
+ # after this frame is gone, and the drain calls this in a loop.
552
+ sprint_id_str = str(sprint_id)
553
+ request_id_str = str(req_pk)
554
+ project_id_str = str(project_id)
555
+ reason = str(failure_reason)
556
+ attempts = int(attempt_count)
557
+ transaction.on_commit(
558
+ lambda: broadcast_board_event(
559
+ project_id_str,
560
+ "sprint_close_failed",
561
+ {
562
+ "id": sprint_id_str,
563
+ "request_id": request_id_str,
564
+ "failure_reason": reason,
565
+ "attempt_count": attempts,
566
+ # Constant by construction — this event is only ever emitted
567
+ # for a request that will not be retried. Stated on the wire
568
+ # so a consumer that never calls the read route still knows
569
+ # it is not looking at a transient blip.
570
+ "terminal": True,
571
+ },
572
+ )
573
+ )
574
+
575
+
576
+ def _finalize_non_closable_sprint(req: Any, sprint: Any) -> bool:
577
+ """Finalize the close request if the locked sprint can't be closed; return handled.
578
+
579
+ Returns True (and updates the ``SprintCloseRequest``) when the sprint is already
580
+ COMPLETED (mark the request COMPLETED — an earlier dispatch closed it), CANCELLED,
581
+ or otherwise not ACTIVE (mark the request FAILED). Returns False when the sprint is
582
+ ACTIVE and the close should proceed.
583
+ """
584
+ from trueppm_api.apps.projects.models import (
585
+ SprintCloseRequest,
586
+ SprintCloseRequestFailureReason,
587
+ SprintCloseRequestStatus,
588
+ SprintState,
589
+ )
590
+
591
+ if sprint.state == SprintState.COMPLETED:
592
+ # Already closed by an earlier dispatch; mark request done.
593
+ SprintCloseRequest.objects.filter(pk=req.pk).update(
594
+ status=SprintCloseRequestStatus.COMPLETED,
595
+ completed_at=timezone.now(),
596
+ next_attempt_at=None,
597
+ )
598
+ return True
599
+
600
+ if sprint.state == SprintState.CANCELLED:
601
+ _finalize_close_failure(
602
+ req_pk=req.pk,
603
+ sprint_id=sprint.pk,
604
+ project_id=sprint.project_id,
605
+ error_message="Sprint was cancelled before close could complete.",
606
+ failure_reason=SprintCloseRequestFailureReason.CANCELLED,
607
+ # ``req`` was read before the increment this attempt already applied.
608
+ attempt_count=req.attempt_count + 1,
609
+ # Terminal by nature: no retry can un-cancel a sprint. Leaving the
610
+ # clock null is what keeps the drain's retry sweep off this row, and
611
+ # is what makes the helper announce it.
612
+ next_attempt_at=None,
613
+ )
614
+ return True
615
+
616
+ if sprint.state != SprintState.ACTIVE:
617
+ _finalize_close_failure(
618
+ req_pk=req.pk,
619
+ sprint_id=sprint.pk,
620
+ project_id=sprint.project_id,
621
+ error_message=f"Sprint state {sprint.state} is not closable.",
622
+ failure_reason=SprintCloseRequestFailureReason.NOT_CLOSABLE,
623
+ attempt_count=req.attempt_count + 1,
624
+ next_attempt_at=None,
625
+ )
626
+ return True
627
+
628
+ return False
629
+
630
+
631
+ def _clear_sprint_ranks(sprint: Any) -> None:
632
+ """Clear the within-sprint execution order on close (#365, ADR-0105 §5).
633
+
634
+ A task returns to the product backlog ordered by ``priority_rank``; its
635
+ closed-sprint ``sprint_rank`` stays queryable on the HistoricalTask rows (the
636
+ pre-clear value on the create-time row, the null on the row the save() below
637
+ writes). Runs BEFORE apply_carry_over so a carried task re-enters its next sprint
638
+ un-ranked (re-seeded from priority_rank on that sprint's activate), never
639
+ inheriting a stale rank. save() (not bulk_update) so server_version bumps +
640
+ history is written.
641
+ """
642
+ from trueppm_api.apps.projects.models import Task
643
+
644
+ for task in Task.objects.filter(sprint_id=sprint.pk, is_deleted=False):
645
+ if task.sprint_rank is not None:
646
+ task.sprint_rank = None
647
+ task.save(update_fields=["sprint_rank", "server_version"])
648
+
649
+
650
+ def _recompute_and_reforecast_milestone(sprint: Any, req: Any) -> None:
651
+ """Recompute the bound milestone rollup and reforecast its finish (ADR-0074/0106).
652
+
653
+ ADR-0074: recompute the milestone rollup with the final ``completed_*`` snapshot.
654
+ Runs inside the drain transaction, after carry-over, so the milestone reflects the
655
+ closed sprint's final contribution before the sprint_closed broadcast goes out.
656
+
657
+ ADR-0106 §3 (#860) — the bridge WOW: reforecast the bound milestone's finish as a
658
+ range from the just-closed sprint's velocity. The snapshot write is synchronous
659
+ (durable with the close); the milestone_forecast_updated broadcast + Enterprise-seam
660
+ signal defer to on_commit inside the service. Wrapped non-blocking: a reforecast bug
661
+ must never strand or revert a sprint close (ADR-0106 §Durable 8).
662
+ """
663
+ if sprint.target_milestone_id is None:
664
+ return
665
+
666
+ from trueppm_api.apps.projects.services import recompute_milestone_rollup
667
+
668
+ recompute_milestone_rollup(sprint.target_milestone_id)
669
+
670
+ try:
671
+ from trueppm_api.apps.projects.services import (
672
+ notify_milestone_forecast_shift,
673
+ reforecast_bound_milestone,
674
+ )
675
+
676
+ forecast = reforecast_bound_milestone(sprint.target_milestone_id)
677
+ # #861 — push the PM cohort the milestone-confidence shift so the bridge
678
+ # reforecast isn't silent when the team closes the sprint outside the PM's
679
+ # session. Non-blocking with the reforecast itself: a digest failure must
680
+ # never strand close.
681
+ if forecast is not None:
682
+ notify_milestone_forecast_shift(forecast, sprint, actor_id=req.requested_by_id)
683
+ except Exception:
684
+ logger.exception(
685
+ "close_sprint: reforecast failed for milestone %s — continuing close",
686
+ sprint.target_milestone_id,
687
+ )
688
+
689
+
690
+ def _compute_velocity_suggestions_safe(sprint: Any) -> None:
691
+ """Compute velocity-calibration suggestions (ADR-0065), non-blocking on failure.
692
+
693
+ Any error logs and is swallowed so a calibration bug cannot strand a sprint close.
694
+ """
695
+ try:
696
+ from trueppm_api.apps.scheduling.services import compute_velocity_suggestions
697
+
698
+ compute_velocity_suggestions(sprint.pk)
699
+ except Exception:
700
+ logger.exception(
701
+ "close_sprint: velocity calibration failed for sprint %s — continuing close",
702
+ sprint.pk,
703
+ )
704
+
705
+
706
+ # ---------------------------------------------------------------------------
707
+ # Drain — every 30 seconds via Beat
708
+ # ---------------------------------------------------------------------------
709
+
710
+
711
+ @idempotent_task(
712
+ lock_key_template="drain_sprint_close_requests",
713
+ lock_ttl=60,
714
+ on_contention="skip",
715
+ soft_time_limit=25,
716
+ time_limit=30,
717
+ acks_late=True,
718
+ reject_on_worker_lost=True,
719
+ name="projects.drain_sprint_close_requests",
720
+ )
721
+ def drain_sprint_close_requests(self: object) -> None:
722
+ """Dispatch any pending or stranded SprintCloseRequest rows.
723
+
724
+ Beat: every 30 s. Like the scheduling drain, this also recovers IN_FLIGHT
725
+ rows that have aged past the 5-minute orphan window (worker died mid-close).
726
+ """
727
+ _do_drain()
728
+
729
+
730
+ @idempotent_task(
731
+ lock_key_template="update_sprint_burndown_snapshots",
732
+ lock_ttl=300,
733
+ on_contention="skip",
734
+ soft_time_limit=120,
735
+ time_limit=180,
736
+ acks_late=True,
737
+ reject_on_worker_lost=True,
738
+ name="projects.update_sprint_burndown_snapshots",
739
+ )
740
+ def update_sprint_burndown_snapshots(self: object) -> None:
741
+ """Write yesterday's burndown row for every ACTIVE sprint.
742
+
743
+ Beat: 01:00 UTC daily. Real-time UPSERTs from the task_status_changed
744
+ signal cover today's row; this Beat task fills in days where no status
745
+ changed (and therefore no signal fired).
746
+ """
747
+ from trueppm_api.apps.projects.models import Sprint, SprintState
748
+ from trueppm_api.apps.projects.services import upsert_burndown_for_sprint
749
+
750
+ yesterday = timezone.localdate() - timedelta(days=1)
751
+ for sprint in Sprint.objects.filter(state=SprintState.ACTIVE, is_deleted=False):
752
+ try:
753
+ upsert_burndown_for_sprint(sprint, snapshot_date=yesterday)
754
+ except Exception:
755
+ logger.exception("update_sprint_burndown_snapshots: failed for sprint %s", sprint.pk)
756
+
757
+
758
+ @idempotent_task(
759
+ lock_key_template="generate_recurring_occurrences",
760
+ lock_ttl=300,
761
+ on_contention="skip",
762
+ soft_time_limit=120,
763
+ time_limit=180,
764
+ acks_late=True,
765
+ reject_on_worker_lost=True,
766
+ name="projects.generate_recurring_occurrences",
767
+ )
768
+ def generate_recurring_occurrences(self: object) -> None:
769
+ """Lazily materialize upcoming occurrences for every active recurrence rule.
770
+
771
+ Beat: hourly. Generates only occurrences due within
772
+ ``settings.TRUEPPM_RECURRENCE_HORIZON_DAYS`` (a bounded look-ahead — never the
773
+ full series) that do not already exist, honoring each rule's end condition.
774
+ Idempotent via the (rule, date) unique constraint and the skip-on-contention
775
+ lock; per-rule try/except so one malformed rule cannot starve the sweep (ADR-0090).
776
+ """
777
+ from django.conf import settings as dj_settings
778
+
779
+ from trueppm_api.apps.projects.models import TaskRecurrenceRule
780
+ from trueppm_api.apps.projects.services import _generate_due_occurrences
781
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
782
+
783
+ horizon = getattr(dj_settings, "TRUEPPM_RECURRENCE_HORIZON_DAYS", 14)
784
+ total = 0
785
+ # Collect new occurrence ids per project so each project's open boards get a
786
+ # single bulk event rather than one per task.
787
+ created_by_project: dict[str, list[str]] = {}
788
+ rules = TaskRecurrenceRule.objects.filter(is_deleted=False).select_related(
789
+ "task", "task__assignee"
790
+ )
791
+ for rule in rules:
792
+ try:
793
+ created = _generate_due_occurrences(rule, horizon_days=horizon)
794
+ except Exception:
795
+ logger.exception("generate_recurring_occurrences: failed for rule %s", rule.pk)
796
+ continue
797
+ for task in created:
798
+ created_by_project.setdefault(str(task.project_id), []).append(str(task.id))
799
+ total += len(created)
800
+ if total:
801
+ logger.info("generate_recurring_occurrences: created %d occurrence(s)", total)
802
+
803
+ # Materialized occurrences are board-scoped tasks that show up on the project
804
+ # board; without a broadcast a connected client only sees them on its next manual
805
+ # refresh or delta pull (#1008). Emit one bulk event per project so open boards
806
+ # live-update. Deferred via on_commit to honor the broadcast contract (broadcast
807
+ # only after the rows are durably committed); this Beat task runs in autocommit,
808
+ # where the occurrences are already committed and the callback fires immediately.
809
+ for pid, task_ids in created_by_project.items():
810
+
811
+ def _broadcast(pid: str = pid, ids: list[str] = task_ids) -> None:
812
+ broadcast_board_event(pid, "tasks_bulk_mutated", {"task_ids": ids})
813
+
814
+ transaction.on_commit(_broadcast)
815
+
816
+
817
+ @idempotent_task(
818
+ lock_key_template="purge_sprint_close_requests",
819
+ lock_ttl=120,
820
+ on_contention="skip",
821
+ soft_time_limit=55,
822
+ time_limit=90,
823
+ acks_late=True,
824
+ reject_on_worker_lost=True,
825
+ name="projects.purge_sprint_close_requests",
826
+ )
827
+ def purge_sprint_close_requests(self: object) -> None:
828
+ """Delete COMPLETED / *terminally* FAILED SprintCloseRequest rows older than 7 days.
829
+
830
+ The retention window is measured from ``requested_at``, so a request that sat
831
+ PENDING for longer than the window — a drain or broker outage, which the purge
832
+ survives because it excludes PENDING — is already older than the cutoff the
833
+ moment it finally runs. Without the ``next_attempt_at`` guard below, a
834
+ transient failure on that first attempt would schedule a 60-second retry and
835
+ the nightly purge could delete the row before the retry ever fired: the sprint
836
+ stays ACTIVE *and* the record of why is gone, which is strictly worse than the
837
+ stranding #2894 exists to fix. A row with a live retry clock is not finished,
838
+ whatever its status says, so it is not eligible for retention purging.
839
+ """
840
+ from trueppm_api.apps.projects.models import (
841
+ SprintCloseRequest,
842
+ SprintCloseRequestStatus,
843
+ )
844
+
845
+ cutoff = timezone.now() - timedelta(days=7)
846
+ deleted, _ = (
847
+ SprintCloseRequest.objects.filter(
848
+ status__in=[
849
+ SprintCloseRequestStatus.COMPLETED,
850
+ SprintCloseRequestStatus.FAILED,
851
+ ],
852
+ requested_at__lt=cutoff,
853
+ )
854
+ .exclude(
855
+ status=SprintCloseRequestStatus.FAILED,
856
+ next_attempt_at__isnull=False,
857
+ )
858
+ .delete()
859
+ )
860
+ logger.info("purge_sprint_close_requests: deleted %d row(s)", deleted)
861
+
862
+
863
+ # ---------------------------------------------------------------------------
864
+ # Soft-deleted project retention purge (#1114, ADR-0173)
865
+ # ---------------------------------------------------------------------------
866
+
867
+ # Projects hard-deleted per transaction. Each project's DB-level CASCADE loads
868
+ # its whole child subtree (tasks, edges, sprints, risks, baselines, …) into the
869
+ # collector, so — unlike the flat single-table purges (webhook/taskrun/sync) — the
870
+ # delete is bounded into batches to cap collector memory and lock-hold time. A
871
+ # soft-deleted project is already invisible to every reader (#1112), so spreading
872
+ # the hard-delete across several transactions has no user-visible effect.
873
+ _PROJECT_PURGE_BATCH_SIZE = 50
874
+
875
+
876
+ def _do_project_purge(*, dry_run: bool = False, override_value: int | None = None) -> int:
877
+ """Business logic for purge_soft_deleted_projects — extracted for testability.
878
+
879
+ Hard-deletes (row + DB CASCADE children) projects whose soft-delete age exceeds
880
+ the window resolved by ``resolve_retention`` (operator override → the
881
+ ``TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS`` default, ADR-0173); ``None``
882
+ disables the purge (unbounded retention). Returns the number of ``Project`` rows
883
+ deleted, or — when ``dry_run`` — the number eligible. ``override_value`` forces a
884
+ hypothetical window (used by the System Health impact estimate).
885
+
886
+ Eligibility is ``is_deleted=True AND deleted_at <= now - window``. A NULL
887
+ ``deleted_at`` is deliberately **excluded**: a project soft-deleted before the
888
+ ``deleted_at`` column existed has an unknown age and must never be silently
889
+ hard-deleted — the safe default is to retain it (an operator can still remove it
890
+ via ``?force=true``).
891
+
892
+ Several FKs to Project are ``on_delete=PROTECT`` and would block a bare
893
+ ``Project.delete()``, so the delete is delegated to ``hard_delete_projects``,
894
+ which resolves those children reflectively from ``_meta`` and purges them first —
895
+ the same helper the ``?force=true`` hard-delete path uses. This used to be a
896
+ hand-written ``ProjectMembership`` pre-delete asserting it was the *only* PROTECT
897
+ FK; that enumeration went stale when mention groups landed and a single project
898
+ that had ever carried one aborted the whole batch (#2372).
899
+
900
+ HistoricalProject rows are intentionally not touched here; they age out via the
901
+ separate history retention purge.
902
+ """
903
+ from trueppm_api.apps.access.services import hard_delete_projects
904
+ from trueppm_api.apps.observability.retention import resolve_retention
905
+ from trueppm_api.apps.projects.models import Project
906
+
907
+ retention_days = (
908
+ override_value
909
+ if override_value is not None
910
+ else resolve_retention("TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS")
911
+ )
912
+ if retention_days is None:
913
+ return 0
914
+
915
+ cutoff = timezone.now() - timedelta(days=retention_days)
916
+ eligible = Project.objects.filter(
917
+ is_deleted=True,
918
+ deleted_at__isnull=False,
919
+ deleted_at__lte=cutoff,
920
+ )
921
+ if dry_run:
922
+ return eligible.count()
923
+
924
+ project_ids = list(eligible.values_list("pk", flat=True))
925
+ deleted_projects = 0
926
+ for start in range(0, len(project_ids), _PROJECT_PURGE_BATCH_SIZE):
927
+ batch = project_ids[start : start + _PROJECT_PURGE_BATCH_SIZE]
928
+ # hard_delete_projects is itself @transaction.atomic, so each batch remains a
929
+ # single transaction — the batching still caps collector memory and lock-hold
930
+ # time without one failure rolling back previously purged batches.
931
+ deleted_projects += hard_delete_projects(batch)
932
+ if deleted_projects:
933
+ logger.info(
934
+ "purge_soft_deleted_projects: hard-deleted %d soft-deleted project(s)",
935
+ deleted_projects,
936
+ )
937
+ return deleted_projects
938
+
939
+
940
+ @idempotent_task(
941
+ lock_key_template="purge_soft_deleted_projects",
942
+ lock_ttl=600,
943
+ on_contention="skip",
944
+ soft_time_limit=540,
945
+ time_limit=600,
946
+ acks_late=True,
947
+ reject_on_worker_lost=True,
948
+ name="projects.purge_soft_deleted_projects",
949
+ )
950
+ def purge_soft_deleted_projects(self: object) -> None:
951
+ """Hard-delete soft-deleted projects past the retention window.
952
+
953
+ Dispatchable directly, but not on its own Beat schedule — the consolidated
954
+ retention coordinator (``retention.run_purge``) owns scheduled purging and picks
955
+ this up via the ``purge_registry`` binding (ADR-0173 §C), running it alongside
956
+ the other retention purges in one unified ``PurgeRun``.
957
+ """
958
+ _do_project_purge()
959
+
960
+
961
+ # ---------------------------------------------------------------------------
962
+ # Drain implementation — extracted for testability
963
+ # ---------------------------------------------------------------------------
964
+
965
+
966
+ _ORPHAN_WINDOW = timedelta(minutes=5)
967
+
968
+
969
+ def _do_drain() -> None:
970
+ from trueppm_api.apps.projects.models import (
971
+ SprintCloseRequest,
972
+ SprintCloseRequestFailureReason,
973
+ SprintCloseRequestStatus,
974
+ )
975
+
976
+ now = timezone.now()
977
+ orphan_cutoff = now - _ORPHAN_WINDOW
978
+
979
+ # Recover IN_FLIGHT rows that have stalled past the orphan window — the
980
+ # task_id may not match anything live, so we reset and let the next
981
+ # dispatch attempt acquire the per-request lock fresh.
982
+ #
983
+ # Bounded by the same budget as the failure path (#2894). ``close_sprint``
984
+ # increments ``attempt_count`` on every entry, so a close that orphans rather
985
+ # than raising — a worker OOM-killed on each attempt — was recovered here
986
+ # without limit while the counter climbed forever. A constant named as *the*
987
+ # close attempt budget that only one of the two recovery paths honors is a
988
+ # guard narrower than the class it names, which is how this bug survived in
989
+ # the failure path to begin with.
990
+ recovered = SprintCloseRequest.objects.filter(
991
+ status=SprintCloseRequestStatus.IN_FLIGHT,
992
+ started_at__lt=orphan_cutoff,
993
+ attempt_count__lt=_MAX_CLOSE_ATTEMPTS,
994
+ ).update(status=SprintCloseRequestStatus.PENDING)
995
+
996
+ # An orphan whose budget is spent must be *abandoned*, not left IN_FLIGHT.
997
+ # Capping the recovery above without this would trade an unbounded loop for a
998
+ # row wedged in a non-terminal state that no sweep touches and no reader can
999
+ # interpret — the same silent stranding in a different status.
1000
+ #
1001
+ # Materialised row-by-row rather than left as one bulk .update() (#2992):
1002
+ # this is a terminal failure like any other, so it has to announce itself on
1003
+ # the sprint's board — and a bulk update returns a count, which cannot say
1004
+ # *which* projects to fan out to. Sliced for the same reason every other
1005
+ # statement in this sweep is: one UPDATE that costs the same whether it hits
1006
+ # one row or ten thousand becomes N BoardEvent inserts and N group_sends
1007
+ # once it broadcasts, inside a task with a soft time limit. A correlated
1008
+ # outage (workers OOM-killed across many projects) is exactly when this path
1009
+ # fires in bulk, so leaving it unbounded would turn a bad minute into a
1010
+ # worse one. The remainder is picked up on the next 30 s sweep.
1011
+ stalled = list(
1012
+ SprintCloseRequest.objects.filter(
1013
+ status=SprintCloseRequestStatus.IN_FLIGHT,
1014
+ started_at__lt=orphan_cutoff,
1015
+ attempt_count__gte=_MAX_CLOSE_ATTEMPTS,
1016
+ ).values("pk", "sprint_id", "sprint__project_id", "attempt_count")[:_ABANDON_BUDGET]
1017
+ )
1018
+ abandoned = 0
1019
+ for row in stalled:
1020
+ # Each row belongs to a different project, so this is one event per board
1021
+ # rather than a loop fanning the same event out N times. Isolated per row:
1022
+ # a channel-layer or BoardEvent fault on one project must not abort the
1023
+ # sweep before it dispatches the fresh, healthy closes at the bottom of
1024
+ # this function — the bulk UPDATE this replaced could not fail that way.
1025
+ try:
1026
+ _finalize_close_failure(
1027
+ req_pk=row["pk"],
1028
+ sprint_id=row["sprint_id"],
1029
+ project_id=row["sprint__project_id"],
1030
+ error_message=(
1031
+ "The close stopped responding on every attempt and was abandoned. "
1032
+ "The sprint is still open."
1033
+ ),
1034
+ failure_reason=SprintCloseRequestFailureReason.STALLED,
1035
+ attempt_count=row["attempt_count"],
1036
+ # Restores the predicate the single bulk UPDATE used to carry in
1037
+ # its WHERE clause. Without it the select-then-write split can
1038
+ # clobber a row that finished in between and announce a failure
1039
+ # for a sprint that closed.
1040
+ expected_status=SprintCloseRequestStatus.IN_FLIGHT,
1041
+ )
1042
+ except Exception:
1043
+ logger.exception(
1044
+ "drain_sprint_close_requests: abandoning stalled request %s failed", row["pk"]
1045
+ )
1046
+ continue
1047
+ abandoned += 1
1048
+ if abandoned:
1049
+ logger.error(
1050
+ "drain_sprint_close_requests: abandoned %d close request(s) stalled past "
1051
+ "the attempt budget",
1052
+ abandoned,
1053
+ )
1054
+ if recovered:
1055
+ logger.warning(
1056
+ "drain_sprint_close_requests: recovered %d orphaned IN_FLIGHT row(s)",
1057
+ recovered,
1058
+ )
1059
+
1060
+ # Re-queue FAILED rows whose retry clock has come round (#2894). Before this
1061
+ # the drain recovered IN_FLIGHT only, so a single unsnapshottable task left
1062
+ # the sprint ACTIVE indefinitely behind a terminally-FAILED request, with no
1063
+ # path back except manual DB intervention.
1064
+ #
1065
+ # `next_attempt_at__isnull=False` is the whole guard: a failure nothing can
1066
+ # fix (sprint cancelled, sprint not closable) and an exhausted budget both
1067
+ # leave it null, so neither is picked up here. `completed_at` is cleared so
1068
+ # the row does not read as finished while it waits to run again.
1069
+ # Capped like the dispatch slices below: a correlated failure could otherwise
1070
+ # flip an unbounded number of rows, and hold their locks, in one statement
1071
+ # inside a task that has a soft time limit.
1072
+ retry_ids = SprintCloseRequest.objects.filter(
1073
+ status=SprintCloseRequestStatus.FAILED,
1074
+ next_attempt_at__isnull=False,
1075
+ next_attempt_at__lte=now,
1076
+ attempt_count__lt=_MAX_CLOSE_ATTEMPTS,
1077
+ ).values("pk")[:_RETRY_REQUEUE_BUDGET]
1078
+ requeued = SprintCloseRequest.objects.filter(pk__in=retry_ids).update(
1079
+ status=SprintCloseRequestStatus.PENDING,
1080
+ next_attempt_at=None,
1081
+ completed_at=None,
1082
+ # Cleared alongside completed_at: a row waiting to run again has not
1083
+ # started, and a stale started_at would both misread on the API and feed
1084
+ # the orphan sweep above a start time from a previous attempt.
1085
+ started_at=None,
1086
+ )
1087
+ if requeued:
1088
+ logger.warning(
1089
+ "drain_sprint_close_requests: re-queued %d failed close request(s) for retry",
1090
+ requeued,
1091
+ )
1092
+
1093
+ pending = list(
1094
+ SprintCloseRequest.objects.filter(
1095
+ status=SprintCloseRequestStatus.PENDING,
1096
+ requested_at__lt=now - timedelta(seconds=2),
1097
+ attempt_count=0,
1098
+ ).order_by("requested_at")[:_DISPATCH_BUDGET]
1099
+ )
1100
+ # Retries draw from their own budget rather than competing for the slice
1101
+ # above (#2894). A re-queued row keeps its original ``requested_at`` — that
1102
+ # field answers "when did the user ask", and the read route shows it — so it
1103
+ # sorts ahead of every newer close. Before the retry sweep existed nothing
1104
+ # old could re-enter the PENDING pool, so a single ordered slice was safe;
1105
+ # now a correlated failure would let the retry cohort fill the whole budget
1106
+ # and strand fresh user closes behind it. Two budgets, neither starving.
1107
+ pending += list(
1108
+ SprintCloseRequest.objects.filter(
1109
+ status=SprintCloseRequestStatus.PENDING,
1110
+ requested_at__lt=now - timedelta(seconds=2),
1111
+ attempt_count__gt=0,
1112
+ ).order_by("requested_at")[:_RETRY_DISPATCH_BUDGET]
1113
+ )
1114
+ dispatched = 0
1115
+ for req in pending:
1116
+ try:
1117
+ close_sprint.delay(str(req.id))
1118
+ except Exception:
1119
+ logger.warning(
1120
+ "drain_sprint_close_requests: broker unavailable — request %s stays pending",
1121
+ req.id,
1122
+ )
1123
+ continue
1124
+ dispatched += 1
1125
+ if dispatched or recovered or requeued:
1126
+ logger.info(
1127
+ "drain_sprint_close_requests: dispatched=%d recovered=%d requeued=%d",
1128
+ dispatched,
1129
+ recovered,
1130
+ requeued,
1131
+ )
1132
+
1133
+
1134
+ # ---------------------------------------------------------------------------
1135
+ # cascade_project_soft_delete — offloaded child tombstone cascade (#1112)
1136
+ # ---------------------------------------------------------------------------
1137
+
1138
+
1139
+ @idempotent_task(
1140
+ lock_key_template="project_cascade_soft_delete:{0}",
1141
+ lock_ttl=600,
1142
+ on_contention="skip",
1143
+ max_retries=3,
1144
+ retry_backoff=10,
1145
+ retry_backoff_max=120,
1146
+ retry_jitter=True,
1147
+ soft_time_limit=540,
1148
+ time_limit=600,
1149
+ acks_late=True,
1150
+ reject_on_worker_lost=True,
1151
+ name="projects.cascade_project_soft_delete",
1152
+ )
1153
+ def cascade_project_soft_delete(self: object, project_id: str) -> None:
1154
+ """Tombstone a soft-deleted project's children off the request path (#1112).
1155
+
1156
+ ``ProjectViewSet.perform_destroy`` tombstones the project row synchronously
1157
+ (instant, so the project reads as gone) and enqueues this task to drain the
1158
+ potentially huge child cascade — tasks, dependency edges, sprints, risks, and
1159
+ baselines — that #1111 previously ran inline inside the request transaction.
1160
+
1161
+ Idempotency: the cascade only touches rows still ``is_deleted=False``, so a
1162
+ broker-retry, a duplicate dispatch, or the ``on_contention="skip"`` lock all
1163
+ resolve to a safe no-op. A vanished project (hard-deleted in the meantime, so
1164
+ its children are gone via DB CASCADE) is simply skipped.
1165
+
1166
+ No broadcast here: the ``project_deleted`` board event already fired from
1167
+ ``perform_destroy`` on commit, and child tombstones reach mobile clients via
1168
+ the sync delta pull (the ``sync_seq`` cursor this cascade stamps, ADR-0686),
1169
+ not over WebSocket — so there is nothing to re-broadcast and no double-fire
1170
+ risk.
1171
+ """
1172
+ from trueppm_api.apps.projects.models import (
1173
+ Project,
1174
+ cascade_project_children_soft_delete,
1175
+ )
1176
+
1177
+ if not Project.objects.filter(pk=project_id).exists():
1178
+ logger.info(
1179
+ "cascade_project_soft_delete: project %s gone (hard-deleted?) — skipping",
1180
+ project_id,
1181
+ )
1182
+ return
1183
+
1184
+ with transaction.atomic():
1185
+ cascade_project_children_soft_delete(project_id)
1186
+ logger.info("cascade_project_soft_delete: cascaded children for project %s", project_id)
1187
+
1188
+
1189
+ # ---------------------------------------------------------------------------
1190
+ # Async project export bundle (ADR-0219, #1266) — mirrors workspace export (ADR-0174)
1191
+ # ---------------------------------------------------------------------------
1192
+
1193
+
1194
+ @shared_task( # type: ignore[untyped-decorator]
1195
+ bind=True,
1196
+ max_retries=EXPORT_MAX_RETRIES,
1197
+ soft_time_limit=600,
1198
+ time_limit=660,
1199
+ acks_late=True,
1200
+ reject_on_worker_lost=True,
1201
+ name="projects.run_project_export",
1202
+ )
1203
+ def run_project_export(self: object, job_id: str) -> None:
1204
+ """Build the project export bundle for ``job_id`` (ADR-0219).
1205
+
1206
+ Idempotent: claims the job under ``select_for_update`` and no-ops unless it is
1207
+ ``pending``/``running``, so a duplicate delivery (broker retry, drain re-dispatch)
1208
+ cannot produce two archives. Transient failures retry up to ``EXPORT_MAX_RETRIES``
1209
+ (the job stays ``running`` so a retry is allowed); on exhaustion the job is marked
1210
+ ``failed`` and the Admin can request a fresh export.
1211
+ """
1212
+ from trueppm_api.apps.projects.export_bundle import build_and_store_project_archive
1213
+ from trueppm_api.apps.projects.models import ExportJobStatus, ProjectExportJob
1214
+
1215
+ with transaction.atomic():
1216
+ job = ProjectExportJob.objects.select_for_update().filter(pk=job_id).first()
1217
+ if job is None:
1218
+ logger.warning("run_project_export: job %s not found", job_id)
1219
+ return
1220
+ if job.status not in (ExportJobStatus.PENDING, ExportJobStatus.RUNNING):
1221
+ logger.info("run_project_export: job %s already %s, skipping", job_id, job.status)
1222
+ return
1223
+ job.status = ExportJobStatus.RUNNING
1224
+ job.started_at = timezone.now()
1225
+ job.celery_task_id = getattr(getattr(self, "request", None), "id", "") or ""
1226
+ job.save(update_fields=["status", "started_at", "celery_task_id"])
1227
+
1228
+ try:
1229
+ storage_path, size = build_and_store_project_archive(job_id)
1230
+ except Exception as exc:
1231
+ retries = getattr(getattr(self, "request", None), "retries", 0)
1232
+ if retries < EXPORT_MAX_RETRIES:
1233
+ logger.warning("run_project_export: job %s failed, retrying", job_id, exc_info=True)
1234
+ raise self.retry(exc=exc, countdown=10 * (2**retries)) from exc # type: ignore[attr-defined]
1235
+ logger.exception("run_project_export: job %s failed permanently", job_id)
1236
+ ProjectExportJob.objects.filter(pk=job_id).update(
1237
+ status=ExportJobStatus.FAILED,
1238
+ error_detail=str(exc)[:2000],
1239
+ completed_at=timezone.now(),
1240
+ )
1241
+ return
1242
+
1243
+ retention = _export_retention_days()
1244
+ expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
1245
+ ProjectExportJob.objects.filter(pk=job_id).update(
1246
+ status=ExportJobStatus.SUCCESS,
1247
+ file_path=storage_path,
1248
+ file_size=size,
1249
+ expires_at=expires_at,
1250
+ completed_at=timezone.now(),
1251
+ error_detail="",
1252
+ )
1253
+
1254
+
1255
+ @idempotent_task(
1256
+ lock_key_template="drain_project_exports",
1257
+ lock_ttl=60,
1258
+ on_contention="skip",
1259
+ soft_time_limit=25,
1260
+ time_limit=30,
1261
+ acks_late=True,
1262
+ reject_on_worker_lost=True,
1263
+ name="projects.drain_project_exports",
1264
+ )
1265
+ def drain_project_exports(self: object) -> None:
1266
+ """Re-dispatch project export jobs stuck in ``pending`` (broker down at on_commit)."""
1267
+ _do_drain_project_exports()
1268
+
1269
+
1270
+ @idempotent_task(
1271
+ lock_key_template="purge_expired_project_exports",
1272
+ lock_ttl=120,
1273
+ on_contention="skip",
1274
+ soft_time_limit=55,
1275
+ time_limit=90,
1276
+ acks_late=True,
1277
+ reject_on_worker_lost=True,
1278
+ name="projects.purge_expired_project_exports",
1279
+ )
1280
+ def purge_expired_project_exports(self: object) -> None:
1281
+ """Delete project export jobs past their link expiry, and their stored archives."""
1282
+ _do_purge_expired_project_exports()
1283
+
1284
+
1285
+ def _export_retention_days() -> int | None:
1286
+ from django.conf import settings
1287
+
1288
+ return getattr(settings, "TRUEPPM_EXPORT_RETENTION_DAYS", DEFAULT_EXPORT_RETENTION_DAYS)
1289
+
1290
+
1291
+ def _do_drain_project_exports() -> None:
1292
+ from trueppm_api.apps.projects.models import ExportJobStatus, ProjectExportJob
1293
+
1294
+ orphan_cutoff = timezone.now() - timedelta(minutes=EXPORT_ORPHAN_WINDOW_MINUTES)
1295
+ stuck = list(
1296
+ ProjectExportJob.objects.filter(
1297
+ status=ExportJobStatus.PENDING,
1298
+ celery_task_id="",
1299
+ created_at__lt=orphan_cutoff,
1300
+ ).order_by("created_at")[:EXPORT_DRAIN_BATCH_SIZE]
1301
+ )
1302
+ if not stuck:
1303
+ return
1304
+ for job in stuck:
1305
+ try:
1306
+ run_project_export.delay(str(job.id))
1307
+ except Exception: # pragma: no cover - broker still down, next tick retries
1308
+ logger.warning("drain_project_exports: broker still unavailable for %s", job.id)
1309
+ break
1310
+ logger.info("drain_project_exports: re-dispatched %d job(s)", len(stuck))
1311
+
1312
+
1313
+ def _do_purge_expired_project_exports() -> None:
1314
+ from django.core.files.storage import default_storage
1315
+
1316
+ from trueppm_api.apps.projects.models import ProjectExportJob
1317
+
1318
+ retention = _export_retention_days()
1319
+ if retention is None: # retention disabled — keep archives indefinitely
1320
+ return
1321
+ expired = ProjectExportJob.objects.filter(expires_at__lt=timezone.now())
1322
+ count = 0
1323
+ for job in expired.iterator():
1324
+ if job.file_path:
1325
+ try:
1326
+ default_storage.delete(job.file_path)
1327
+ except OSError: # pragma: no cover - storage drift, still drop the row
1328
+ logger.warning(
1329
+ "purge_expired_project_exports: could not delete file for %s", job.id
1330
+ )
1331
+ job.delete()
1332
+ count += 1
1333
+ if count:
1334
+ logger.info("purge_expired_project_exports: deleted %d expired export(s)", count)
1335
+
1336
+
1337
+ # ---------------------------------------------------------------------------
1338
+ # Async program export bundle (ADR-0219, #1958) — the program-grain sibling of
1339
+ # the project export tasks above; identical durable-execution shape.
1340
+ # ---------------------------------------------------------------------------
1341
+
1342
+
1343
+ @shared_task( # type: ignore[untyped-decorator]
1344
+ bind=True,
1345
+ max_retries=EXPORT_MAX_RETRIES,
1346
+ soft_time_limit=600,
1347
+ time_limit=660,
1348
+ acks_late=True,
1349
+ reject_on_worker_lost=True,
1350
+ name="projects.run_program_export",
1351
+ )
1352
+ def run_program_export(self: object, job_id: str) -> None:
1353
+ """Build the program export bundle for ``job_id`` (ADR-0219, #1958).
1354
+
1355
+ Idempotent: claims the job under ``select_for_update`` and no-ops unless it is
1356
+ ``pending``/``running``, so a duplicate delivery (broker retry, drain re-dispatch)
1357
+ cannot produce two archives. Transient failures retry up to ``EXPORT_MAX_RETRIES``;
1358
+ on exhaustion the job is marked ``failed`` and the Admin can request a fresh export.
1359
+ """
1360
+ from trueppm_api.apps.projects.export_bundle import build_and_store_program_archive
1361
+ from trueppm_api.apps.projects.models import ExportJobStatus, ProgramExportJob
1362
+
1363
+ with transaction.atomic():
1364
+ job = ProgramExportJob.objects.select_for_update().filter(pk=job_id).first()
1365
+ if job is None:
1366
+ logger.warning("run_program_export: job %s not found", job_id)
1367
+ return
1368
+ if job.status not in (ExportJobStatus.PENDING, ExportJobStatus.RUNNING):
1369
+ logger.info("run_program_export: job %s already %s, skipping", job_id, job.status)
1370
+ return
1371
+ job.status = ExportJobStatus.RUNNING
1372
+ job.started_at = timezone.now()
1373
+ job.celery_task_id = getattr(getattr(self, "request", None), "id", "") or ""
1374
+ job.save(update_fields=["status", "started_at", "celery_task_id"])
1375
+
1376
+ try:
1377
+ storage_path, size = build_and_store_program_archive(job_id)
1378
+ except Exception as exc:
1379
+ retries = getattr(getattr(self, "request", None), "retries", 0)
1380
+ if retries < EXPORT_MAX_RETRIES:
1381
+ logger.warning("run_program_export: job %s failed, retrying", job_id, exc_info=True)
1382
+ raise self.retry(exc=exc, countdown=10 * (2**retries)) from exc # type: ignore[attr-defined]
1383
+ logger.exception("run_program_export: job %s failed permanently", job_id)
1384
+ ProgramExportJob.objects.filter(pk=job_id).update(
1385
+ status=ExportJobStatus.FAILED,
1386
+ error_detail=str(exc)[:2000],
1387
+ completed_at=timezone.now(),
1388
+ )
1389
+ return
1390
+
1391
+ retention = _export_retention_days()
1392
+ expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
1393
+ ProgramExportJob.objects.filter(pk=job_id).update(
1394
+ status=ExportJobStatus.SUCCESS,
1395
+ file_path=storage_path,
1396
+ file_size=size,
1397
+ expires_at=expires_at,
1398
+ completed_at=timezone.now(),
1399
+ error_detail="",
1400
+ )
1401
+
1402
+
1403
+ @idempotent_task(
1404
+ lock_key_template="drain_program_exports",
1405
+ lock_ttl=60,
1406
+ on_contention="skip",
1407
+ soft_time_limit=25,
1408
+ time_limit=30,
1409
+ acks_late=True,
1410
+ reject_on_worker_lost=True,
1411
+ name="projects.drain_program_exports",
1412
+ )
1413
+ def drain_program_exports(self: object) -> None:
1414
+ """Re-dispatch program export jobs stuck in ``pending`` (broker down at on_commit)."""
1415
+ _do_drain_program_exports()
1416
+
1417
+
1418
+ @idempotent_task(
1419
+ lock_key_template="purge_expired_program_exports",
1420
+ lock_ttl=120,
1421
+ on_contention="skip",
1422
+ soft_time_limit=55,
1423
+ time_limit=90,
1424
+ acks_late=True,
1425
+ reject_on_worker_lost=True,
1426
+ name="projects.purge_expired_program_exports",
1427
+ )
1428
+ def purge_expired_program_exports(self: object) -> None:
1429
+ """Delete program export jobs past their link expiry, and their stored archives."""
1430
+ _do_purge_expired_program_exports()
1431
+
1432
+
1433
+ def _do_drain_program_exports() -> None:
1434
+ from trueppm_api.apps.projects.models import ExportJobStatus, ProgramExportJob
1435
+
1436
+ orphan_cutoff = timezone.now() - timedelta(minutes=EXPORT_ORPHAN_WINDOW_MINUTES)
1437
+ stuck = list(
1438
+ ProgramExportJob.objects.filter(
1439
+ status=ExportJobStatus.PENDING,
1440
+ celery_task_id="",
1441
+ created_at__lt=orphan_cutoff,
1442
+ ).order_by("created_at")[:EXPORT_DRAIN_BATCH_SIZE]
1443
+ )
1444
+ if not stuck:
1445
+ return
1446
+ for job in stuck:
1447
+ try:
1448
+ run_program_export.delay(str(job.id))
1449
+ except Exception: # pragma: no cover - broker still down, next tick retries
1450
+ logger.warning("drain_program_exports: broker still unavailable for %s", job.id)
1451
+ break
1452
+ logger.info("drain_program_exports: re-dispatched %d job(s)", len(stuck))
1453
+
1454
+
1455
+ def _do_purge_expired_program_exports() -> None:
1456
+ from django.core.files.storage import default_storage
1457
+
1458
+ from trueppm_api.apps.projects.models import ProgramExportJob
1459
+
1460
+ retention = _export_retention_days()
1461
+ if retention is None: # retention disabled — keep archives indefinitely
1462
+ return
1463
+ # Bounded per run (#2615). Each iteration is a storage DELETE plus a row
1464
+ # DELETE, and the task carries a 55 s soft limit — an unbounded walk over a
1465
+ # backlog larger than one window is SIGKILLed partway rather than draining,
1466
+ # so the same oversized backlog is retried from the start on every tick and
1467
+ # never shrinks. A slice drains it incrementally instead: whatever is left
1468
+ # is picked up by the next scheduled run.
1469
+ expired = ProgramExportJob.objects.filter(expires_at__lt=timezone.now()).order_by("expires_at")
1470
+ count = 0
1471
+ for job in expired[:PURGE_BATCH_SIZE]:
1472
+ if job.file_path:
1473
+ try:
1474
+ default_storage.delete(job.file_path)
1475
+ except OSError: # pragma: no cover - storage drift, still drop the row
1476
+ logger.warning(
1477
+ "purge_expired_program_exports: could not delete file for %s", job.id
1478
+ )
1479
+ job.delete()
1480
+ count += 1
1481
+ if count:
1482
+ logger.info("purge_expired_program_exports: deleted %d expired export(s)", count)
1483
+ if count == PURGE_BATCH_SIZE and (remaining := expired.count()):
1484
+ logger.info(
1485
+ "purge_expired_program_exports: batch full, %d expired export(s) remain for the "
1486
+ "next run",
1487
+ remaining,
1488
+ )
1489
+
1490
+
1491
+ # ---------------------------------------------------------------------------
1492
+ # Async program seed import (ADR-0726, #2574) — the mirror of the export tasks
1493
+ # above. Same durable-execution shape; the payload travels through storage.
1494
+ # ---------------------------------------------------------------------------
1495
+
1496
+ IMPORT_MAX_RETRIES = 3 # ADR-0726 §Durable Execution item 8
1497
+ IMPORT_ORPHAN_WINDOW_MINUTES = 10 # ADR-0726 §Durable Execution item 3
1498
+ IMPORT_DRAIN_BATCH_SIZE = 10
1499
+
1500
+
1501
+ def _import_retention_days() -> int | None:
1502
+ """Days a terminal import job (and its stored payload) is kept; ``None`` disables."""
1503
+ from django.conf import settings
1504
+
1505
+ value = getattr(settings, "TRUEPPM_IMPORT_RETENTION_DAYS", 7)
1506
+ return None if value in (None, 0) else int(value)
1507
+
1508
+
1509
+ @shared_task( # type: ignore[untyped-decorator]
1510
+ bind=True,
1511
+ max_retries=IMPORT_MAX_RETRIES,
1512
+ soft_time_limit=1800,
1513
+ time_limit=1860,
1514
+ acks_late=True,
1515
+ reject_on_worker_lost=True,
1516
+ name="projects.run_program_import",
1517
+ )
1518
+ def run_program_import(self: object, job_id: str) -> None:
1519
+ """Build the subtree for a queued seed import (ADR-0726, #2574).
1520
+
1521
+ The request already did the parts that must be synchronous: it validated the
1522
+ document, resolved and performed any confirmed replacement, and created the
1523
+ program shell. This task only fills that shell, which is why it is *purely
1524
+ additive* — and why the claim below is load-bearing rather than decorative.
1525
+ A duplicate delivery (drain re-dispatch, ``acks_late`` redelivery after a
1526
+ worker loss) would otherwise run the whole build a second time into the same
1527
+ program and double every task, dependency, and sprint; ``Task`` carries no
1528
+ ``(project, wbs_path)`` unique constraint to catch it.
1529
+
1530
+ Only transient faults retry. A validation or referential failure is
1531
+ deterministic — and the request already rejected those synchronously — so it
1532
+ goes straight to ``failed`` with the diagnostics on the row.
1533
+
1534
+ A failed job deliberately leaves the empty program shell in place rather than
1535
+ cleaning it up: the replaced subtree is in Trash, and the Owner needs both the
1536
+ shell and the failed job row to reason about what happened.
1537
+ """
1538
+ import json
1539
+
1540
+ from django.core.files.storage import default_storage
1541
+
1542
+ from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
1543
+ from trueppm_api.apps.projects.seed import SeedValidationError
1544
+ from trueppm_api.apps.projects.seed import import_seed as run_import
1545
+
1546
+ with transaction.atomic():
1547
+ job = ProgramImportJob.objects.select_for_update().filter(pk=job_id).first()
1548
+ if job is None:
1549
+ logger.warning("run_program_import: job %s not found", job_id)
1550
+ return
1551
+ if job.status not in (ImportJobStatus.PENDING, ImportJobStatus.RUNNING):
1552
+ logger.info("run_program_import: job %s already %s, skipping", job_id, job.status)
1553
+ return
1554
+ job.status = ImportJobStatus.RUNNING
1555
+ job.started_at = timezone.now()
1556
+ job.celery_task_id = getattr(getattr(self, "request", None), "id", "") or ""
1557
+ job.save(update_fields=["status", "started_at", "celery_task_id"])
1558
+
1559
+ program = job.program
1560
+
1561
+ # ``requested_by`` is SET_NULL, so an account deleted between the 202 and
1562
+ # this run leaves the importer with no owner to build memberships against.
1563
+ # It fails safely today — the importer raises rather than granting anything
1564
+ # to ``None`` — but it does so as an unhandled exception that burns all three
1565
+ # retries first and lands a traceback string in ``error_detail``. The
1566
+ # condition is deterministic, so refuse it up front with a reason the
1567
+ # polling client can render (#2615).
1568
+ if job.requested_by_id is None:
1569
+ logger.warning("run_program_import: job %s has no requester, refusing", job_id)
1570
+ _fail_import_job(
1571
+ job_id,
1572
+ "The account that requested this import no longer exists. "
1573
+ "Re-import as an existing user.",
1574
+ )
1575
+ return
1576
+
1577
+ try:
1578
+ with default_storage.open(job.file_path, "rb") as handle:
1579
+ payload = json.loads(handle.read().decode("utf-8"))
1580
+ except Exception:
1581
+ # The payload is gone or unreadable: deterministic, so never retry. The
1582
+ # reason is logged, not persisted — a storage exception renders the
1583
+ # absolute key path, and ``error_detail`` is served verbatim to any
1584
+ # program Admin while ``file_path`` is deliberately withheld from the
1585
+ # same serializer.
1586
+ logger.exception("run_program_import: payload unreadable for job %s", job_id)
1587
+ _fail_import_job(job_id, "The uploaded seed could no longer be read. Re-import the file.")
1588
+ return
1589
+
1590
+ try:
1591
+ # ``target_program`` adopts the shell the request created, which also
1592
+ # tells the importer to skip its own replace pass entirely — the
1593
+ # replacement, if any, already happened inside the request the operator
1594
+ # authorized (ADR-0726 §4).
1595
+ run_import(
1596
+ payload,
1597
+ owner=job.requested_by,
1598
+ create_users=False,
1599
+ target_program=program,
1600
+ )
1601
+ except SeedValidationError as exc:
1602
+ _fail_import_job(job_id, "; ".join(exc.errors)[:2000])
1603
+ return
1604
+ except Exception as exc:
1605
+ retries = getattr(getattr(self, "request", None), "retries", 0)
1606
+ if retries < IMPORT_MAX_RETRIES:
1607
+ logger.warning("run_program_import: job %s failed, retrying", job_id, exc_info=True)
1608
+ raise self.retry(exc=exc, countdown=10 * (2**retries)) from exc # type: ignore[attr-defined]
1609
+ logger.exception("run_program_import: job %s failed permanently", job_id)
1610
+ _fail_import_job(job_id, str(exc)[:2000])
1611
+ return
1612
+
1613
+ retention = _import_retention_days()
1614
+ expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
1615
+ ProgramImportJob.objects.filter(pk=job_id).update(
1616
+ status=ImportJobStatus.SUCCESS,
1617
+ result_summary=_import_summary(program),
1618
+ expires_at=expires_at,
1619
+ completed_at=timezone.now(),
1620
+ error_detail="",
1621
+ )
1622
+
1623
+
1624
+ def _import_summary(program: Any) -> dict[str, int]:
1625
+ """Entity counts the polling client renders once the job reaches ``success``."""
1626
+ from trueppm_api.apps.projects.models import Dependency, Project, Sprint, Task
1627
+
1628
+ project_ids = list(
1629
+ Project.objects.filter(program=program, is_deleted=False).values_list("pk", flat=True)
1630
+ )
1631
+ return {
1632
+ "projects": len(project_ids),
1633
+ "tasks": Task.objects.filter(project_id__in=project_ids, is_deleted=False).count(),
1634
+ "sprints": Sprint.objects.filter(project_id__in=project_ids, is_deleted=False).count(),
1635
+ "dependencies": Dependency.objects.filter(
1636
+ predecessor__project_id__in=project_ids, is_deleted=False
1637
+ ).count(),
1638
+ }
1639
+
1640
+
1641
+ def _fail_import_job(job_id: str, detail: str) -> None:
1642
+ """Mark a job ``failed`` with a reason the polling client can render.
1643
+
1644
+ Persisted rather than only logged: an async import failure has to be visible
1645
+ on the surface that launched it, which is the whole point of returning a job
1646
+ handle instead of a 504.
1647
+ """
1648
+ from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
1649
+
1650
+ retention = _import_retention_days()
1651
+ expires_at = timezone.now() + timedelta(days=retention) if retention is not None else None
1652
+ ProgramImportJob.objects.filter(pk=job_id).update(
1653
+ status=ImportJobStatus.FAILED,
1654
+ error_detail=detail or "The seed could not be imported.",
1655
+ expires_at=expires_at,
1656
+ completed_at=timezone.now(),
1657
+ )
1658
+
1659
+
1660
+ @idempotent_task(
1661
+ lock_key_template="drain_program_imports",
1662
+ lock_ttl=60,
1663
+ on_contention="skip",
1664
+ soft_time_limit=25,
1665
+ time_limit=30,
1666
+ acks_late=True,
1667
+ reject_on_worker_lost=True,
1668
+ name="projects.drain_program_imports",
1669
+ )
1670
+ def drain_program_imports(self: object) -> None:
1671
+ """Re-dispatch seed import jobs stuck in ``pending`` (broker down at on_commit)."""
1672
+ _do_drain_program_imports()
1673
+
1674
+
1675
+ @idempotent_task(
1676
+ lock_key_template="purge_expired_program_imports",
1677
+ lock_ttl=120,
1678
+ on_contention="skip",
1679
+ soft_time_limit=55,
1680
+ time_limit=90,
1681
+ acks_late=True,
1682
+ reject_on_worker_lost=True,
1683
+ name="projects.purge_expired_program_imports",
1684
+ )
1685
+ def purge_expired_program_imports(self: object) -> None:
1686
+ """Delete terminal seed import jobs past retention, and their stored payloads."""
1687
+ _do_purge_expired_program_imports()
1688
+
1689
+
1690
+ def _do_drain_program_imports() -> None:
1691
+ from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
1692
+
1693
+ # Only rows whose on_commit dispatch demonstrably never landed: still
1694
+ # pending, never assigned a task id, and older than the window. A row inside
1695
+ # an open on_commit callback is invisible until commit, so the age floor is
1696
+ # what stops the drain racing an in-flight upload it would double-dispatch.
1697
+ orphan_cutoff = timezone.now() - timedelta(minutes=IMPORT_ORPHAN_WINDOW_MINUTES)
1698
+ stuck = list(
1699
+ ProgramImportJob.objects.filter(
1700
+ status=ImportJobStatus.PENDING,
1701
+ celery_task_id="",
1702
+ created_at__lt=orphan_cutoff,
1703
+ ).order_by("created_at")[:IMPORT_DRAIN_BATCH_SIZE]
1704
+ )
1705
+ if not stuck:
1706
+ return
1707
+ for job in stuck:
1708
+ try:
1709
+ run_program_import.delay(str(job.id))
1710
+ except Exception: # pragma: no cover - broker still down, next tick retries
1711
+ logger.warning("drain_program_imports: broker still unavailable for %s", job.id)
1712
+ break
1713
+ logger.info("drain_program_imports: re-dispatched %d job(s)", len(stuck))
1714
+
1715
+
1716
+ def _do_purge_expired_program_imports() -> None:
1717
+ from django.core.files.storage import default_storage
1718
+
1719
+ from trueppm_api.apps.projects.models import ImportJobStatus, ProgramImportJob
1720
+
1721
+ retention = _import_retention_days()
1722
+ if retention is None: # retention disabled — keep rows indefinitely
1723
+ return
1724
+ # Bounded per run for the same reason as the export purge above (#2615).
1725
+ expired = ProgramImportJob.objects.filter(expires_at__lt=timezone.now()).order_by("expires_at")
1726
+ count = 0
1727
+ reaped = 0
1728
+ for job in expired[:PURGE_BATCH_SIZE]:
1729
+ if job.file_path:
1730
+ try:
1731
+ default_storage.delete(job.file_path)
1732
+ except OSError: # pragma: no cover - storage drift, still drop the row
1733
+ logger.warning(
1734
+ "purge_expired_program_imports: could not delete payload for %s", job.id
1735
+ )
1736
+ # A failed import deliberately keeps its program shell so the Owner can
1737
+ # reason about what happened (ADR-0726 §Durable Execution 8). That
1738
+ # reasoning needs the *job row*, and the job row is what this loop is
1739
+ # deleting — so past retention the shell is no longer evidence of
1740
+ # anything, it is only an empty program in the Owner's list that nothing
1741
+ # reaps. At the seed_import throttle's 6/min that is ~8,600 of them per
1742
+ # account per day. Retire it here, at the same moment its explanation
1743
+ # goes away, and only if it is still empty: anything the Owner has since
1744
+ # built under it makes it theirs, not the importer's leftover.
1745
+ # One transaction per job, not one for the batch: a storage error on the
1746
+ # next row must not roll back the rows already reaped, and the whole
1747
+ # point of the batch is that it can be interrupted and resumed. The
1748
+ # atomic block matters because ``soft_delete`` is two statements — the
1749
+ # row UPDATE and the ``deleted_version`` stamp the sync tombstone is read
1750
+ # from — and this runs in a beat task with no ambient transaction, so
1751
+ # without it a crash between them leaves a tombstone sync cannot see.
1752
+ with transaction.atomic():
1753
+ if job.status == ImportJobStatus.FAILED and _reap_empty_import_shell(job):
1754
+ reaped += 1
1755
+ job.delete()
1756
+ count += 1
1757
+ if count:
1758
+ logger.info("purge_expired_program_imports: deleted %d expired import(s)", count)
1759
+ if reaped:
1760
+ logger.info(
1761
+ "purge_expired_program_imports: retired %d empty failed-import shell(s)", reaped
1762
+ )
1763
+ if count == PURGE_BATCH_SIZE and (remaining := expired.count()):
1764
+ logger.info(
1765
+ "purge_expired_program_imports: batch full, %d expired import(s) remain for the "
1766
+ "next run",
1767
+ remaining,
1768
+ )
1769
+
1770
+
1771
+ def _reap_empty_import_shell(job: Any) -> bool:
1772
+ """Tombstone the empty program shell a failed import left behind (#2615).
1773
+
1774
+ Soft-deleted rather than hard-deleted: the program row's ``server_version``
1775
+ bump is what reaches offline sync consumers, and a hard delete would strand
1776
+ a client that had already seen the shell — the same reasoning that makes
1777
+ ``soft_delete_program_subtree`` tombstone the program instead of removing it.
1778
+ There is no program Trash (#2587), so this is not recoverable through the UI;
1779
+ that is why every guard below is a *reason not to touch it*.
1780
+
1781
+ Returns True when a shell was retired.
1782
+ """
1783
+ from trueppm_api.apps.projects.models import (
1784
+ ImportJobStatus,
1785
+ Program,
1786
+ ProgramImportJob,
1787
+ Project,
1788
+ )
1789
+
1790
+ program = Program.objects.filter(pk=job.program_id, is_deleted=False).first()
1791
+ if program is None: # already gone, or already tombstoned by a replace
1792
+ return False
1793
+ if Project.objects.filter(program_id=program.pk, is_deleted=False).exists():
1794
+ return False
1795
+ # A later import that succeeded into the same shell makes it real, even
1796
+ # though *this* job failed.
1797
+ if (
1798
+ ProgramImportJob.objects.filter(program_id=program.pk)
1799
+ .exclude(pk=job.pk)
1800
+ .exclude(status=ImportJobStatus.FAILED)
1801
+ .exists()
1802
+ ):
1803
+ return False
1804
+ # The ``server_version`` bump ``soft_delete`` writes is the delivery
1805
+ # mechanism: an offline client learns the shell is gone from its next sync
1806
+ # delta, which is the same path that carries every other program tombstone.
1807
+ #
1808
+ # Deliberately **no** ``program_deleted`` broadcast, unlike
1809
+ # ``soft_delete_program_subtree``. That one runs in a request a user is
1810
+ # watching; this runs in a nightly sweep, weeks later, over a shell that by
1811
+ # definition nobody has open. And ``broadcast_board_event`` is synchronous
1812
+ # (``async_to_sync`` over the channel layer) with no enclosing atomic block
1813
+ # here, so ``on_commit`` would fire it inline — up to ``PURGE_BATCH_SIZE``
1814
+ # blocking round-trips inside the same 55 s budget the batching above exists
1815
+ # to protect, for an event that no consumer can currently receive anyway
1816
+ # (program groups have no ``group_add``, #836).
1817
+ program.soft_delete()
1818
+ return True
1819
+
1820
+
1821
+ # ---------------------------------------------------------------------------
1822
+ # Schedule outline batch operation undo ledgers (ADR-0810, #2756)
1823
+ # ---------------------------------------------------------------------------
1824
+ # PasteManyOperation and CascadeClassificationOperation, not CsvImportRequest —
1825
+ # that table already has its own retention story (task_batch_services.py's
1826
+ # import-fix section). Shaped like purge_expired_project_exports above, not the
1827
+ # consolidated ADR-0173 retention coordinator (apps/observability): that
1828
+ # coordinator's RETENTION_SPECS is a deliberately curated, count-asserted set of
1829
+ # six tables surfaced in an operator-facing editor, and folding a seventh in
1830
+ # would be scope beyond what #2756 asked for. A plain settings-driven purge,
1831
+ # same shape as the export/import job purges already in this file, is
1832
+ # consistent with the (lighter) precedent those set.
1833
+
1834
+
1835
+ def _batch_operation_retention_days() -> int | None:
1836
+ from django.conf import settings
1837
+
1838
+ return getattr(settings, "TRUEPPM_BATCH_OPERATION_RETENTION_DAYS", 30)
1839
+
1840
+
1841
+ @idempotent_task(
1842
+ lock_key_template="purge_expired_batch_operations",
1843
+ lock_ttl=120,
1844
+ on_contention="skip",
1845
+ soft_time_limit=55,
1846
+ time_limit=90,
1847
+ acks_late=True,
1848
+ reject_on_worker_lost=True,
1849
+ name="projects.purge_expired_batch_operations",
1850
+ )
1851
+ def purge_expired_batch_operations(self: object) -> None:
1852
+ """Delete paste-many/cascade undo ledger rows past retention, undone or not."""
1853
+ _do_purge_expired_batch_operations()
1854
+
1855
+
1856
+ def _structural_operation_retention_days() -> int | None:
1857
+ """Structural rows get their own, shorter window (ADR-0880 Sec.9).
1858
+
1859
+ A structural act fires on every indent and every drag, so an afternoon of outlining
1860
+ is hundreds of rows where a paste-many is a handful. Sharing the 30-day batch
1861
+ window would accumulate an order of magnitude more rows for a horizon nobody uses:
1862
+ the undo affordance is session-scoped (the session trail caps at 10 entries), so
1863
+ 7 days comfortably outlives any session while keeping the table small.
1864
+ """
1865
+ from django.conf import settings
1866
+
1867
+ return getattr(settings, "TRUEPPM_STRUCTURAL_OPERATION_RETENTION_DAYS", 7)
1868
+
1869
+
1870
+ def _do_purge_expired_batch_operations() -> None:
1871
+ from trueppm_api.apps.projects.models import (
1872
+ CascadeClassificationOperation,
1873
+ PasteManyOperation,
1874
+ StructuralOperation,
1875
+ )
1876
+
1877
+ retention = _batch_operation_retention_days()
1878
+ paste_deleted = cascade_deleted = structural_deleted = 0
1879
+ if retention is not None: # None disables retention — keep rows indefinitely
1880
+ cutoff = timezone.now() - timedelta(days=retention)
1881
+ paste_deleted, _ = PasteManyOperation.objects.filter(created_at__lt=cutoff).delete()
1882
+ cascade_deleted, _ = CascadeClassificationOperation.objects.filter(
1883
+ created_at__lt=cutoff
1884
+ ).delete()
1885
+
1886
+ structural_retention = _structural_operation_retention_days()
1887
+ if structural_retention is not None:
1888
+ structural_cutoff = timezone.now() - timedelta(days=structural_retention)
1889
+ structural_deleted, _ = StructuralOperation.objects.filter(
1890
+ created_at__lt=structural_cutoff
1891
+ ).delete()
1892
+
1893
+ total = paste_deleted + cascade_deleted + structural_deleted
1894
+ if total:
1895
+ logger.info(
1896
+ "purge_expired_batch_operations: deleted %d paste-many + %d cascade "
1897
+ "+ %d structural row(s)",
1898
+ paste_deleted,
1899
+ cascade_deleted,
1900
+ structural_deleted,
1901
+ )
1902
+
1903
+
1904
+ # ---------------------------------------------------------------------------
1905
+ # Template application (ADR-0789, #2729)
1906
+ # ---------------------------------------------------------------------------
1907
+ # Celery's autodiscover_tasks() only imports ``<app>/tasks.py``, so a task defined
1908
+ # in a sibling module never registers and its Beat entry silently resolves to
1909
+ # nothing — the drain would appear scheduled and never run. Re-exported here so
1910
+ # the two template tasks are discovered, while their implementation stays in
1911
+ # ``template_tasks.py`` next to the rest of the template code.
1912
+ from trueppm_api.apps.projects.template_tasks import ( # noqa: E402
1913
+ apply_template,
1914
+ drain_template_apply_queue,
1915
+ )
1916
+
1917
+ __all__ = ["apply_template", "drain_template_apply_queue"]