trueppm-api 0.4.0b2__py3-none-any.whl

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