trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1330 @@
1
+ """Config-change notice: a change to the shared surface tells everyone on it (#2972).
2
+
3
+ Removing a board lane, hiding a column or a leaf surface, or switching the
4
+ methodology preset changes the surface **every assignee** works on. Before this,
5
+ only the person who clicked knew — the write broadcast ``board_config_updated``
6
+ to whoever happened to have the board open, and nothing reached anyone else.
7
+
8
+ Three properties, and the second is the one most likely to be eroded later:
9
+
10
+ 1. **It fires on the consequence, not the write.** A rename, a color, a WIP limit,
11
+ an added lane, a reorder — none of these notify. Only a change that moves or
12
+ hides work does. A notice on every save is noise, and noise gets muted, which
13
+ costs the signal this exists to carry. This is why the leaf-surface arm speaks
14
+ only for :data:`ENFORCED_SURFACE_KEYS`: a toggle no client reads moves nothing
15
+ on screen, so announcing it is the write talking, not the consequence (#3376).
16
+ 2. **The body names what the change did to the recipient's own items.** "Board
17
+ configuration updated" is precisely the notification this module exists to
18
+ refuse. Each recipient's row is rendered for them, with their own count, and
19
+ the batch is written in one insert — ``create_event_notifications_batch`` for
20
+ the board notice, its project-spanning form for the surface notice.
21
+ 3. **It reaches everyone the change re-shapes, not just the actor.** For the
22
+ board notice that is everyone with work on the board: the union of
23
+ ``Task.assignee`` and ``TaskResource`` over the project — see
24
+ :func:`assigned_recipient_ids` for why it is the union and not either one
25
+ alone — intersected with live membership. For the **surface** notice it is
26
+ wider, because assignment is the wrong proxy for "this surface is yours":
27
+ :func:`surface_recipient_ids` adds the ADR-0078 facet holders and everyone
28
+ seated at ``role >= SCHEDULER`` (#3291).
29
+
30
+ Never dispatched from a refusal path. Under ``ATOMIC_REQUESTS`` DRF's exception
31
+ handler calls ``set_rollback()`` for every ``APIException``, which discards the
32
+ whole transaction *including* its ``on_commit`` callbacks — so a notice attached
33
+ to a rejected write would be silently dropped anyway. Every entry point here is
34
+ called after a successful ``save()``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import logging
41
+ from collections import Counter
42
+ from functools import partial
43
+ from typing import TYPE_CHECKING, Any, NamedTuple
44
+
45
+ from celery.exceptions import SoftTimeLimitExceeded
46
+ from django.db import transaction
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Sequence
50
+
51
+ from .models import Project
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ #: Human labels for the four leaf surfaces (``surface_visibility.SURFACE_KEYS``).
56
+ #: Kept here rather than derived from the key so the notice reads as product copy
57
+ #: ("Monte Carlo") rather than as a column name ("monte_carlo"). The map stays
58
+ #: complete even though ``time_tracking`` is currently unreachable through
59
+ #: :data:`ENFORCED_SURFACE_KEYS` — restoring that key is then a one-line change
60
+ #: rather than a re-derivation.
61
+ SURFACE_LABELS: dict[str, str] = {
62
+ "reporting": "Reporting",
63
+ "time_tracking": "Time tracking",
64
+ "baselines": "Baselines",
65
+ "monte_carlo": "Monte Carlo",
66
+ }
67
+
68
+ #: The surfaces whose visibility a client actually acts on — the subset of
69
+ #: ``SURFACE_KEYS`` this module is allowed to speak about.
70
+ #:
71
+ #: ``time_tracking`` is deliberately absent (#3376). Nothing on web or mobile
72
+ #: reads ``Project.show_time_tracking``, so hiding it moves nothing on anyone's
73
+ #: screen — yet the notice told every assigned member, every facet holder and
74
+ #: every Scheduler+ seat that "Dana hid Time tracking in this project." That is
75
+ #: property 1 of this module inverted: it fired on the *write* rather than on the
76
+ #: consequence, and a notice about a change with no consequence is exactly the
77
+ #: noise that gets the signal muted.
78
+ #:
79
+ #: Filtering here rather than in the copy is deliberate — it is the single choke
80
+ #: point. :func:`capture_project_surface` builds both snapshots, so restricting it
81
+ #: suppresses the clause AND stops a time-tracking-only flip from being a
82
+ #: :class:`SurfaceChange` at all (which would otherwise emit a notice whose only
83
+ #: remaining sentence is "Nothing you own moved."). Add the key back the day a
84
+ #: surface reads the flag; see ``Project.show_time_tracking``.
85
+ #:
86
+ #: **Known hole, tracked as #3400.** This set is the has-a-consumer fact, and it
87
+ #: lives here — client-side of the API — so the REST surface still serves
88
+ #: ``effective_surface_visibility.time_tracking`` as a bare boolean an agent reads
89
+ #: as a real answer. #3400 moves the fact beside ``SURFACE_KEYS`` and serves it,
90
+ #: which also collapses the three places that must currently be edited in lockstep
91
+ #: (this set, the settings page's ``notEnforced`` string, and the docs section)
92
+ #: into one. Do not add a fourth unbound copy; extend #3400 instead.
93
+ #:
94
+ #: Pinned against the live ``SURFACE_KEYS`` by ``test_config_change_notice``'s
95
+ #: ``test_the_notice_speaks_for_every_surface_except_the_one_with_no_reader`` —
96
+ #: an allowlist omits a NEW key by default, and that failure is silent.
97
+ ENFORCED_SURFACE_KEYS: frozenset[str] = frozenset({"reporting", "baselines", "monte_carlo"})
98
+
99
+ #: Projects whose surface notices are resolved, rendered and written together.
100
+ #:
101
+ #: The reads are batched, so on query count alone the best chunk is the whole
102
+ #: apply — two hundred projects for eight reads. This trades a little of that
103
+ #: (four passes instead of one for a max-size apply, against ~1,800 before the
104
+ #: batching) for two bounds one flat batch does not have:
105
+ #:
106
+ #: * **Peak memory.** The rendered ``rows`` list holds a subject and a body
107
+ #: string per recipient. Flat, that is the sum over the whole apply; chunked it
108
+ #: is the maximum over one chunk.
109
+ #: * **Blast radius.** The reads and the insert sit under one ``try``, so a
110
+ #: failure in either loses every project that shares it. Flat, one bad read
111
+ #: drops the whole apply's notices; chunked it drops one chunk's.
112
+ #:
113
+ #: Fifty is the size at which the two bounds in play agree rather than one
114
+ #: dominating: a chunk of fifty projects at a typical ten-recipient cohort
115
+ #: renders ~500 rows, which is exactly ``NOTIFICATION_BULK_BATCH_SIZE`` — so the
116
+ #: list being accumulated and the statement it becomes are capped at the same
117
+ #: order of magnitude, and neither bound is doing all the work alone.
118
+ SURFACE_EMIT_CHUNK_SIZE = 50
119
+
120
+
121
+ def _chunked(items: Sequence[SurfaceChange], size: int) -> list[Sequence[SurfaceChange]]:
122
+ """``items`` split into consecutive runs of at most ``size``.
123
+
124
+ ``itertools.batched`` is the stdlib answer and this is not it, deliberately:
125
+ the runtime floor is Python 3.12 but ``[tool.mypy] python_version`` is 3.11,
126
+ where the stubs have no ``batched`` and ``mypy --strict`` fails. Widening a
127
+ global type-checker setting to import one helper is the wrong trade.
128
+ """
129
+ return [items[start : start + size] for start in range(0, len(items), size)]
130
+
131
+
132
+ #: Human labels for the methodology preset values.
133
+ METHODOLOGY_LABELS: dict[str, str] = {
134
+ "WATERFALL": "Waterfall",
135
+ "AGILE": "Agile",
136
+ "HYBRID": "Hybrid",
137
+ }
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Recipients — "everyone assigned in the project"
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _assigned_candidates_by_project(project_ids: Sequence[Any]) -> dict[str, set[Any]]:
146
+ """``{str(project_id): {user_id, ...}}`` — everyone with work, for a batch.
147
+
148
+ Two queries for any number of projects, which is the whole point: the union
149
+ of ``Task.assignee`` and ``TaskResource.resource.user`` is the same pair of
150
+ reads whether it is asked of one project or two hundred, and asking it per
151
+ project inside a bulk apply is how the emit reached ~2,000 round-trips
152
+ (#3335). :func:`assigned_recipient_ids` is the one-project caller.
153
+
154
+ Keys are stringified because the ids that reach here travel through an
155
+ ``on_commit`` payload as ``str``, while the ORM yields ``uuid.UUID`` — a
156
+ mapping keyed on the raw column would silently miss every lookup.
157
+
158
+ ``.distinct()`` matters on both arms: without it Postgres streams one row per
159
+ TASK to build a set of maybe thirty user ids, so a 10k-task project pulls 10k
160
+ UUIDs over the wire twice. The de-dupe belongs in the database.
161
+
162
+ **``.order_by()`` is what makes that ``.distinct()`` real, and it is not
163
+ cosmetic.** ``Task.Meta.ordering`` is ``["wbs_path", "name"]``, and a
164
+ ``.distinct()`` with no ``distinct_fields`` promotes every ORDER BY column
165
+ into the SELECT list — so the statement becomes ``SELECT DISTINCT project_id,
166
+ assignee_id, wbs_path, name``. ``projects_task`` carries an
167
+ ``ExclusionConstraint`` on ``(project, wbs_path)`` over live rows, which makes
168
+ that tuple unique among exactly the rows being scanned, so the DISTINCT
169
+ matches nothing and returns one row per task — the de-dupe silently does not
170
+ happen. Clearing the default ordering is the only thing standing between this
171
+ and the full-table transfer the previous paragraph exists to prevent.
172
+ """
173
+ from trueppm_api.apps.resources.models import TaskResource
174
+
175
+ from .models import Task
176
+
177
+ by_project: dict[str, set[Any]] = {}
178
+ for project_id, user_id in (
179
+ Task.objects.filter(project_id__in=project_ids, is_deleted=False)
180
+ .exclude(assignee_id=None)
181
+ .order_by()
182
+ .values_list("project_id", "assignee_id")
183
+ .distinct()
184
+ ):
185
+ by_project.setdefault(str(project_id), set()).add(user_id)
186
+ for project_id, user_id in (
187
+ # `TaskResource.Meta.ordering` is empty today, so this `order_by()` is a
188
+ # no-op — kept so that giving the model a default ordering later cannot
189
+ # quietly defeat this `distinct()` the way it does the Task arm above.
190
+ TaskResource.objects.filter(task__project_id__in=project_ids, task__is_deleted=False)
191
+ .exclude(resource__user__isnull=True)
192
+ .order_by()
193
+ .values_list("task__project_id", "resource__user_id")
194
+ .distinct()
195
+ ):
196
+ by_project.setdefault(str(project_id), set()).add(user_id)
197
+ return by_project
198
+
199
+
200
+ def assigned_recipient_ids(
201
+ project_id: Any,
202
+ *,
203
+ exclude_user_id: Any = None,
204
+ live_member_ids: set[Any] | None = None,
205
+ ) -> list[Any]:
206
+ """User ids of everyone with work in ``project_id``, minus the actor.
207
+
208
+ The board notice's cohort, and the assignment-shaped half of
209
+ :func:`surface_recipient_ids`. A lane going away moves *cards*, so having a
210
+ card is what makes someone a recipient — the wider surface cohort is not
211
+ right here, because a Product Owner with no card on the board has had nothing
212
+ moved out from under them.
213
+
214
+ The **union** of ``Task.assignee`` and ``TaskResource.resource.user``,
215
+ deliberately — and this is the one place in the codebase where the union is
216
+ right rather than one or the other. ``notify_amend`` uses ``TaskResource``
217
+ alone because it is talking about committed *load*, and a bare assignee
218
+ carries none. This is about the board a person works on, and a bare assignee
219
+ looks at exactly the same board as a resourced one; using ``TaskResource``
220
+ alone would silently tell nobody in a project imported from Jira (which
221
+ writes neither) or seeded from a template (assignees, no assignments).
222
+
223
+ Intersected with live ``ProjectMembership`` (``is_deleted=False``): an inbox
224
+ row about a project the recipient can no longer open is a dead link, and
225
+ naming a project's internal structure to a removed member is a scope leak
226
+ (ADR-0104's back-door close — a non-member sits below every tier).
227
+
228
+ ``live_member_ids`` lets a caller that has already read the project's live
229
+ membership pass it in rather than making this run the same filter a second
230
+ time — :func:`surface_recipient_ids` needs the full ``(user_id, role)`` map
231
+ for its own cohorts, and that map is a strict superset of the answer this
232
+ liveness check would compute for itself. Defaulted to ``None`` so the board
233
+ call site stays self-contained.
234
+ """
235
+ from trueppm_api.apps.access.models import ProjectMembership
236
+
237
+ candidates = set(_assigned_candidates_by_project([project_id]).get(str(project_id), set()))
238
+ candidates.discard(None)
239
+ if exclude_user_id is not None:
240
+ candidates.discard(exclude_user_id)
241
+ if not candidates:
242
+ return []
243
+
244
+ live_members = (
245
+ live_member_ids
246
+ if live_member_ids is not None
247
+ else set(
248
+ ProjectMembership.objects.filter(
249
+ project_id=project_id, is_deleted=False, user_id__in=candidates
250
+ ).values_list("user_id", flat=True)
251
+ )
252
+ )
253
+ return sorted(candidates & live_members, key=str)
254
+
255
+
256
+ def surface_recipient_ids(project_id: Any, *, exclude_user_id: Any = None) -> list[Any]:
257
+ """User ids of everyone a surface change re-shapes in ``project_id``, minus the actor.
258
+
259
+ Three cohorts, unioned:
260
+
261
+ 1. everyone with work in the project — :func:`assigned_recipient_ids`;
262
+ 2. the ADR-0078 Scrum Master and Product Owner facet holders;
263
+ 3. ``ProjectMembership`` rows at ``role >= Role.SCHEDULER``.
264
+
265
+ Assignment alone is the wrong proxy for "this surface is yours" (#3291). A
266
+ Product Owner authors and prioritizes the backlog and is frequently assigned
267
+ none of it; a Scrum Master facilitates and is neither an assignee nor a booked
268
+ resource; a PM who owns the plan routinely assigns none of it to themselves.
269
+ Those are the three people a preset flip re-shapes hardest, and the ones
270
+ everyone else asks to explain it — and the assigned-only cohort excluded
271
+ exactly them. Same class as #2897 at a new sink, which is why the facet half
272
+ reads :func:`~trueppm_api.apps.teams.services.facet_holder_user_ids` — the
273
+ scope gate's own source — rather than reimplementing the team lookup here.
274
+
275
+ The two sinks are ``ProjectViewSet.perform_update`` (gated ``IsProjectScheduler``)
276
+ and the program bulk matrix (gated ``IsProgramAdmin`` — a program-level gate with
277
+ no project-role correspondence at all). ``role >= SCHEDULER`` therefore sits *at
278
+ or below* the write gate on both paths rather than mirroring either, and the
279
+ threshold is justified by **read** parity, not write parity: ``show_*`` and
280
+ ``effective_surface_visibility`` are already readable by any project member
281
+ (Viewer+), so telling a Scheduler discloses nothing they could not fetch
282
+ themselves. Note the cohort is deliberately not role-monotone — the facet arm
283
+ notifies Member- and Viewer-seated holders, per ADR-0078 and #2897.
284
+
285
+ **Every cohort is intersected with live ``ProjectMembership``**, and the facet
286
+ half genuinely needs it: the ADR-0078 §F mirror only ever *creates* team rows
287
+ (:mod:`trueppm_api.apps.teams.signals` has no ``post_delete`` receiver and
288
+ ``TeamMembership`` has no FK a cascade could travel), so soft-deleting a
289
+ ``ProjectMembership`` leaves the mirrored ``TeamMembership`` live with its
290
+ facet flags intact. Without the intersection, widening the cohort would widen
291
+ who keeps being told a project's internal structure after losing access — a
292
+ dead link and a scope leak (ADR-0104's back-door close: a non-member sits
293
+ below every tier).
294
+
295
+ The two sibling facet cohorts leaked on exactly that path until #3334 moved the
296
+ guard into ``facet_holder_user_ids`` itself, where it is now the default for
297
+ every caller — :func:`~trueppm_api.apps.projects.services._sprint_lead_recipient_ids`
298
+ and :func:`~trueppm_api.apps.projects.blocker_services.resolve_impediment_recipients`
299
+ get it without asking. This function opts *out* of the helper's own subquery
300
+ (``live_project_members_only=False``) precisely because it has already read live
301
+ membership and intersects in Python — see the paragraph below.
302
+
303
+ One membership read serves all three cohorts: the ``(user_id, role)`` map is
304
+ the Scheduler+ set, the facet arm's liveness floor, **and** the liveness floor
305
+ :func:`assigned_recipient_ids` would otherwise query for itself — which is why
306
+ it is threaded in rather than letting that call repeat the same filter.
307
+
308
+ The actor is discarded **after** the union, not before it, and that order is
309
+ load-bearing: the actor of a surface change is Scheduler+ on the
310
+ ``perform_update`` path essentially by definition, so the Scheduler arm re-adds
311
+ them on every flip. This final ``discard`` is the only thing standing between a
312
+ PM and a notice about their own click.
313
+
314
+ Implemented by :func:`surface_recipient_ids_by_project` with a one-element
315
+ batch — same four queries, one definition of the cohort.
316
+ """
317
+ return surface_recipient_ids_by_project([project_id], exclude_user_id=exclude_user_id).get(
318
+ str(project_id), []
319
+ )
320
+
321
+
322
+ def surface_recipient_ids_by_project(
323
+ project_ids: Sequence[Any], *, exclude_user_id: Any = None
324
+ ) -> dict[str, list[Any]]:
325
+ """``{str(project_id): [user_id, ...]}`` — the surface cohort, for a batch.
326
+
327
+ Four queries for any number of projects, and exactly the four
328
+ :func:`surface_recipient_ids` used to issue for one: the membership scan, the
329
+ two assignment reads and the facet join. Each is ``project_id IN (...)``
330
+ grouped by project, and nothing in the batch depends on another project's
331
+ result, so the per-project loop that used to hold them belongs above them
332
+ rather than around them (#3335).
333
+
334
+ Cohort semantics, liveness and actor exclusion are :func:`surface_recipient_ids`'s
335
+ — read that docstring; this is the same function asked of a set of projects,
336
+ and the single-project form now delegates here so the two cannot drift.
337
+
338
+ The facet arm reads :func:`~trueppm_api.apps.teams.services.facet_holder_user_ids_by_project`,
339
+ the batch counterpart of the scope gate's own source, and intersects it with
340
+ ``live_ids`` here — the same intersection the one-project path applied, from
341
+ the same membership map, so widening the batch cannot widen who keeps being
342
+ told a project's internal structure after losing access.
343
+ """
344
+ from trueppm_api.apps.access.models import ProjectMembership, Role
345
+ from trueppm_api.apps.teams.services import facet_holder_user_ids_by_project
346
+
347
+ live_roles: dict[str, dict[Any, int]] = {}
348
+ for project_id, user_id, role in ProjectMembership.objects.filter(
349
+ project_id__in=project_ids, is_deleted=False
350
+ ).values_list("project_id", "user_id", "role"):
351
+ live_roles.setdefault(str(project_id), {})[user_id] = role
352
+
353
+ assigned = _assigned_candidates_by_project(project_ids)
354
+ facets = {
355
+ str(pid): holders for pid, holders in facet_holder_user_ids_by_project(project_ids).items()
356
+ }
357
+
358
+ cohorts: dict[str, list[Any]] = {}
359
+ for raw_id in project_ids:
360
+ pid = str(raw_id)
361
+ roles = live_roles.get(pid, {})
362
+ live_ids = set(roles)
363
+
364
+ recipients = assigned.get(pid, set()) & live_ids
365
+ recipients |= {uid for uid, role in roles.items() if role >= Role.SCHEDULER}
366
+ recipients |= facets.get(pid, set()) & live_ids
367
+
368
+ recipients.discard(None)
369
+ if exclude_user_id is not None:
370
+ recipients.discard(exclude_user_id)
371
+ cohorts[pid] = sorted(recipients, key=str)
372
+ return cohorts
373
+
374
+
375
+ def _counts_by_user(project_id: Any, task_filter: Any) -> Counter[Any]:
376
+ """Per-user count of the affected tasks in ``project_id``.
377
+
378
+ Two **grouped** queries, and no per-task rows materialized in Python. The
379
+ surface path passes a bare ``Q()`` — every task in the project — so pulling
380
+ ``(user, task)`` pairs back to de-duplicate them would build a set
381
+ proportional to the whole task table on a project that could have tens of
382
+ thousands of rows, to produce a handful of integers.
383
+
384
+ A task the same user both owns and is booked on must count **once**, which is
385
+ what the ``F("task__assignee_id")`` exclusion does: the assignee side counts
386
+ every owned task, and the resource side counts only the ones the counter does
387
+ not already own. ``distinct=True`` belongs on the **resource** arm only, where
388
+ a user booked twice on one task through two resource rows would otherwise
389
+ count twice; the assignee arm groups by ``assignee_id`` over a join-free
390
+ queryset, so its ``id`` is already unique per group.
391
+
392
+ Implemented by :func:`_counts_by_user_by_project` with a one-element batch —
393
+ still two queries, one definition of the count.
394
+ """
395
+ return _counts_by_user_by_project([project_id], task_filter).get(str(project_id), Counter())
396
+
397
+
398
+ def _counts_by_user_by_project(
399
+ project_ids: Sequence[Any], task_filter: Any
400
+ ) -> dict[str, Counter[Any]]:
401
+ """``{str(project_id): Counter({user_id: n})}`` — :func:`_counts_by_user`, batched.
402
+
403
+ Two **grouped** queries for any number of projects: the grouping key gains
404
+ ``project_id`` and the filter becomes ``IN (...)``, which is the entire
405
+ difference. The surface notice runs this once per bulk apply rather than once
406
+ per project (#3335), and every de-duplication rule described above is
407
+ unchanged — ``project_id`` only partitions groups a single project's query
408
+ already kept separate, since a task belongs to exactly one project.
409
+ """
410
+ from django.db.models import Count, F
411
+
412
+ from trueppm_api.apps.resources.models import TaskResource
413
+
414
+ from .models import Task
415
+
416
+ affected = Task.objects.filter(project_id__in=project_ids, is_deleted=False).filter(task_filter)
417
+ counts: dict[str, Counter[Any]] = {}
418
+ for pid, uid, n in (
419
+ affected.exclude(assignee_id=None)
420
+ .values("project_id", "assignee_id")
421
+ .annotate(n=Count("id"))
422
+ .values_list("project_id", "assignee_id", "n")
423
+ ):
424
+ counts.setdefault(str(pid), Counter())[uid] += n
425
+ for pid, uid, n in (
426
+ TaskResource.objects.filter(task__in=affected)
427
+ .exclude(resource__user__isnull=True)
428
+ .exclude(resource__user_id=F("task__assignee_id"))
429
+ .values("task__project_id", "resource__user_id")
430
+ .annotate(n=Count("task_id", distinct=True))
431
+ .values_list("task__project_id", "resource__user_id", "n")
432
+ ):
433
+ counts.setdefault(str(pid), Counter())[uid] += n
434
+ return counts
435
+
436
+
437
+ def _actor_name(actor: Any) -> str:
438
+ """Display name for the person who made the change, or a neutral stand-in."""
439
+ if actor is None or not getattr(actor, "is_authenticated", False):
440
+ return "Someone"
441
+ return (
442
+ getattr(actor, "get_full_name", lambda: "")() or getattr(actor, "username", "") or "Someone"
443
+ )
444
+
445
+
446
+ def _actor_id(actor: Any) -> Any:
447
+ return actor.pk if actor is not None and getattr(actor, "is_authenticated", False) else None
448
+
449
+
450
+ def _plural(count: int, singular: str = "item", plural: str = "items") -> str:
451
+ return f"{count} {singular if count == 1 else plural}"
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+ # Board configuration — lanes removed, columns hidden
456
+ # ---------------------------------------------------------------------------
457
+
458
+
459
+ class _RemovedLane(NamedTuple):
460
+ key: str
461
+ label: str
462
+ column_label: str
463
+ destination: str
464
+
465
+
466
+ class _HiddenColumn(NamedTuple):
467
+ status: str
468
+ label: str
469
+
470
+
471
+ def _column_label(column: dict[str, Any]) -> str:
472
+ return str(column.get("label") or column.get("status") or "this column")
473
+
474
+
475
+ def _first_lane_label(column: dict[str, Any] | None) -> str:
476
+ """Where an orphaned lane key lands, said in words.
477
+
478
+ ``Task.board_lane`` is never rewritten when a lane is deleted (a bulk UPDATE
479
+ would bypass the ``server_version`` bump every synced write depends on), so
480
+ the board resolves a dangling key to the column's **first** lane on read. The
481
+ notice must name the same destination the board will actually show, or it
482
+ describes a move that did not happen.
483
+ """
484
+ if column is None:
485
+ return "its column"
486
+ lanes = column.get("lanes") or []
487
+ if lanes:
488
+ first = lanes[0]
489
+ return str(first.get("label") or first.get("key") or "the first lane")
490
+ # No lanes left at all: the column renders as one implicit lane, i.e. itself.
491
+ return _column_label(column)
492
+
493
+
494
+ def _removed_lanes(
495
+ old_by_status: dict[str, dict[str, Any]],
496
+ new_by_status: dict[str, dict[str, Any]],
497
+ ) -> list[_RemovedLane]:
498
+ """Lanes that existed before the write and do not survive it.
499
+
500
+ Each one carries the destination its orphaned cards will resolve to, read
501
+ from the *new* column — see :func:`_first_lane_label`.
502
+ """
503
+ removed: list[_RemovedLane] = []
504
+ for status, old_col in old_by_status.items():
505
+ new_col = new_by_status.get(status)
506
+ surviving = {str(lane.get("key")) for lane in (new_col or {}).get("lanes") or []}
507
+ for lane in old_col.get("lanes") or []:
508
+ key = str(lane.get("key"))
509
+ if key in surviving:
510
+ continue
511
+ removed.append(
512
+ _RemovedLane(
513
+ key=key,
514
+ label=str(lane.get("label") or key),
515
+ column_label=_column_label(new_col or old_col),
516
+ destination=_first_lane_label(new_col),
517
+ )
518
+ )
519
+ return removed
520
+
521
+
522
+ def _hidden_columns(
523
+ old_by_status: dict[str, dict[str, Any]],
524
+ new_by_status: dict[str, dict[str, Any]],
525
+ ) -> list[_HiddenColumn]:
526
+ """Columns that were visible before the write and are not after it."""
527
+ hidden: list[_HiddenColumn] = []
528
+ for status, old_col in old_by_status.items():
529
+ new_col = new_by_status.get(status)
530
+ was_visible = old_col.get("visible", True)
531
+ # A column dropped from the payload entirely cannot happen (the serializer
532
+ # requires all five canonical statuses), but treat absence as hidden
533
+ # rather than as "unchanged" so the notice can never under-report.
534
+ now_visible = new_col.get("visible", True) if new_col is not None else False
535
+ if was_visible and not now_visible:
536
+ hidden.append(_HiddenColumn(status=status, label=_column_label(new_col or old_col)))
537
+ return hidden
538
+
539
+
540
+ def diff_board_config(
541
+ old_columns: list[dict[str, Any]], new_columns: list[dict[str, Any]]
542
+ ) -> tuple[list[_RemovedLane], list[_HiddenColumn]]:
543
+ """The two board changes that move or hide work.
544
+
545
+ Everything else a board-config PUT can carry — rename, reorder, recolor, a
546
+ WIP-limit change, an *added* lane, a column being *un*-hidden — leaves every
547
+ card exactly where the person who owns it last saw it, and produces no
548
+ notice. Returning the pair (rather than a bool) is what lets the body name
549
+ the specific lane and column instead of the fact that a PUT happened.
550
+ """
551
+ new_by_status = {str(c.get("status")): c for c in new_columns}
552
+ old_by_status = {str(c.get("status")): c for c in old_columns}
553
+ return (
554
+ _removed_lanes(old_by_status, new_by_status),
555
+ _hidden_columns(old_by_status, new_by_status),
556
+ )
557
+
558
+
559
+ def _removed_clause(actor_name: str, removed: list[_RemovedLane], lane_count: int) -> str:
560
+ """The removed-lane sentence pair, or ``""`` when no lane was removed.
561
+
562
+ Every destination is named against the lane it belongs to. Removing two lanes
563
+ from two different columns sends their cards to two *different* first lanes,
564
+ so a single destination borrowed from ``removed[0]`` would send most of the
565
+ recipient to the wrong place — which is worse than saying nothing, because
566
+ they will look there, not find their work, and stop trusting the next notice.
567
+ """
568
+ if not removed:
569
+ return ""
570
+ if len(removed) == 1:
571
+ lane = removed[0]
572
+ lead = f"{actor_name} removed the “{lane.label}” lane from {lane.column_label}."
573
+ tail = (
574
+ f"Your {_plural(lane_count)} in there now "
575
+ f"{'shows' if lane_count == 1 else 'show'} in “{lane.destination}” — "
576
+ f"the first lane of that column."
577
+ if lane_count
578
+ else "None of your items were in it."
579
+ )
580
+ else:
581
+ moves = _join_phrases(
582
+ [
583
+ f"“{lane.label}” from {lane.column_label} (cards move to “{lane.destination}”)"
584
+ for lane in removed
585
+ ]
586
+ )
587
+ lead = f"{actor_name} removed {len(removed)} lanes: {moves}."
588
+ tail = (
589
+ f"{lane_count} of those items {'is' if lane_count == 1 else 'are'} yours."
590
+ if lane_count
591
+ else "None of your items were in them."
592
+ )
593
+ return f"{lead} {tail}"
594
+
595
+
596
+ def _hidden_clause(actor_name: str, hidden: list[_HiddenColumn], column_count: int) -> str:
597
+ """The hidden-column sentence pair, or ``""`` when no column was hidden."""
598
+ if not hidden:
599
+ return ""
600
+ names = _join_phrases([f"“{col.label}”" for col in hidden])
601
+ noun = "column" if len(hidden) == 1 else "columns"
602
+ lead = f"{actor_name} hid the {names} {noun}."
603
+ if column_count:
604
+ # NOT "reach them from My Work": `/me/work/` filters on `assignee` alone
605
+ # and excludes BACKLOG, so that instruction is false for anyone counted
606
+ # through a TaskResource booking, and false for everyone when the hidden
607
+ # column is Backlog. A notice that sends the reader somewhere their work
608
+ # is not is the failure this whole module is written to avoid — so it
609
+ # names the two surfaces that show every status and both assignment kinds.
610
+ it_them = "it" if column_count == 1 else "them"
611
+ return (
612
+ f"{lead} Your {_plural(column_count)} with that status "
613
+ f"{'keeps its' if column_count == 1 else 'keep their'} status and dates, "
614
+ f"but the board no longer shows {it_them} — find {it_them} in the "
615
+ f"task list or search."
616
+ )
617
+ return f"{lead} None of your items have that status."
618
+
619
+
620
+ def _board_body(
621
+ actor_name: str,
622
+ removed: list[_RemovedLane],
623
+ hidden: list[_HiddenColumn],
624
+ lane_count: int,
625
+ column_count: int,
626
+ ) -> str:
627
+ """One recipient's copy of the board notice."""
628
+ parts = [
629
+ _removed_clause(actor_name, removed, lane_count),
630
+ _hidden_clause(actor_name, hidden, column_count),
631
+ ]
632
+ return " ".join(part for part in parts if part)
633
+
634
+
635
+ def _join_phrases(phrases: list[str]) -> str:
636
+ """``a``, ``a and b``, ``a, b and c`` — an Oxford-comma-free serial list."""
637
+ if len(phrases) == 1:
638
+ return phrases[0]
639
+ return f"{', '.join(phrases[:-1])} and {phrases[-1]}"
640
+
641
+
642
+ def _board_subject(removed: list[_RemovedLane], hidden: list[_HiddenColumn], affected: int) -> str:
643
+ """A subject that already carries the consequence.
644
+
645
+ The inbox row previews the body, but the subject is what a recipient scanning
646
+ a full bell sees first — so it states what happened to *them*, not that a
647
+ configuration changed.
648
+ """
649
+ if removed and hidden:
650
+ return (
651
+ f"Your board was reconfigured — {_plural(affected)} affected"
652
+ if affected
653
+ else "Your board was reconfigured"
654
+ )
655
+ if removed:
656
+ what = (
657
+ "A board lane was removed"
658
+ if len(removed) == 1
659
+ else f"{len(removed)} board lanes were removed"
660
+ )
661
+ return f"{what} — {_plural(affected)} moved" if affected else what
662
+ what = (
663
+ "A board column was hidden"
664
+ if len(hidden) == 1
665
+ else f"{len(hidden)} board columns were hidden"
666
+ )
667
+ return f"{what} — {_plural(affected)} no longer on the board" if affected else what
668
+
669
+
670
+ def notify_board_config_change(
671
+ project: Project,
672
+ *,
673
+ old_columns: list[dict[str, Any]],
674
+ new_columns: list[dict[str, Any]],
675
+ actor: Any,
676
+ ) -> None:
677
+ """Tell everyone with work in ``project`` that a lane or column went away.
678
+
679
+ Queued through the ``ConfigNoticeRequest`` outbox (ADR-1174) and emitted by a
680
+ worker: a notification failure must never revert a board configuration the
681
+ caller has already accepted, and must not be lost to a broker outage either.
682
+
683
+ **Every read the notice depends on happens in the worker** — the recipient set
684
+ as well as the counts. Resolving recipients here instead would run three
685
+ queries while the caller still holds its write locks, and would pair a
686
+ pre-commit recipient list with post-commit counts, so a person whose
687
+ assignment changed in the window could get a row whose count describes a
688
+ different moment than the list that selected them.
689
+ """
690
+ removed, hidden = diff_board_config(old_columns, new_columns)
691
+ if not removed and not hidden:
692
+ return
693
+
694
+ _enqueue(
695
+ KIND_BOARD,
696
+ {
697
+ "project_id": str(project.pk),
698
+ "actor_name": _actor_name(actor),
699
+ "removed": [lane._asdict() for lane in removed],
700
+ "hidden": [column._asdict() for column in hidden],
701
+ },
702
+ actor=actor,
703
+ )
704
+
705
+
706
+ def _emit_board_notifications(
707
+ project_id: str,
708
+ actor_id: Any,
709
+ removed: list[_RemovedLane],
710
+ hidden: list[_HiddenColumn],
711
+ actor_name: str,
712
+ ) -> None:
713
+ from django.db.models import Q
714
+
715
+ from trueppm_api.apps.notifications.models import NotificationEventType
716
+ from trueppm_api.apps.notifications.services import create_event_notifications_batch
717
+
718
+ try:
719
+ recipient_ids = assigned_recipient_ids(project_id, exclude_user_id=actor_id)
720
+ if not recipient_ids:
721
+ return
722
+
723
+ lane_counts: Counter[Any] = Counter()
724
+ if removed:
725
+ lane_counts = _counts_by_user(
726
+ project_id, Q(board_lane__in=[lane.key for lane in removed])
727
+ )
728
+ column_counts: Counter[Any] = Counter()
729
+ if hidden:
730
+ column_counts = _counts_by_user(
731
+ project_id, Q(status__in=[col.status for col in hidden])
732
+ )
733
+
734
+ rows = []
735
+ for rid in recipient_ids:
736
+ lane_n = lane_counts.get(rid, 0)
737
+ column_n = column_counts.get(rid, 0)
738
+ rows.append(
739
+ (
740
+ rid,
741
+ _board_subject(removed, hidden, lane_n + column_n),
742
+ _board_body(actor_name, removed, hidden, lane_n, column_n),
743
+ None,
744
+ )
745
+ )
746
+ create_event_notifications_batch(
747
+ event_type=NotificationEventType.PROJECT_CONFIG_CHANGED,
748
+ project_id=project_id,
749
+ rows=rows,
750
+ )
751
+ except Exception:
752
+ logger.exception("notify_board_config_change: emit failed for project %s", project_id)
753
+
754
+
755
+ # ---------------------------------------------------------------------------
756
+ # Project preset and leaf-surface visibility
757
+ # ---------------------------------------------------------------------------
758
+
759
+
760
+ class ProjectSurfaceSnapshot(NamedTuple):
761
+ """The project's surface as it stood before a write.
762
+
763
+ Captured rather than re-read because ``serializer.save()`` mutates the
764
+ instance in place — after it, the previous preset is unrecoverable.
765
+ """
766
+
767
+ methodology: str
768
+ #: Effective visibility keyed by surface, restricted to
769
+ #: :data:`ENFORCED_SURFACE_KEYS` — NOT every key in ``SURFACE_KEYS``.
770
+ visibility: dict[str, bool]
771
+ #: The surfaces :data:`ENFORCED_SURFACE_KEYS` leaves out. Captured for ONE
772
+ #: purpose — the diagnostic in :func:`collect_project_surface_change` — and
773
+ #: deliberately excluded from the change comparison, so it can never
774
+ #: resurrect the notice it exists to explain the absence of. Defaults to
775
+ #: empty for hand-built test snapshots; never mutated, so the shared default
776
+ #: is safe.
777
+ unenforced: dict[str, bool] = {} # noqa: RUF012
778
+
779
+
780
+ def capture_project_surface(project: Project, *, workspace: Any = None) -> ProjectSurfaceSnapshot:
781
+ """Snapshot ``project``'s preset and its *enforced* leaf-surface visibility.
782
+
783
+ Narrowed to :data:`ENFORCED_SURFACE_KEYS`, so the snapshot carries only the
784
+ surfaces a client actually renders against. A surface nothing reads cannot
785
+ make the before/after differ, and therefore cannot produce a notice — which
786
+ is the whole guarantee, applied once here rather than restated in the diff
787
+ and again in the copy.
788
+ """
789
+ from .surface_visibility import resolve_effective_visibility
790
+
791
+ resolved = resolve_effective_visibility(project, workspace=workspace)
792
+ return ProjectSurfaceSnapshot(
793
+ methodology=str(project.methodology or ""),
794
+ visibility={k: v for k, v in resolved.items() if k in ENFORCED_SURFACE_KEYS},
795
+ unenforced={k: v for k, v in resolved.items() if k not in ENFORCED_SURFACE_KEYS},
796
+ )
797
+
798
+
799
+ def _is_are(count: int) -> str:
800
+ return "is" if count == 1 else "are"
801
+
802
+
803
+ def _attribution_clauses(
804
+ actor_name: str,
805
+ before: ProjectSurfaceSnapshot,
806
+ after: ProjectSurfaceSnapshot,
807
+ ) -> list[str]:
808
+ """The preset clause and the surface clauses, in that order.
809
+
810
+ The actor is named exactly once, and which clause names them is load-bearing
811
+ — which is why both sides of the preset-vs-override split live in this one
812
+ function rather than in two. A preset switch is the deliberate act and the
813
+ surface changes fall out of it, so the preset clause is attributed and the
814
+ surfaces are stated as consequence: writing "Dana hid Baselines" for a flip
815
+ Dana never made surface-by-surface would attribute a decision to them that
816
+ they did not take. When there is no preset change the override IS the
817
+ deliberate act, so it takes the attribution.
818
+ """
819
+ parts: list[str] = []
820
+ hidden = [k for k, v in after.visibility.items() if before.visibility.get(k) and not v]
821
+ shown = [k for k, v in after.visibility.items() if v and not before.visibility.get(k, False)]
822
+
823
+ if before.methodology != after.methodology:
824
+ old_label = METHODOLOGY_LABELS.get(before.methodology, before.methodology or "none")
825
+ new_label = METHODOLOGY_LABELS.get(after.methodology, after.methodology)
826
+ parts.append(
827
+ f"{actor_name} switched this project's planning preset from {old_label} to {new_label}."
828
+ )
829
+ if hidden:
830
+ parts.append(f"{_join_labels(hidden)} {_is_are(len(hidden))} no longer shown here.")
831
+ if shown:
832
+ parts.append(f"{_join_labels(shown)} {_is_are(len(shown))} now shown.")
833
+ else:
834
+ if hidden:
835
+ parts.append(f"{actor_name} hid {_join_labels(hidden)} in this project.")
836
+ if shown:
837
+ parts.append(f"{actor_name} turned {_join_labels(shown)} back on in this project.")
838
+ return parts
839
+
840
+
841
+ def _ownership_clause(item_count: int) -> str:
842
+ """The closing clause, which always speaks to the recipient's own items.
843
+
844
+ The zero branch is load-bearing rather than an edge case: once the cohort
845
+ includes facet holders and Scheduler+ members (:func:`surface_recipient_ids`),
846
+ a recipient owning nothing is the *normal* case for a PO or Scrum Master, not
847
+ a rarity. It stays a sentence rather than an omission because a dropped clause
848
+ reads as an unstated many.
849
+ """
850
+ if item_count:
851
+ keeps = "keeps its" if item_count == 1 else "keep their"
852
+ return (
853
+ f"Your {_plural(item_count)} {keeps} status, dates and assignments — "
854
+ f"what changed is where you find "
855
+ f"{'it' if item_count == 1 else 'them'}."
856
+ )
857
+ return "Nothing you own moved."
858
+
859
+
860
+ def _surface_body(
861
+ actor_name: str,
862
+ before: ProjectSurfaceSnapshot,
863
+ after: ProjectSurfaceSnapshot,
864
+ item_count: int,
865
+ ) -> str:
866
+ """One recipient's copy of the preset / views notice.
867
+
868
+ Every clause is grounded in a value the server computed: the two preset
869
+ labels, and the surfaces whose *effective* visibility actually flipped. It
870
+ does not claim anything about which chrome the web client chooses to render
871
+ for a preset — a notice that over-claims is worse than a terse one, because
872
+ the recipient checks it once and stops believing the next one.
873
+ """
874
+ parts = _attribution_clauses(actor_name, before, after)
875
+ parts.append(_ownership_clause(item_count))
876
+ return " ".join(parts)
877
+
878
+
879
+ def _join_labels(keys: list[str]) -> str:
880
+ return _join_phrases([SURFACE_LABELS.get(k, k) for k in keys])
881
+
882
+
883
+ def _surface_subject(before: ProjectSurfaceSnapshot, after: ProjectSurfaceSnapshot) -> str:
884
+ if before.methodology != after.methodology:
885
+ new_label = METHODOLOGY_LABELS.get(after.methodology, after.methodology)
886
+ return f"This project now runs as {new_label}"
887
+ return "The views in this project changed"
888
+
889
+
890
+ class SurfaceChange(NamedTuple):
891
+ """One project's before/after, ready to render once the transaction commits."""
892
+
893
+ project_id: str
894
+ before: ProjectSurfaceSnapshot
895
+ after: ProjectSurfaceSnapshot
896
+
897
+
898
+ def collect_project_surface_change(
899
+ project: Project,
900
+ *,
901
+ before: ProjectSurfaceSnapshot,
902
+ workspace: Any = None,
903
+ ) -> SurfaceChange | None:
904
+ """``SurfaceChange`` if this project's surface actually moved, else ``None``.
905
+
906
+ Split out from :func:`notify_project_surface_change` so the bulk settings
907
+ matrix — which applies one field map to as many as ``MAX_BULK_TARGETS``
908
+ projects — can register **one** ``on_commit`` callback for the whole batch
909
+ rather than one per project. Two hundred separate callbacks would each run
910
+ their own recipient and count queries inline in the request/response cycle.
911
+
912
+ Returns ``None`` when the preset is unchanged and every leaf surface's
913
+ **effective** visibility is unchanged — so writing an explicit override equal
914
+ to the value the project already resolved to notifies nobody, which is right:
915
+ nothing on screen moved.
916
+ """
917
+ after = capture_project_surface(project, workspace=workspace)
918
+ if before.methodology == after.methodology and before.visibility == after.visibility:
919
+ # A suppressed notice is a silent non-event: an operator can see the write
920
+ # in ``HistoricalProject`` and see zero Notification rows, and nothing
921
+ # connects the two. Without this line the answer to "I hid Time tracking
922
+ # and nobody was told — is the notice system broken?" lives only in a
923
+ # frozenset in this file. Log the ONE case that is a deliberate
924
+ # suppression rather than a genuine no-op, so the decision is visible at
925
+ # the moment it takes effect (#3376).
926
+ moved = sorted(k for k, v in after.unenforced.items() if before.unenforced.get(k) != v)
927
+ if moved:
928
+ logger.info(
929
+ "surface notice suppressed for project %s: only unenforced surface(s) %s "
930
+ "moved — nothing reads them, so nothing on screen changed "
931
+ "(config_notice.ENFORCED_SURFACE_KEYS)",
932
+ project.pk,
933
+ moved,
934
+ )
935
+ return None
936
+ return SurfaceChange(project_id=str(project.pk), before=before, after=after)
937
+
938
+
939
+ def notify_project_surface_change(
940
+ project: Project,
941
+ *,
942
+ before: ProjectSurfaceSnapshot,
943
+ actor: Any,
944
+ workspace: Any = None,
945
+ ) -> None:
946
+ """Tell everyone this project's surface belongs to that its preset or views moved.
947
+
948
+ The cohort is :func:`surface_recipient_ids` — wider than the board notice's,
949
+ because a preset flip re-shapes the working surface of people who hold no
950
+ assigned task.
951
+ """
952
+ change = collect_project_surface_change(project, before=before, workspace=workspace)
953
+ if change is None:
954
+ return
955
+ notify_surface_changes([change], actor=actor)
956
+
957
+
958
+ def notify_surface_changes(changes: list[SurfaceChange], *, actor: Any) -> None:
959
+ """Queue one emit for every project whose surface moved in this transaction.
960
+
961
+ One outbox row for the whole batch, so the bulk matrix's reads stay grouped in
962
+ the worker exactly as #3335 grouped them in the request (ADR-1174).
963
+ """
964
+ if not changes:
965
+ return
966
+ _enqueue(
967
+ KIND_SURFACE,
968
+ {
969
+ "actor_name": _actor_name(actor),
970
+ "changes": [_surface_change_to_json(change) for change in changes],
971
+ },
972
+ actor=actor,
973
+ )
974
+
975
+
976
+ # ---------------------------------------------------------------------------
977
+ # Outbox — the emit leaves the request (#3009, ADR-1174)
978
+ # ---------------------------------------------------------------------------
979
+
980
+ KIND_BOARD = "board"
981
+ KIND_SURFACE = "surface"
982
+
983
+ #: Claims a queued notice may take before its failure is final. Counted per claim,
984
+ #: so a dispatch whose broker message was lost never spends the budget.
985
+ MAX_CONFIG_NOTICE_ATTEMPTS = 3
986
+
987
+
988
+ def _snapshot_to_json(snapshot: ProjectSurfaceSnapshot) -> dict[str, Any]:
989
+ return {"methodology": snapshot.methodology, "visibility": dict(snapshot.visibility)}
990
+
991
+
992
+ def _snapshot_from_json(data: dict[str, Any]) -> ProjectSurfaceSnapshot:
993
+ return ProjectSurfaceSnapshot(
994
+ methodology=str(data["methodology"]),
995
+ visibility={str(k): bool(v) for k, v in data["visibility"].items()},
996
+ )
997
+
998
+
999
+ def _surface_change_to_json(change: SurfaceChange) -> dict[str, Any]:
1000
+ return {
1001
+ "project_id": str(change.project_id),
1002
+ "before": _snapshot_to_json(change.before),
1003
+ "after": _snapshot_to_json(change.after),
1004
+ }
1005
+
1006
+
1007
+ def _surface_change_from_json(data: dict[str, Any]) -> SurfaceChange:
1008
+ return SurfaceChange(
1009
+ project_id=str(data["project_id"]),
1010
+ before=_snapshot_from_json(data["before"]),
1011
+ after=_snapshot_from_json(data["after"]),
1012
+ )
1013
+
1014
+
1015
+ def _enqueue(kind: str, payload: dict[str, Any], *, actor: Any) -> None:
1016
+ """Write the outbox row in the caller's transaction; dispatch once it commits.
1017
+
1018
+ The row is the durability, not the dispatch. It commits or rolls back with the
1019
+ config write it describes, so a notice can neither outlive a rejected write nor
1020
+ be lost to a broker that was unreachable at the moment of commit — the drain
1021
+ picks up whatever the on-commit dispatch could not send.
1022
+ """
1023
+ from .models import ConfigNoticeRequest
1024
+
1025
+ row = ConfigNoticeRequest.objects.create(kind=kind, payload=payload, actor_id=_actor_id(actor))
1026
+ transaction.on_commit(partial(_dispatch_config_notice, str(row.pk)))
1027
+
1028
+
1029
+ def _dispatch_config_notice(request_id: str) -> None:
1030
+ """Best-effort immediate dispatch of a committed notice.
1031
+
1032
+ Swallows every failure: this runs after COMMIT but still inside the request, so
1033
+ raising here would turn a write the server already accepted into a 500. A row
1034
+ this cannot mark ``dispatched`` stays ``pending`` and the drain sends it.
1035
+ """
1036
+ from django.utils import timezone
1037
+
1038
+ from .models import ConfigNoticeRequest, ConfigNoticeRequestStatus
1039
+ from .tasks import emit_config_notice
1040
+
1041
+ try:
1042
+ result = emit_config_notice.delay(request_id)
1043
+ ConfigNoticeRequest.objects.filter(
1044
+ pk=request_id, status=ConfigNoticeRequestStatus.PENDING
1045
+ ).update(
1046
+ status=ConfigNoticeRequestStatus.DISPATCHED,
1047
+ celery_task_id=str(result.id),
1048
+ claimed_at=timezone.now(),
1049
+ )
1050
+ except Exception:
1051
+ logger.exception(
1052
+ "config notice %s: immediate dispatch failed; left pending for the drain",
1053
+ request_id,
1054
+ )
1055
+
1056
+
1057
+ def run_config_notice_request(request_id: str) -> None:
1058
+ """Claim a queued notice, apply the cooldown, and emit it.
1059
+
1060
+ The claim is a conditional UPDATE, so the on-commit dispatch and a drain
1061
+ re-dispatch of the same row can race: zero rows claimed means the row is
1062
+ running elsewhere or already finished, and this delivery is a no-op.
1063
+
1064
+ The emitters log and swallow their own per-chunk and per-project failures, so
1065
+ an exception reaching this handler is structural (an unreadable payload). It
1066
+ returns the row to ``pending`` for the drain, or marks it ``dead`` once the
1067
+ claim budget is spent.
1068
+ """
1069
+ from django.db.models import F
1070
+ from django.utils import timezone
1071
+
1072
+ from .models import ConfigNoticeRequest, ConfigNoticeRequestStatus
1073
+
1074
+ claimed = ConfigNoticeRequest.objects.filter(
1075
+ pk=request_id,
1076
+ status__in=[ConfigNoticeRequestStatus.PENDING, ConfigNoticeRequestStatus.DISPATCHED],
1077
+ ).update(
1078
+ status=ConfigNoticeRequestStatus.RUNNING,
1079
+ claimed_at=timezone.now(),
1080
+ attempt_count=F("attempt_count") + 1,
1081
+ )
1082
+ if not claimed:
1083
+ return
1084
+
1085
+ row = ConfigNoticeRequest.objects.get(pk=request_id)
1086
+ running = ConfigNoticeRequest.objects.filter(
1087
+ pk=request_id, status=ConfigNoticeRequestStatus.RUNNING
1088
+ )
1089
+ try:
1090
+ if row.kind == KIND_BOARD:
1091
+ _run_board_notice(row)
1092
+ else:
1093
+ _run_surface_notice(row)
1094
+ except Exception:
1095
+ final = row.attempt_count >= MAX_CONFIG_NOTICE_ATTEMPTS
1096
+ logger.log(
1097
+ logging.ERROR if final else logging.WARNING,
1098
+ "config notice %s: emit failed on attempt %d%s",
1099
+ request_id,
1100
+ row.attempt_count,
1101
+ " — abandoned" if final else "; left pending for the drain",
1102
+ exc_info=True,
1103
+ )
1104
+ running.update(
1105
+ status=ConfigNoticeRequestStatus.DEAD if final else ConfigNoticeRequestStatus.PENDING,
1106
+ completed_at=timezone.now() if final else None,
1107
+ )
1108
+ return
1109
+ running.update(status=ConfigNoticeRequestStatus.DONE, completed_at=timezone.now())
1110
+
1111
+
1112
+ def _run_board_notice(row: Any) -> None:
1113
+ payload = row.payload
1114
+ project_id = str(payload["project_id"])
1115
+ removed = [_RemovedLane(**lane) for lane in payload["removed"]]
1116
+ hidden = [_HiddenColumn(**column) for column in payload["hidden"]]
1117
+ suppressed = _cooling_down(
1118
+ KIND_BOARD, row.actor_id, str(row.pk), {project_id: _board_signature(removed, hidden)}
1119
+ )
1120
+ if project_id in suppressed:
1121
+ return
1122
+ _emit_board_notifications(project_id, row.actor_id, removed, hidden, str(payload["actor_name"]))
1123
+
1124
+
1125
+ def _run_surface_notice(row: Any) -> None:
1126
+ payload = row.payload
1127
+ changes = [_surface_change_from_json(change) for change in payload["changes"]]
1128
+ suppressed = _cooling_down(
1129
+ KIND_SURFACE,
1130
+ row.actor_id,
1131
+ str(row.pk),
1132
+ {change.project_id: _surface_signature(change) for change in changes},
1133
+ )
1134
+ live = [change for change in changes if change.project_id not in suppressed]
1135
+ if live:
1136
+ _emit_surface_notifications(live, row.actor_id, str(payload["actor_name"]))
1137
+
1138
+
1139
+ def _board_signature(removed: list[_RemovedLane], hidden: list[_HiddenColumn]) -> str:
1140
+ """What a board notice asserts: which lanes went where, which columns went away."""
1141
+ return json.dumps(
1142
+ {
1143
+ "removed": sorted([lane.key, lane.column_label, lane.destination] for lane in removed),
1144
+ "hidden": sorted(column.status for column in hidden),
1145
+ },
1146
+ sort_keys=True,
1147
+ )
1148
+
1149
+
1150
+ def _surface_signature(change: SurfaceChange) -> str:
1151
+ """What a surface notice asserts — the same three facts ``_attribution_clauses`` renders."""
1152
+ before, after = change.before, change.after
1153
+ return json.dumps(
1154
+ {
1155
+ "preset": after.methodology if before.methodology != after.methodology else None,
1156
+ "hidden": sorted(
1157
+ k for k, v in after.visibility.items() if before.visibility.get(k) and not v
1158
+ ),
1159
+ "shown": sorted(
1160
+ k for k, v in after.visibility.items() if v and not before.visibility.get(k, False)
1161
+ ),
1162
+ },
1163
+ sort_keys=True,
1164
+ )
1165
+
1166
+
1167
+ def _cooling_down(
1168
+ kind: str, actor_id: Any, request_id: str, signatures: dict[str, str]
1169
+ ) -> set[str]:
1170
+ """Projects whose notice repeats the last one sent for them, by the same actor.
1171
+
1172
+ Suppresses only when the stored actor, the signature, AND a *different* outbox
1173
+ row all match the last notice recorded for ``(kind, project)``:
1174
+
1175
+ * **Against the last notice sent, never "any recent" one.** After
1176
+ Agile→Waterfall→Agile, a third flip to Waterfall must send — suppressing it
1177
+ because the first matched would leave the recipient's latest notice saying
1178
+ Agile about a project that runs as Waterfall. A hide/un-hide/hide cycle does
1179
+ collapse, because the un-hide sends nothing and the last notice still says
1180
+ "hidden".
1181
+ * **Keyed per project, with the actor stored.** If someone else's notice
1182
+ intervened, the stored actor no longer matches and the repeat sends.
1183
+ * **The row id is stored** so a row the drain re-drives after a worker died
1184
+ mid-emit does not find its own signature and suppress the chunks it never
1185
+ wrote.
1186
+
1187
+ Fails open on a Valkey error, as ``MentionRateThrottle`` does: the cooldown only
1188
+ ever removes noise and must never be the way a notice is lost (ADR-1174 §D).
1189
+ The window is fixed from the notice that was sent, not slid by suppressed
1190
+ repeats, so a change toggled indefinitely still re-notifies once per window.
1191
+ """
1192
+ from typing import cast
1193
+
1194
+ import redis
1195
+ from django.conf import settings
1196
+
1197
+ from trueppm_api.core import valkey
1198
+
1199
+ window = int(getattr(settings, "TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS", 0) or 0)
1200
+ if window <= 0 or actor_id is None or not signatures:
1201
+ return set()
1202
+
1203
+ actor = str(actor_id)
1204
+ items = list(signatures.items())
1205
+ keys = [f"config_notice:last:{kind}:{project_id}" for project_id, _ in items]
1206
+ suppressed: set[str] = set()
1207
+ try:
1208
+ client = valkey.client(valkey.DB_NOTIFICATIONS, decode_responses=True)
1209
+ previous = cast("list[str | None]", client.mget(keys))
1210
+ pipe = client.pipeline(transaction=False)
1211
+ for (project_id, signature), key, stored in zip(items, keys, previous, strict=True):
1212
+ if stored is not None:
1213
+ last_actor, last_signature, last_request = json.loads(stored)
1214
+ if (
1215
+ last_actor == actor
1216
+ and last_signature == signature
1217
+ and last_request != request_id
1218
+ ):
1219
+ suppressed.add(project_id)
1220
+ continue
1221
+ pipe.set(key, json.dumps([actor, signature, request_id]), ex=window)
1222
+ pipe.execute()
1223
+ except redis.RedisError:
1224
+ logger.exception("config notice cooldown: Valkey error, failing open")
1225
+ return set()
1226
+
1227
+ for project_id in suppressed:
1228
+ logger.info(
1229
+ "config notice suppressed for project %s: repeats the last %s notice actor %s sent "
1230
+ "within %ss (TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS)",
1231
+ project_id,
1232
+ kind,
1233
+ actor,
1234
+ window,
1235
+ )
1236
+ return suppressed
1237
+
1238
+
1239
+ def _emit_surface_notifications(
1240
+ changes: list[SurfaceChange],
1241
+ actor_id: Any,
1242
+ actor_name: str,
1243
+ ) -> None:
1244
+ """Render and write one notice per recipient across every changed project.
1245
+
1246
+ **Every read is hoisted out of the per-project loop** (#3335). The program
1247
+ settings matrix can hand this up to ``MAX_BULK_TARGETS`` projects in one
1248
+ outbox row. Since #3009 the emit runs in the ``projects.emit_config_notice``
1249
+ worker rather than the request thread, but a read left inside the loop would
1250
+ still be two hundred round-trips holding a worker slot. Each read is
1251
+ ``project_id IN (...)`` grouped by project, and nothing in the batch depends
1252
+ on another project's result.
1253
+
1254
+ The work is then processed in chunks of :data:`SURFACE_EMIT_CHUNK_SIZE`
1255
+ projects rather than as one batch, which is a deliberate trade of a little of
1256
+ that saving for two bounds the flat version did not have — see that constant
1257
+ for why. Within a chunk the cost is flat: the same eight reads whether the
1258
+ chunk holds one project or fifty.
1259
+
1260
+ The per-project ``try`` survives the hoist and now guards **only rendering**,
1261
+ which is where a per-project failure can actually originate: a malformed
1262
+ snapshot in one project's copy must not swallow the notices for the rest of
1263
+ the batch. The per-chunk ``try`` is both the on-commit contract — this
1264
+ callback runs after the write has been accepted and must never raise into it
1265
+ — and the reason a failed read or insert costs one chunk rather than the
1266
+ whole apply.
1267
+ """
1268
+ from django.db.models import Q
1269
+
1270
+ from trueppm_api.apps.notifications.models import NotificationEventType
1271
+ from trueppm_api.apps.notifications.services import (
1272
+ create_event_notifications_multi_project,
1273
+ )
1274
+
1275
+ for chunk in _chunked(changes, SURFACE_EMIT_CHUNK_SIZE):
1276
+ project_ids = [change.project_id for change in chunk]
1277
+ try:
1278
+ recipients_by_project = surface_recipient_ids_by_project(
1279
+ project_ids, exclude_user_id=actor_id
1280
+ )
1281
+ counts_by_project = _counts_by_user_by_project(project_ids, Q())
1282
+
1283
+ rows: list[tuple[Any, Any, str, str, Any]] = []
1284
+ for change in chunk:
1285
+ try:
1286
+ pid = str(change.project_id)
1287
+ recipient_ids = recipients_by_project.get(pid, [])
1288
+ if not recipient_ids:
1289
+ continue
1290
+ counts = counts_by_project.get(pid, Counter())
1291
+ subject = _surface_subject(change.before, change.after)
1292
+ # Materialized before it is appended, so a render that fails
1293
+ # half-way through one project's recipients contributes
1294
+ # nothing rather than a partial fan-out.
1295
+ rendered = [
1296
+ (
1297
+ rid,
1298
+ change.project_id,
1299
+ subject,
1300
+ _surface_body(
1301
+ actor_name, change.before, change.after, counts.get(rid, 0)
1302
+ ),
1303
+ None,
1304
+ )
1305
+ for rid in recipient_ids
1306
+ ]
1307
+ rows.extend(rendered)
1308
+ except SoftTimeLimitExceeded:
1309
+ raise
1310
+ except Exception:
1311
+ logger.exception(
1312
+ "notify_project_surface_change: render failed for project %s",
1313
+ change.project_id,
1314
+ )
1315
+
1316
+ if not rows:
1317
+ continue
1318
+ # ``Notification`` carries project_id per row, so a chunk is one
1319
+ # insert (itself capped at NOTIFICATION_BULK_BATCH_SIZE rows per
1320
+ # statement) rather than one insert per project.
1321
+ create_event_notifications_multi_project(
1322
+ event_type=NotificationEventType.PROJECT_CONFIG_CHANGED,
1323
+ rows=rows,
1324
+ )
1325
+ except SoftTimeLimitExceeded:
1326
+ raise
1327
+ except Exception:
1328
+ logger.exception(
1329
+ "notify_project_surface_change: emit failed for projects %s", project_ids
1330
+ )