trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1489 @@
1
+ """Notification fan-out + mention parser (ADR-0075 §C, §D).
2
+
3
+ Pure-function `parse_mentions` extracts `@user` and `@group` references from
4
+ free-text comment bodies. Code-fence and inline-code aware so `@foo` inside
5
+ triple-backtick blocks or single backticks is NOT treated as a mention.
6
+ `\\@foo` is an explicit escape that renders the literal text.
7
+
8
+ `create_mention_notifications` is the single transactional fan-out path:
9
+ called from the comment viewset's `perform_create` (inside `transaction.
10
+ on_commit` is NOT correct here — Notification rows must be visible to the
11
+ caller in the same transaction so the API response can include the count).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime
17
+ import logging
18
+ import re
19
+ from typing import TYPE_CHECKING, Any, NamedTuple
20
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
21
+
22
+ from django.conf import settings
23
+ from django.contrib.auth import get_user_model
24
+ from django.db import transaction
25
+ from django.db.models.functions import Lower
26
+ from django.utils import timezone
27
+
28
+ # django-stubs needs a real class for type annotations; get_user_model() returns
29
+ # a runtime alias that mypy can't use in a type position. Import the concrete
30
+ # User class for typing, keep get_user_model() result for runtime queries.
31
+ if TYPE_CHECKING:
32
+ from django.contrib.auth.models import User as UserType
33
+ else:
34
+ UserType = get_user_model()
35
+
36
+ from trueppm_api.apps.access.groups import (
37
+ ALL_AUTO_GROUP_KEYS,
38
+ GroupTooLargeError,
39
+ InvalidGroupKeyError,
40
+ resolve_group_members,
41
+ )
42
+
43
+ from .models import (
44
+ DEFAULT_PREFERENCES,
45
+ DND_BYPASS_EVENTS,
46
+ PROJECT_NOTIFICATION_DEFAULT_MATRIX,
47
+ Mention,
48
+ MentionScope,
49
+ Notification,
50
+ NotificationChannel,
51
+ NotificationEventType,
52
+ NotificationPreference,
53
+ ProjectNotificationChannel,
54
+ ProjectNotificationEventType,
55
+ ProjectNotificationPreference,
56
+ UserNotificationSettings,
57
+ )
58
+
59
+ if TYPE_CHECKING:
60
+ import uuid
61
+ from collections.abc import Callable, Sequence
62
+
63
+ from trueppm_api.apps.access.models import ExternalStakeholder
64
+ from trueppm_api.apps.projects.models import TaskComment
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ User = get_user_model()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Email-drain nudge (#4191)
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def drain_notification_emails_soon() -> None:
77
+ """Best-effort nudge the notification-email drain to run now, not next tick.
78
+
79
+ Only a latency optimization: the ``Notification`` row is the durable outbox
80
+ record and the 30 s Beat ``drain_notification_emails`` still sends it if the
81
+ broker drops this nudge, so broker errors are logged and swallowed. The drain
82
+ is the only sender and is serialized by a singleton lock — a nudged run that
83
+ finds a drain already running skips — so the nudge cannot cause a second send.
84
+ Call from ``transaction.on_commit``.
85
+ """
86
+ from .tasks import drain_notification_emails
87
+
88
+ try:
89
+ drain_notification_emails.delay()
90
+ except Exception:
91
+ logger.warning("broker unavailable; periodic drain_notification_emails will send")
92
+
93
+
94
+ def _bulk_create_notifications(
95
+ notifications: list[Notification], *, batch_size: int | None = None
96
+ ) -> None:
97
+ """Insert fan-out rows and, when any queues an email, nudge the drain on commit.
98
+
99
+ Every email-queuing fan-out in this module goes through here so first-send
100
+ latency is seconds everywhere, not only on the paths someone remembered to
101
+ wire (#4191). Deferred to ``on_commit`` so a rolled-back write enqueues nothing
102
+ and the drain never runs before the rows it should send are visible.
103
+ """
104
+ Notification.objects.bulk_create(notifications, batch_size=batch_size)
105
+ if any(n.email_pending for n in notifications):
106
+ transaction.on_commit(drain_notification_emails_soon)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Mention parser
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ class ParsedMention(NamedTuple):
115
+ """One extracted @reference. `kind` is 'user' or 'group'."""
116
+
117
+ kind: str # "user" or "group"
118
+ value: str # username or group key
119
+
120
+
121
+ # Matches @{username-or-groupkey}. Username/key allows letters, digits,
122
+ # underscore, hyphen, dot. Min 1 char. Word boundary required after.
123
+ # A leading backslash escapes the @ (handled in the loop, not the regex).
124
+ _MENTION_RE = re.compile(r"(?P<esc>\\?)@(?P<name>[A-Za-z0-9_.-]+)")
125
+
126
+ # Stripping strategy: replace fenced code blocks (```...```) and inline code
127
+ # (`...`) with same-length placeholders so the regex's character offsets stay
128
+ # consistent, then scan for mentions. We don't need to preserve original
129
+ # content — only to mask out the regions where @ doesn't mean mention.
130
+ _FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
131
+ _INLINE_CODE_RE = re.compile(r"`[^`]*`")
132
+
133
+
134
+ def _mask_code_regions(body: str) -> str:
135
+ """Replace code spans with same-length spaces so @-mentions inside don't match."""
136
+
137
+ def _spaces(match: re.Match[str]) -> str:
138
+ return " " * (match.end() - match.start())
139
+
140
+ body = _FENCE_RE.sub(_spaces, body)
141
+ body = _INLINE_CODE_RE.sub(_spaces, body)
142
+ return body
143
+
144
+
145
+ def parse_mentions(body: str) -> list[ParsedMention]:
146
+ """Extract distinct @mentions from a comment body.
147
+
148
+ - `@scrum-team`, `@program-pms`, and other auto-group keys (project +
149
+ program scope) are returned as `kind='group'`
150
+ - Anything else matching the @name pattern is returned as `kind='user'`
151
+ - `\\@name` is treated as escaped — not a mention
152
+ - `@name` inside ``` ``` ``` ``` fences or `single-backtick` code is NOT a mention
153
+ - Duplicates collapsed (one mention per distinct value, first-occurrence order)
154
+
155
+ User existence + project-membership check happens in
156
+ `create_mention_notifications`, not in the parser. The parser is pure.
157
+ """
158
+ masked = _mask_code_regions(body)
159
+ seen: set[tuple[str, str]] = set()
160
+ out: list[ParsedMention] = []
161
+
162
+ for match in _MENTION_RE.finditer(masked):
163
+ if match.group("esc"): # escaped: \@name
164
+ continue
165
+ name = match.group("name")
166
+ kind = "group" if name.lower() in ALL_AUTO_GROUP_KEYS else "user"
167
+ key = (kind, name if kind == "user" else name.lower())
168
+ if key in seen:
169
+ continue
170
+ seen.add(key)
171
+ out.append(ParsedMention(kind=kind, value=key[1]))
172
+ return out
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Rate-limit constants (ADR-0075 locked constraints #8, #9)
177
+ # ---------------------------------------------------------------------------
178
+
179
+ MENTION_DAILY_LIMIT = 1000
180
+ MENTION_HOURLY_BURST = 100
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Notification preference defaults
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ def get_or_create_default_preferences(user: UserType) -> list[NotificationPreference]:
189
+ """Backfill NotificationPreference rows for a user using DEFAULT_PREFERENCES.
190
+
191
+ Idempotent: existing rows are left alone; only missing (event_type, channel)
192
+ pairs are inserted. Returns the user's full preference set.
193
+ """
194
+ with transaction.atomic():
195
+ existing = {
196
+ (pref.event_type, pref.channel): pref
197
+ for pref in NotificationPreference.objects.filter(user=user)
198
+ }
199
+ to_create = [
200
+ NotificationPreference(
201
+ user=user, event_type=event_type, channel=channel, enabled=enabled
202
+ )
203
+ for event_type, channel, enabled in DEFAULT_PREFERENCES
204
+ if (event_type, channel) not in existing
205
+ ]
206
+ if to_create:
207
+ NotificationPreference.objects.bulk_create(to_create)
208
+ return list(NotificationPreference.objects.filter(user=user))
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Project-scoped delivery gate (#674)
213
+ # ---------------------------------------------------------------------------
214
+
215
+ # Channels carrying a durable record rather than a transient ping. The in-app
216
+ # inbox row *is* the notification — suppressing it during quiet hours would
217
+ # lose the mention outright instead of deferring a ping, so quiet hours never
218
+ # gate it (the matrix in_app cell still does). Every other channel is a transient
219
+ # interrupt and IS silenced inside the window. Mirrors Slack and GitHub DND: the
220
+ # record persists; only the interruption is held back.
221
+ #
222
+ # Today that means email, and only email: `slack` and `mobile_push` are matrix
223
+ # columns with no delivery path (see PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS),
224
+ # so the gate below is written for them but has nothing to hold back. Naming them
225
+ # as silenced channels would read as a claim that they otherwise deliver (#3378).
226
+ _QUIET_HOURS_EXEMPT_CHANNELS = frozenset({ProjectNotificationChannel.IN_APP.value})
227
+
228
+ # Channels account-wide Do-Not-Disturb (UserNotificationSettings.dnd_enabled)
229
+ # silences. Same rationale as _QUIET_HOURS_EXEMPT_CHANNELS: the in-app inbox row
230
+ # is the durable record and is NEVER silenced — DND holds back the interrupt, not
231
+ # the record. The "in_app" string value is shared by NotificationChannel.IN_APP
232
+ # and ProjectNotificationChannel.IN_APP, so this exemption is correct on both the
233
+ # global event-sourced gate and the project-scoped gate.
234
+ _DND_EXEMPT_CHANNELS = frozenset({NotificationChannel.IN_APP.value})
235
+
236
+
237
+ def _workspace_timezone_name(workspace: Any = None) -> str:
238
+ """The singleton workspace's IANA timezone name, or ``""`` if unreadable.
239
+
240
+ Two ways to get ``""``, both meaning "no workspace tier — move down the chain":
241
+ there is no singleton row (a fresh install, or one purged per ADR-0174), or the
242
+ table is not reachable at all (mid-migration). This sits inside a notification
243
+ dispatch path, so neither may take the fan-out down with it.
244
+
245
+ Pass an already-loaded ``workspace`` to skip the query entirely — the same
246
+ threading idiom as ``scheduling.forecast_history_settings.resolve_effective_mc_history``,
247
+ which keeps a batched resolve at one workspace query rather than one per row.
248
+ """
249
+ if workspace is not None:
250
+ return str(getattr(workspace, "timezone", "") or "").strip()
251
+ from django.db import DatabaseError, transaction
252
+
253
+ from trueppm_api.apps.workspace.models import Workspace
254
+
255
+ try:
256
+ # Two deliberate choices here.
257
+ #
258
+ # A plain read, NOT ``Workspace.load()``: load() is a get_or_create, and a
259
+ # dispatch path must not write a row as a side effect of asking a question.
260
+ # No row simply means "no workspace tier" and the chain moves on.
261
+ #
262
+ # Wrapped in a savepoint: ATOMIC_REQUESTS wraps the whole request, so a
263
+ # DatabaseError swallowed without one would leave the outer atomic block
264
+ # marked for rollback and turn this clean degradation into a 500 that names
265
+ # some unrelated later query.
266
+ with transaction.atomic():
267
+ stored = (
268
+ Workspace.objects.filter(singleton_key=1).values_list("timezone", flat=True).first()
269
+ )
270
+ except DatabaseError:
271
+ # Narrow on purpose. The intended degradation is "the table isn't reachable"
272
+ # (fresh install, mid-migration). A bare ``except Exception`` would also
273
+ # swallow a genuine bug in this resolve and report it as "no workspace
274
+ # timezone set", which is indistinguishable from a correct empty answer.
275
+ logger.warning("Workspace timezone unreadable — falling back", exc_info=True)
276
+ return ""
277
+ return str(stored or "").strip()
278
+
279
+
280
+ # Which tier of the quiet-hours timezone chain supplied the resolved zone. Returned
281
+ # alongside the zone by :func:`resolve_quiet_hours_timezone` and surfaced read-only on
282
+ # the per-project notification-preference endpoint, so "why was my 20:00 email held
283
+ # back?" has a server-side answer instead of requiring the client to re-implement a
284
+ # four-tier chain across three models (#3377).
285
+ QUIET_HOURS_TZ_SOURCE_PROJECT = "project"
286
+ QUIET_HOURS_TZ_SOURCE_WORKSPACE = "workspace"
287
+ QUIET_HOURS_TZ_SOURCE_SERVER = "server"
288
+ QUIET_HOURS_TZ_SOURCE_FALLBACK = "fallback"
289
+
290
+
291
+ def resolve_quiet_hours_timezone(project: Any, *, workspace: Any = None) -> tuple[ZoneInfo, str]:
292
+ """Resolve the tz a project's quiet-hours window is interpreted in, and its tier.
293
+
294
+ Quiet hours are a *project* policy, not a per-viewer preference, so the window
295
+ anchors to the project's IANA timezone (``Project.timezone``, #520), falling back
296
+ to the workspace default (``Workspace.timezone``, #3377), then to the server's
297
+ ``settings.TIME_ZONE``, then to UTC.
298
+
299
+ Unparseable tz data at any tier **walks to the next tier** rather than jumping
300
+ straight to UTC (the pre-#3377 behavior): a workspace whose admin saved a bad zone
301
+ should still get the server default, not silently lose an hour boundary. Nothing
302
+ here raises — this sits inside a dispatch path, and a bad string must not take a
303
+ fan-out down.
304
+
305
+ This function is the **only** consumer of ``Workspace.timezone`` — see the comment
306
+ on that field. It is deliberately not a *display* timezone: an instant rendered to
307
+ a person is re-clocked from that person's own ``profiles.Profile.timezone``
308
+ (ADR-0410). Quiet hours cannot use that value because the window is one stored
309
+ range per project preference row, not a per-viewer rendering.
310
+
311
+ The workspace tier is read lazily — a project that sets its own usable timezone
312
+ never touches the workspace table. Pass ``workspace`` from a fan-out that has
313
+ already loaded the singleton so the resolve stays at one query per fan-out rather
314
+ than one per recipient.
315
+
316
+ Args:
317
+ project: The project whose quiet-hours window is being interpreted. May be
318
+ ``None`` (a deleted project mid-fan-out) — the chain simply starts lower.
319
+ workspace: Optional pre-loaded ``Workspace`` singleton.
320
+
321
+ Returns:
322
+ ``(zone, source)`` where ``source`` is one of ``QUIET_HOURS_TZ_SOURCE_*``.
323
+ """
324
+ project_name = (getattr(project, "timezone", "") or "").strip()
325
+ if project_name:
326
+ try:
327
+ return ZoneInfo(project_name), QUIET_HOURS_TZ_SOURCE_PROJECT
328
+ except (ZoneInfoNotFoundError, ValueError, OSError):
329
+ # Log every skipped tier: relocating someone's quiet-hours window to a
330
+ # different wall clock with no signal anywhere is the failure mode that
331
+ # is impossible to debug from the outside. Project.timezone predates any
332
+ # validator, so stored rows can still be unparseable.
333
+ logger.warning("Unparseable Project.timezone %r — trying the workspace", project_name)
334
+ workspace_name = _workspace_timezone_name(workspace)
335
+ if workspace_name:
336
+ try:
337
+ return ZoneInfo(workspace_name), QUIET_HOURS_TZ_SOURCE_WORKSPACE
338
+ except (ZoneInfoNotFoundError, ValueError, OSError):
339
+ logger.warning(
340
+ "Unparseable Workspace.timezone %r — trying the server default", workspace_name
341
+ )
342
+ server_name = (settings.TIME_ZONE or "").strip()
343
+ if server_name:
344
+ try:
345
+ return ZoneInfo(server_name), QUIET_HOURS_TZ_SOURCE_SERVER
346
+ except (ZoneInfoNotFoundError, ValueError, OSError):
347
+ logger.warning("Unparseable settings.TIME_ZONE %r — falling back to UTC", server_name)
348
+ return ZoneInfo("UTC"), QUIET_HOURS_TZ_SOURCE_FALLBACK
349
+
350
+
351
+ def _project_timezone(project: Any, *, workspace: Any = None) -> ZoneInfo:
352
+ """The zone half of :func:`resolve_quiet_hours_timezone` — see it for the chain."""
353
+ return resolve_quiet_hours_timezone(project, workspace=workspace)[0]
354
+
355
+
356
+ def _in_quiet_window(now_local: datetime.time, start: datetime.time, end: datetime.time) -> bool:
357
+ """True if ``now_local`` falls in the half-open ``[start, end)`` window.
358
+
359
+ Handles the wrap-past-midnight case (e.g. 22:00–07:00, where ``start > end``).
360
+ A zero-width window (``start == end``) means "no quiet hours".
361
+ """
362
+ if start == end:
363
+ return False
364
+ if start < end:
365
+ return start <= now_local < end
366
+ return now_local >= start or now_local < end
367
+
368
+
369
+ def _matrix_cell(matrix: Any, event_type: str, channel: str) -> bool:
370
+ """Effective on/off for one ``(event_type, channel)`` cell.
371
+
372
+ Falls through to ``PROJECT_NOTIFICATION_DEFAULT_MATRIX`` whenever the stored
373
+ matrix omits the event type or channel, so a stale row that predates a newly
374
+ added event still routes correctly. Unknown keys resolve to ``False`` — a
375
+ safe default that also neutralizes any legacy garbage that escaped key
376
+ validation (#675).
377
+ """
378
+ if isinstance(matrix, dict):
379
+ row = matrix.get(event_type)
380
+ if isinstance(row, dict):
381
+ cell = row.get(channel)
382
+ if isinstance(cell, bool):
383
+ return cell
384
+ default_row = PROJECT_NOTIFICATION_DEFAULT_MATRIX.get(event_type, {})
385
+ return bool(default_row.get(channel, False))
386
+
387
+
388
+ def _preference_allows(
389
+ pref: ProjectNotificationPreference,
390
+ *,
391
+ event_type: str,
392
+ channel: str,
393
+ now: datetime.datetime,
394
+ tz: ZoneInfo,
395
+ ) -> bool:
396
+ """Evaluate the delivery gate against an already-loaded preference row.
397
+
398
+ Pure (no DB access) so a fan-out can batch-load every recipient's row in one
399
+ query and evaluate each without an N+1 or a write-amplifying get_or_create.
400
+ An unsaved ``ProjectNotificationPreference()`` carries the model defaults, so
401
+ callers can pass a fresh instance for a recipient who has no row yet.
402
+ """
403
+ # Per-project kill-switch (#589) overrides the matrix entirely.
404
+ if pref.paused:
405
+ return False
406
+ if not _matrix_cell(pref.matrix, event_type, channel):
407
+ return False
408
+ if channel not in _QUIET_HOURS_EXEMPT_CHANNELS and pref.quiet_hours_enabled:
409
+ now_local = now.astimezone(tz).time()
410
+ if _in_quiet_window(now_local, pref.quiet_hours_from, pref.quiet_hours_until):
411
+ return False
412
+ return True
413
+
414
+
415
+ def should_deliver(
416
+ user: Any,
417
+ project: Any,
418
+ event_type: str,
419
+ channel: str,
420
+ *,
421
+ now: datetime.datetime | None = None,
422
+ workspace: Any = None,
423
+ ) -> bool:
424
+ """Whether a project-scoped notification should be delivered to ``user``.
425
+
426
+ The routing source of truth for project notifications (#674). Loads — and
427
+ lazily creates — the user's :class:`ProjectNotificationPreference` row, then
428
+ applies, in order: the per-project pause kill-switch (#589), the matrix cell
429
+ (defaults overlaid for missing keys), and the quiet-hours window (transient
430
+ channels only — the in-app inbox is always recorded). ``now`` defaults to the
431
+ current time and is injectable for testing.
432
+
433
+ Call before sending at any project-scoped dispatch site. For a fan-out to
434
+ many recipients, batch-load rows and reuse :func:`_preference_allows` rather
435
+ than calling this once per recipient. If you must loop this instead, pass
436
+ ``workspace`` — otherwise every iteration re-reads the workspace singleton on
437
+ top of the per-recipient ``get_or_create``, turning one query per recipient
438
+ into two (#3377).
439
+ """
440
+ if now is None:
441
+ now = timezone.now()
442
+ pref, _ = ProjectNotificationPreference.objects.get_or_create(project=project, user=user)
443
+ return _preference_allows(
444
+ pref,
445
+ event_type=event_type,
446
+ channel=channel,
447
+ now=now,
448
+ tz=_project_timezone(project, workspace=workspace),
449
+ ) and _dnd_allows(user, event_type, channel)
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # Account-wide Do-Not-Disturb gate (#1707, ADR-0292)
454
+ # ---------------------------------------------------------------------------
455
+
456
+
457
+ def get_or_create_notification_settings(user: Any) -> UserNotificationSettings:
458
+ """Return the user's account-wide notification settings, creating the row on
459
+ first access.
460
+
461
+ Mirrors :func:`get_or_create_default_preferences` — no backfill on user
462
+ create; the absence of a row reads as DND off. This is the authoritative
463
+ accessor used by the read/write endpoint and the single-user delivery gate.
464
+ ``user`` is typed ``Any`` to match the request-context accessors in this
465
+ module (``should_deliver`` / ``_get_or_create_pref``): the ``IsAuthenticated``
466
+ gate on the view guarantees a real ``User`` at runtime.
467
+ """
468
+ settings_row, _ = UserNotificationSettings.objects.get_or_create(user=user)
469
+ return settings_row
470
+
471
+
472
+ def load_dnd_user_ids(user_ids: Any) -> set[Any]:
473
+ """Return the subset of ``user_ids`` that currently have DND enabled.
474
+
475
+ One query for a whole fan-out (no N+1, no write-amplifying get_or_create) —
476
+ a user with no settings row is simply absent from the result, which reads as
477
+ DND off. Pair with :func:`_dnd_silences` in a batch dispatch loop.
478
+ """
479
+ return set(
480
+ UserNotificationSettings.objects.filter(user_id__in=user_ids, dnd_enabled=True).values_list(
481
+ "user_id", flat=True
482
+ )
483
+ )
484
+
485
+
486
+ def _dnd_silences(event_type: str, channel: str, *, dnd_enabled: bool) -> bool:
487
+ """Whether account-wide DND holds back this ``(event_type, channel)`` delivery.
488
+
489
+ Pure — takes the already-resolved DND flag so a batch fan-out can load the DND
490
+ set once (see :func:`load_dnd_user_ids`) and evaluate each recipient without a
491
+ query. DND silences ONLY transient channels (email/push) and ONLY for events
492
+ outside :data:`DND_BYPASS_EVENTS`; the in-app inbox row (``_DND_EXEMPT_CHANNELS``)
493
+ and the four bypass events always deliver — the safety contract is one
494
+ frozenset checked at one gate, so it cannot drift or swallow a blocker.
495
+ """
496
+ if not dnd_enabled:
497
+ return False
498
+ if channel in _DND_EXEMPT_CHANNELS:
499
+ return False
500
+ # A non-exempt (transient) channel under DND: silence it unless the event is
501
+ # on the always-through safety list.
502
+ return event_type not in DND_BYPASS_EVENTS
503
+
504
+
505
+ def _dnd_allows(user: Any, event_type: str, channel: str) -> bool:
506
+ """Single-user DND gate for :func:`should_deliver`.
507
+
508
+ Resolves (and lazily creates) the caller's settings row, then delegates to the
509
+ pure :func:`_dnd_silences` predicate. For a fan-out to many recipients, use
510
+ :func:`load_dnd_user_ids` + :func:`_dnd_silences` instead of calling this per
511
+ recipient.
512
+ """
513
+ settings_row = get_or_create_notification_settings(user)
514
+ return not _dnd_silences(event_type, channel, dnd_enabled=settings_row.dnd_enabled)
515
+
516
+
517
+ # ---------------------------------------------------------------------------
518
+ # Mention fan-out
519
+ # ---------------------------------------------------------------------------
520
+
521
+
522
+ class MentionParseResult(NamedTuple):
523
+ """Result of resolving parsed mentions against project membership."""
524
+
525
+ user_targets: list[UserType]
526
+ group_targets: list[tuple[str, list[UserType]]] # (group_key, members)
527
+ skipped_users: list[str] # usernames that didn't resolve / not project members
528
+ skipped_groups: list[str] # group keys that were unknown or too large
529
+ # Non-account external stakeholders reached by a resolved @program-stakeholders
530
+ # mention (#1658, ADR-0264). Additive and SEPARATE from group_targets — these
531
+ # rows have no User, so create_mention_notifications never writes Mention /
532
+ # Notification rows for them and no email is sent (delivery deferred to #1675).
533
+ # Default is a shared empty list, only ever read, never mutated in place — the
534
+ # NamedTuple is immutable and every producer builds a fresh list (RUF012 flags
535
+ # the mutable default, but the shared-mutation footgun does not apply here).
536
+ external_targets: list[ExternalStakeholder] = [] # noqa: RUF012
537
+
538
+
539
+ def _load_project_udg_members(
540
+ project_id: uuid.UUID | str, names_lower: list[str]
541
+ ) -> dict[str, list[UserType]]:
542
+ """Project-scoped user-defined mention groups (ADR-0212), keyed by lowercased name.
543
+
544
+ Members are snapshotted at write time and filtered to those still on the project.
545
+ """
546
+ from trueppm_api.apps.access.models import ProjectMembership, UserDefinedMentionGroup
547
+
548
+ active_member_ids = set(
549
+ ProjectMembership.objects.filter(project_id=project_id, is_deleted=False).values_list(
550
+ "user_id", flat=True
551
+ )
552
+ )
553
+ resolved: dict[str, list[UserType]] = {}
554
+ udg_groups = (
555
+ UserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
556
+ .filter(project_id=project_id, name_lower__in=names_lower, is_deleted=False)
557
+ .prefetch_related("members")
558
+ )
559
+ for group in udg_groups:
560
+ resolved[group.name.lower()] = [
561
+ member for member in group.members.all() if member.pk in active_member_ids
562
+ ]
563
+ return resolved
564
+
565
+
566
+ def _load_program_udg_members(
567
+ project_id: uuid.UUID | str, names_lower: list[str]
568
+ ) -> dict[str, list[UserType]]:
569
+ """Program-scoped user-defined mention groups (ADR-0248, #516), keyed by lowercased name.
570
+
571
+ Returns empty for a standalone project (no program). Members are filtered to those
572
+ still on any project in the program.
573
+ """
574
+ from trueppm_api.apps.access.models import (
575
+ ProgramUserDefinedMentionGroup,
576
+ ProjectMembership,
577
+ )
578
+ from trueppm_api.apps.projects.models import Project
579
+
580
+ program_id = Project.objects.filter(pk=project_id).values_list("program_id", flat=True).first()
581
+ if program_id is None:
582
+ return {}
583
+ program_member_ids = set(
584
+ ProjectMembership.objects.filter(
585
+ project__program_id=program_id,
586
+ project__is_deleted=False,
587
+ is_deleted=False,
588
+ ).values_list("user_id", flat=True)
589
+ )
590
+ resolved: dict[str, list[UserType]] = {}
591
+ pudg_groups = (
592
+ ProgramUserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
593
+ .filter(program_id=program_id, name_lower__in=names_lower, is_deleted=False)
594
+ .prefetch_related("members")
595
+ )
596
+ for pgroup in pudg_groups:
597
+ resolved[pgroup.name.lower()] = [
598
+ member for member in pgroup.members.all() if member.pk in program_member_ids
599
+ ]
600
+ return resolved
601
+
602
+
603
+ def _resolve_udg_groups_by_lower(
604
+ project_id: uuid.UUID | str, unresolved: list[str]
605
+ ) -> dict[str, list[UserType]]:
606
+ """Reinterpret non-member @names as user-defined groups (ADR-0212, ADR-0248).
607
+
608
+ Batch-resolved in one query set per scope (not per-token) to keep the synchronous
609
+ comment-create path off the N+1. Precedence is member → project group → program
610
+ group: a project group shadows a same-named program group, so only names still
611
+ unresolved after the project step fall through to the program step.
612
+ """
613
+ groups_by_lower = _load_project_udg_members(project_id, [name.lower() for name in unresolved])
614
+ still_unresolved = [name for name in unresolved if name.lower() not in groups_by_lower]
615
+ if still_unresolved:
616
+ program_groups = _load_program_udg_members(
617
+ project_id, [name.lower() for name in still_unresolved]
618
+ )
619
+ for key, members in program_groups.items():
620
+ groups_by_lower[key] = members
621
+ return groups_by_lower
622
+
623
+
624
+ def _resolve_user_mentions(
625
+ parsed: Sequence[ParsedMention], project_id: uuid.UUID | str
626
+ ) -> tuple[list[UserType], list[tuple[str, list[UserType]]], list[str]]:
627
+ """Resolve @user tokens to project members, user-defined groups, or skipped names.
628
+
629
+ A real project member always wins on an exact name collision; a non-member name is
630
+ promoted to a user-defined group target when one exists (see
631
+ :func:`_resolve_udg_groups_by_lower`), else recorded in ``skipped_users``.
632
+ """
633
+ from trueppm_api.apps.access.models import ProjectMembership
634
+
635
+ user_targets: list[UserType] = []
636
+ group_targets: list[tuple[str, list[UserType]]] = []
637
+ skipped_users: list[str] = []
638
+
639
+ requested_usernames = [m.value for m in parsed if m.kind == "user"]
640
+ if not requested_usernames:
641
+ return user_targets, group_targets, skipped_users
642
+
643
+ member_users = list(
644
+ User.objects.filter(
645
+ username__in=requested_usernames,
646
+ pk__in=ProjectMembership.objects.filter(
647
+ project_id=project_id, is_deleted=False
648
+ ).values_list("user_id", flat=True),
649
+ )
650
+ )
651
+ resolved_by_name = {u.username: u for u in member_users}
652
+ unresolved = [name for name in requested_usernames if name not in resolved_by_name]
653
+ groups_by_lower = _resolve_udg_groups_by_lower(project_id, unresolved) if unresolved else {}
654
+ for name in requested_usernames:
655
+ if name in resolved_by_name:
656
+ user_targets.append(resolved_by_name[name])
657
+ elif name.lower() in groups_by_lower:
658
+ group_targets.append((name.lower(), groups_by_lower[name.lower()]))
659
+ else:
660
+ skipped_users.append(name)
661
+ return user_targets, group_targets, skipped_users
662
+
663
+
664
+ def _resolve_group_mentions(
665
+ parsed: Sequence[ParsedMention],
666
+ project_id: uuid.UUID | str,
667
+ actor_role: int | None,
668
+ ) -> tuple[list[tuple[str, list[UserType]]], list[str]]:
669
+ """Resolve @group tokens via ``resolve_group_members``.
670
+
671
+ ``@all`` / ``@program-all`` are unbounded fan-outs and require actor_role >= ADMIN
672
+ (ADR-0075 locked constraint #2); unknown or oversized groups land in
673
+ ``skipped_groups`` with a structured marker.
674
+ """
675
+ from trueppm_api.apps.access.models import Role
676
+
677
+ group_targets: list[tuple[str, list[UserType]]] = []
678
+ skipped_groups: list[str] = []
679
+ for m in parsed:
680
+ if m.kind != "group":
681
+ continue
682
+ key = m.value
683
+ if key in ("all", "program-all") and (actor_role is None or actor_role < Role.ADMIN):
684
+ skipped_groups.append(key)
685
+ continue
686
+ try:
687
+ member_ids = resolve_group_members(project_id, key)
688
+ except (InvalidGroupKeyError, GroupTooLargeError):
689
+ skipped_groups.append(key)
690
+ continue
691
+ # resolve_group_members annotates its result list[UUID], but the `pk`
692
+ # lookup is typed Iterable[str|int] under django-stubs; stringify so the
693
+ # ids satisfy the lookup type (Django coerces them back to the User pk).
694
+ members = list(User.objects.filter(pk__in=[str(uid) for uid in member_ids]))
695
+ group_targets.append((key, members))
696
+ return group_targets, skipped_groups
697
+
698
+
699
+ def resolve_parsed_mentions(
700
+ parsed: Sequence[ParsedMention],
701
+ project_id: uuid.UUID | str,
702
+ *,
703
+ actor_role: int | None = None,
704
+ ) -> MentionParseResult:
705
+ """Turn parser output into validated, project-scoped resolution.
706
+
707
+ - User mentions are filtered to current project members; non-members go to
708
+ `skipped_users` so the caller can surface a structured 400.
709
+ - A `@name` that resolves to neither a member nor an auto-group key
710
+ is reinterpreted as a **user-defined group** (ADR-0212, #515):
711
+ the parser is pure and cannot know a name is a group, so this project-aware
712
+ step promotes it to a group target when a live
713
+ `UserDefinedMentionGroup` with that name exists in the project. A real
714
+ member always wins on an exact name collision.
715
+ - Group mentions go through `resolve_group_members`; `@all` and
716
+ `@program-all` require actor_role >= ADMIN (ADR-0075 locked constraint
717
+ #2). Oversized groups land in `skipped_groups` with a structured marker.
718
+ """
719
+ user_targets, user_group_targets, skipped_users = _resolve_user_mentions(parsed, project_id)
720
+ auto_group_targets, skipped_groups = _resolve_group_mentions(parsed, project_id, actor_role)
721
+ # User-token-resolved groups precede auto-groups, preserving the original
722
+ # single-list append order (user block first, then the @group loop).
723
+ group_targets = user_group_targets + auto_group_targets
724
+
725
+ # External stakeholders (#1658, ADR-0264) are the non-account arm of a resolved
726
+ # @program-stakeholders mention. Resolve them ONLY when that group actually
727
+ # resolved (it is present in group_targets — a standalone project skips it), and
728
+ # keep them on a distinct field: they have no User account, so they are never
729
+ # unioned into the User-keyed group_targets and never produce Notification rows.
730
+ external_targets: list[ExternalStakeholder] = []
731
+ if any(group_key == "program-stakeholders" for group_key, _ in group_targets):
732
+ from trueppm_api.apps.access.groups import resolve_external_stakeholders
733
+
734
+ external_targets = resolve_external_stakeholders(project_id)
735
+
736
+ return MentionParseResult(
737
+ user_targets=user_targets,
738
+ group_targets=group_targets,
739
+ skipped_users=skipped_users,
740
+ skipped_groups=skipped_groups,
741
+ external_targets=external_targets,
742
+ )
743
+
744
+
745
+ def _build_mention_rows(
746
+ *,
747
+ task_comment: TaskComment,
748
+ mentioner: UserType,
749
+ parsed_result: MentionParseResult,
750
+ project_id: uuid.UUID | str,
751
+ scope: MentionScope,
752
+ ) -> tuple[list[Mention], dict[str, list[UserType]]]:
753
+ """Build the unsaved Mention rows and the per-group member index.
754
+
755
+ One Mention row per direct user, then one per group (preserving "@scrum-team was
756
+ mentioned" semantics); the returned index maps each group key to its members for
757
+ the later per-member fan-out. Order matches the original append sequence: direct
758
+ user mentions first, group mentions second.
759
+ """
760
+ mentions: list[Mention] = []
761
+ for user in parsed_result.user_targets:
762
+ mentions.append(
763
+ Mention(
764
+ mentioner=mentioner,
765
+ mentioned_user=user,
766
+ task_comment=task_comment,
767
+ project_id=project_id,
768
+ scope=scope,
769
+ )
770
+ )
771
+ group_member_index: dict[str, list[UserType]] = {}
772
+ for group_key, members in parsed_result.group_targets:
773
+ mentions.append(
774
+ Mention(
775
+ mentioner=mentioner,
776
+ mentioned_group_key=group_key,
777
+ task_comment=task_comment,
778
+ project_id=project_id,
779
+ scope=scope,
780
+ )
781
+ )
782
+ group_member_index[group_key] = members
783
+ return mentions, group_member_index
784
+
785
+
786
+ def _load_udg_email_settings(
787
+ project_id: uuid.UUID | str, mentioned_group_keys: list[str]
788
+ ) -> tuple[dict[str, bool], set[tuple[int | str, str]]]:
789
+ """Load per-group email defaults and mute sets for mentioned user-defined groups.
790
+
791
+ User-defined group routing (ADR-0212 §5). Auto-group keys (@admins, @scrum-team, …)
792
+ simply don't match and keep their existing global-toggle behavior. The returned
793
+ ``udg_email_default`` maps a lowercased group name to its manager-set email default;
794
+ ``udg_mutes`` is the set of ``(user_id, group_name)`` pairs whose owner muted that
795
+ group. Keys in ``mentioned_group_keys`` are lowercased (see resolve_parsed_mentions),
796
+ so match on ``Lower(name)`` — group names are case-insensitive mention keys even
797
+ though the display name preserves the manager's casing.
798
+
799
+ Program-scoped user-defined groups (ADR-0248, #516) load first so a same-named
800
+ project group overwrites the email default (project wins, matching the resolution
801
+ precedence). Mutes union across scopes — a member who muted either same-named group
802
+ is defensively suppressed (a negligible, documented name-collision case).
803
+ """
804
+ udg_email_default: dict[str, bool] = {}
805
+ udg_mutes: set[tuple[int | str, str]] = set()
806
+ if not mentioned_group_keys:
807
+ return udg_email_default, udg_mutes
808
+
809
+ from trueppm_api.apps.access.models import (
810
+ ProgramUserDefinedMentionGroup,
811
+ UserDefinedMentionGroup,
812
+ )
813
+ from trueppm_api.apps.projects.models import Project
814
+
815
+ program_id = Project.objects.filter(pk=project_id).values_list("program_id", flat=True).first()
816
+ if program_id is not None:
817
+ pudg_groups = (
818
+ ProgramUserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
819
+ .filter(program_id=program_id, name_lower__in=mentioned_group_keys, is_deleted=False)
820
+ .prefetch_related("muted_by")
821
+ )
822
+ for pgroup in pudg_groups:
823
+ key = pgroup.name.lower()
824
+ udg_email_default[key] = pgroup.email_default_on
825
+ for muter in pgroup.muted_by.all():
826
+ udg_mutes.add((muter.pk, key))
827
+ udg_groups = (
828
+ UserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
829
+ .filter(project_id=project_id, name_lower__in=mentioned_group_keys, is_deleted=False)
830
+ .prefetch_related("muted_by")
831
+ )
832
+ for group in udg_groups:
833
+ key = group.name.lower()
834
+ udg_email_default[key] = group.email_default_on
835
+ for muter in group.muted_by.all():
836
+ udg_mutes.add((muter.pk, key))
837
+ return udg_email_default, udg_mutes
838
+
839
+
840
+ def _add_group_recipients(
841
+ recipients: dict[int | str, Mention],
842
+ group_mention: Mention,
843
+ members: list[UserType],
844
+ udg_mutes: set[tuple[int | str, str]],
845
+ mentioner_pk: int | str,
846
+ ) -> None:
847
+ """Fan a group mention out to its members, mutating ``recipients`` in place.
848
+
849
+ Skips the mentioner (don't ping yourself) and any member who muted THIS
850
+ user-defined group (ADR-0212); ``setdefault`` means a member already claimed by a
851
+ direct @mention or an earlier un-muted group is left untouched.
852
+ """
853
+ group_key = group_mention.mentioned_group_key
854
+ for member in members:
855
+ if member.pk == mentioner_pk:
856
+ continue
857
+ if (member.pk, group_key) in udg_mutes:
858
+ continue
859
+ recipients.setdefault(member.pk, group_mention)
860
+
861
+
862
+ def _resolve_mention_recipients(
863
+ mentions: list[Mention],
864
+ group_member_index: dict[str, list[UserType]],
865
+ udg_mutes: set[tuple[int | str, str]],
866
+ mentioner: UserType,
867
+ ) -> dict[int | str, Mention]:
868
+ """Dedup mentions to one source Mention per recipient.
869
+
870
+ A user mentioned directly AND via a group gets one notification; the direct-mention
871
+ row wins (overwriting a prior group-source entry) so the UI shows "@you". Per-group
872
+ mutes (ADR-0212) drop a member for the muting group only — they may still be added
873
+ by a different, un-muted group. The mentioner is never notified.
874
+ """
875
+ recipients: dict[int | str, Mention] = {}
876
+ for mention in mentions:
877
+ if mention.mentioned_user_id:
878
+ recipients[mention.mentioned_user_id] = mention
879
+ for mention in mentions:
880
+ if mention.mentioned_group_key:
881
+ _add_group_recipients(
882
+ recipients,
883
+ mention,
884
+ group_member_index[mention.mentioned_group_key],
885
+ udg_mutes,
886
+ mentioner.pk,
887
+ )
888
+ # Drop the mentioner from direct mentions too (don't ping yourself)
889
+ recipients.pop(mentioner.pk, None)
890
+ return recipients
891
+
892
+
893
+ class _MentionRoutingContext(NamedTuple):
894
+ """Batch-loaded, per-fan-out routing state for mention notification delivery."""
895
+
896
+ project_tz: ZoneInfo
897
+ project_prefs: dict[int | str, ProjectNotificationPreference]
898
+ global_email: dict[int | str, dict[str, bool]]
899
+ source_project_member_ids: set[Any]
900
+ dnd_user_ids: set[Any]
901
+
902
+
903
+ def _load_mention_routing_context(
904
+ project_id: uuid.UUID | str, recipient_ids: list[int | str]
905
+ ) -> _MentionRoutingContext:
906
+ """Batch-load the per-recipient routing state in a fixed number of queries.
907
+
908
+ - Project timezone + each recipient's ProjectNotificationPreference row (#674):
909
+ evaluated per recipient without an N+1; a recipient with no row evaluates against
910
+ an unsaved instance carrying the model defaults (subsumes the #589 pause check).
911
+ - Global per-user mention email preference (#311): a mention emails only when BOTH
912
+ the project matrix and this global toggle allow it.
913
+ - Source-project membership set (cross-project email read boundary, ADR-0248 §5 /
914
+ ADR-0240 §5): the email render path embeds the raw comment body, so email is
915
+ suppressed for any recipient not a current member of the source project; they
916
+ still get the redacted in-app row.
917
+ - Account-wide DND set (#1707): a mention is not a bypass event, so DND silences its
918
+ email while the durable in-app inbox row still lands.
919
+ """
920
+ from trueppm_api.apps.access.models import ProjectMembership
921
+ from trueppm_api.apps.projects.models import Project
922
+
923
+ project = Project.objects.filter(pk=project_id).only("timezone").first()
924
+ project_tz = _project_timezone(project)
925
+ project_prefs: dict[int | str, ProjectNotificationPreference] = {
926
+ proj_pref.user_id: proj_pref
927
+ for proj_pref in ProjectNotificationPreference.objects.filter(
928
+ project_id=project_id, user_id__in=recipient_ids
929
+ )
930
+ }
931
+ global_email: dict[int | str, dict[str, bool]] = {}
932
+ for global_pref in NotificationPreference.objects.filter(
933
+ user_id__in=recipient_ids, channel=NotificationChannel.EMAIL
934
+ ):
935
+ global_email.setdefault(global_pref.user_id, {})[global_pref.event_type] = (
936
+ global_pref.enabled
937
+ )
938
+ source_project_member_ids = set(
939
+ ProjectMembership.objects.filter(
940
+ project_id=project_id, is_deleted=False, user_id__in=recipient_ids
941
+ ).values_list("user_id", flat=True)
942
+ )
943
+ dnd_user_ids = load_dnd_user_ids(recipient_ids)
944
+ return _MentionRoutingContext(
945
+ project_tz=project_tz,
946
+ project_prefs=project_prefs,
947
+ global_email=global_email,
948
+ source_project_member_ids=source_project_member_ids,
949
+ dnd_user_ids=dnd_user_ids,
950
+ )
951
+
952
+
953
+ def _build_mention_notification(
954
+ *,
955
+ user_id: int | str,
956
+ source_mention: Mention,
957
+ context: _MentionRoutingContext,
958
+ udg_email_default: dict[str, bool],
959
+ project_id: uuid.UUID | str,
960
+ now: datetime.datetime,
961
+ ) -> Notification | None:
962
+ """Build one recipient's Notification row, or None if the in-app gate blocks it.
963
+
964
+ The in-app inbox row is matrix-gated only; quiet hours never drop the durable record
965
+ (see _QUIET_HOURS_EXEMPT_CHANNELS). The email gate layers the project matrix
966
+ (comment_mention/email) + quiet hours with the per-source opt-in:
967
+ - user-defined group (ADR-0212 §5): the group manager's per-group email default is
968
+ the self-contained opt-in — a mute already removed the recipient from the in-app
969
+ gate above, so reaching here means un-muted; email follows the group default.
970
+ - direct mention / auto-group: the existing global per-user toggle (#311) —
971
+ MENTION_INDIVIDUAL / MENTION_GROUP — governs, default OFF.
972
+ then the cross-project read boundary and account-wide DND (#1707).
973
+ """
974
+ event_type_project = ProjectNotificationEventType.COMMENT_MENTION.value
975
+ pref = context.project_prefs.get(user_id) or ProjectNotificationPreference()
976
+ if not _preference_allows(
977
+ pref,
978
+ event_type=event_type_project,
979
+ channel=ProjectNotificationChannel.IN_APP.value,
980
+ now=now,
981
+ tz=context.project_tz,
982
+ ):
983
+ return None
984
+ global_event = (
985
+ NotificationEventType.MENTION_INDIVIDUAL.value
986
+ if source_mention.mentioned_user_id
987
+ else NotificationEventType.MENTION_GROUP.value
988
+ )
989
+ source_group_key = source_mention.mentioned_group_key
990
+ if source_group_key and source_group_key in udg_email_default:
991
+ per_user_opt_in = udg_email_default[source_group_key]
992
+ else:
993
+ per_user_opt_in = context.global_email.get(user_id, {}).get(global_event, False)
994
+ email_pending = (
995
+ _preference_allows(
996
+ pref,
997
+ event_type=event_type_project,
998
+ channel=ProjectNotificationChannel.EMAIL.value,
999
+ now=now,
1000
+ tz=context.project_tz,
1001
+ )
1002
+ and per_user_opt_in
1003
+ and user_id in context.source_project_member_ids
1004
+ and not _dnd_silences(
1005
+ global_event,
1006
+ ProjectNotificationChannel.EMAIL.value,
1007
+ dnd_enabled=user_id in context.dnd_user_ids,
1008
+ )
1009
+ )
1010
+ return Notification(
1011
+ recipient_id=user_id,
1012
+ mention=source_mention,
1013
+ project_id=project_id,
1014
+ email_pending=email_pending,
1015
+ )
1016
+
1017
+
1018
+ def create_mention_notifications(
1019
+ *,
1020
+ task_comment: TaskComment,
1021
+ mentioner: UserType,
1022
+ parsed_result: MentionParseResult,
1023
+ project_id: uuid.UUID | str,
1024
+ scope: MentionScope = MentionScope.PROJECT_VISIBLE,
1025
+ now: datetime.datetime | None = None,
1026
+ ) -> int:
1027
+ """Create Mention + Notification rows from a parsed comment.
1028
+
1029
+ Returns the number of Notification rows created. Direct mentions and
1030
+ group members are deduplicated per-recipient (one user mentioned both
1031
+ directly and via @scrum-team gets one Notification, not two).
1032
+
1033
+ Each recipient is gated by their ProjectNotificationPreference for the
1034
+ `comment_mention` event (#674): the in-app row is created only if the matrix
1035
+ `in_app` cell is on, and `email_pending` is set only if the matrix `email`
1036
+ cell is on AND outside quiet hours AND the user's global mention email
1037
+ preference is on. `now` is injectable so quiet-hours behavior is testable.
1038
+
1039
+ All writes happen in the caller's transaction — the response can include
1040
+ the count immediately. Email delivery is best-effort via `email_pending`
1041
+ + the `drain_notification_emails` task (not this function), nudged on commit.
1042
+ """
1043
+ if now is None:
1044
+ now = timezone.now()
1045
+
1046
+ mentions, group_member_index = _build_mention_rows(
1047
+ task_comment=task_comment,
1048
+ mentioner=mentioner,
1049
+ parsed_result=parsed_result,
1050
+ project_id=project_id,
1051
+ scope=scope,
1052
+ )
1053
+ udg_email_default, udg_mutes = _load_udg_email_settings(
1054
+ project_id, list(group_member_index.keys())
1055
+ )
1056
+
1057
+ if not mentions:
1058
+ return 0
1059
+
1060
+ Mention.objects.bulk_create(mentions)
1061
+
1062
+ recipients = _resolve_mention_recipients(mentions, group_member_index, udg_mutes, mentioner)
1063
+ if not recipients:
1064
+ return 0
1065
+
1066
+ context = _load_mention_routing_context(project_id, list(recipients.keys()))
1067
+
1068
+ notifications: list[Notification] = []
1069
+ for user_id, source_mention in recipients.items():
1070
+ notification = _build_mention_notification(
1071
+ user_id=user_id,
1072
+ source_mention=source_mention,
1073
+ context=context,
1074
+ udg_email_default=udg_email_default,
1075
+ project_id=project_id,
1076
+ now=now,
1077
+ )
1078
+ if notification is not None:
1079
+ notifications.append(notification)
1080
+ if not notifications:
1081
+ return 0
1082
+ _bulk_create_notifications(notifications)
1083
+ return len(notifications)
1084
+
1085
+
1086
+ def _build_event_notification_gate(
1087
+ event_type: str, unique_ids: set[int | str]
1088
+ ) -> tuple[Callable[[int | str, str], bool], set[Any]]:
1089
+ """Per-recipient (event_type, channel) gating shared by every
1090
+ ``create_event_notifications*`` fan-out: one ``NotificationPreference``
1091
+ query plus one DND lookup over the unique recipient set, regardless of how
1092
+ many rows or projects the caller is about to write.
1093
+
1094
+ Returns the ``_allows(user_id, channel)`` predicate (stored preference,
1095
+ falling back to ``DEFAULT_PREFERENCES``) and the set of recipients with
1096
+ account-wide DND enabled (#1707), for the caller to pass to
1097
+ ``_dnd_silences`` per row.
1098
+ """
1099
+ defaults = {(et, ch): enabled for et, ch, enabled in DEFAULT_PREFERENCES}
1100
+ stored: dict[int | str, dict[str, bool]] = {}
1101
+ for pref in NotificationPreference.objects.filter(
1102
+ user_id__in=unique_ids, event_type=event_type
1103
+ ):
1104
+ stored.setdefault(pref.user_id, {})[pref.channel] = pref.enabled
1105
+
1106
+ def _allows(user_id: int | str, channel: str) -> bool:
1107
+ per_user = stored.get(user_id, {})
1108
+ if channel in per_user:
1109
+ return per_user[channel]
1110
+ return defaults.get((event_type, channel), False)
1111
+
1112
+ # Account-wide DND (#1707) silences email for non-bypass events. Loaded once
1113
+ # over the recipient set; the in-app inbox row below is never gated by DND.
1114
+ dnd_user_ids = load_dnd_user_ids(unique_ids)
1115
+ return _allows, dnd_user_ids
1116
+
1117
+
1118
+ def create_event_notifications(
1119
+ *,
1120
+ event_type: str,
1121
+ recipient_ids: Sequence[int | str | None],
1122
+ subject: str,
1123
+ body: str,
1124
+ project_id: uuid.UUID | str | None,
1125
+ task_id: uuid.UUID | str | None = None,
1126
+ ) -> int:
1127
+ """Create event-sourced Notification rows for an own-task event (#639).
1128
+
1129
+ Gated by each recipient's **global** ``NotificationPreference`` for
1130
+ ``(event_type, channel)`` — the per-user toggles on the User → Settings →
1131
+ Notifications page — falling back to ``DEFAULT_PREFERENCES`` for users who
1132
+ have never visited that page (no stored rows). Mirrors the mention path's
1133
+ coupling (ADR-0085 §4): the in-app inbox row is the durable record, created
1134
+ only when ``in_app`` is enabled; ``email_pending`` is set additionally when
1135
+ ``email`` is enabled (default OFF — Priya's VoC blocker). A recipient who has
1136
+ turned ``in_app`` off for this event opts out of both channels.
1137
+
1138
+ The ``subject``/``body`` are rendered by the caller at dispatch time and
1139
+ frozen onto each row, so the drain can send the email without re-deriving it
1140
+ from a (possibly later-mutated or deleted) source object.
1141
+
1142
+ Args:
1143
+ event_type: A ``NotificationEventType`` value (e.g. ``"task.assigned"``).
1144
+ recipient_ids: User PKs to notify; ``None`` and duplicates are dropped,
1145
+ so callers may pass the actor without special-casing.
1146
+ subject: Pre-rendered email subject line.
1147
+ body: Pre-rendered plain-text email body.
1148
+ project_id: The project the event occurred on (scopes the inbox row).
1149
+ ``None`` only for account-scoped rows that genuinely have no owning
1150
+ project — the ADR-0663 scheduled digests, which span the recipient's
1151
+ whole membership set. Every event- and mention-sourced caller passes a
1152
+ real project.
1153
+ task_id: Optional deep-link target — the task/milestone the inbox row
1154
+ should link to (#497/#861). ``None`` for events with no task anchor.
1155
+
1156
+ Returns:
1157
+ The number of Notification rows created.
1158
+ """
1159
+ unique_ids = {rid for rid in recipient_ids if rid is not None}
1160
+ if not unique_ids:
1161
+ return 0
1162
+
1163
+ _allows, dnd_user_ids = _build_event_notification_gate(event_type, unique_ids)
1164
+
1165
+ notifications: list[Notification] = []
1166
+ for user_id in unique_ids:
1167
+ if not _allows(user_id, NotificationChannel.IN_APP.value):
1168
+ continue
1169
+ notifications.append(
1170
+ Notification(
1171
+ recipient_id=user_id,
1172
+ event_type=event_type,
1173
+ subject=subject,
1174
+ body=body,
1175
+ project_id=project_id,
1176
+ task_id=task_id,
1177
+ email_pending=_allows(user_id, NotificationChannel.EMAIL.value)
1178
+ and not _dnd_silences(
1179
+ event_type,
1180
+ NotificationChannel.EMAIL.value,
1181
+ dnd_enabled=user_id in dnd_user_ids,
1182
+ ),
1183
+ )
1184
+ )
1185
+ if not notifications:
1186
+ return 0
1187
+ _bulk_create_notifications(notifications)
1188
+ return len(notifications)
1189
+
1190
+
1191
+ #: Rows per INSERT for the event fan-outs below.
1192
+ #:
1193
+ #: ``Notification`` carries 17 concrete columns, so a single unbounded
1194
+ #: ``bulk_create`` crosses Postgres's 65535 bind-parameter ceiling at ~3,850 rows and
1195
+ #: raises. That was unreachable while every fan-out was scoped to one project's
1196
+ #: recipients; batching a program-matrix apply across up to ``MAX_BULK_TARGETS``
1197
+ #: projects into one call makes it reachable, and the emit's ``except Exception``
1198
+ #: would swallow the failure whole and drop every notice silently (#3335).
1199
+ NOTIFICATION_BULK_BATCH_SIZE = 500
1200
+
1201
+
1202
+ def create_event_notifications_batch(
1203
+ *,
1204
+ event_type: str,
1205
+ project_id: uuid.UUID | str,
1206
+ rows: Sequence[tuple[int | str | None, str, str, uuid.UUID | str | None]],
1207
+ ) -> int:
1208
+ """Create per-recipient event Notification rows for a batch of
1209
+ ``(recipient_id, subject, body, task_id)`` tuples in ONE preference lookup
1210
+ and one ``bulk_create``.
1211
+
1212
+ Same gating as ``create_event_notifications`` — each row is written only when
1213
+ the recipient's ``NotificationPreference`` (falling back to
1214
+ ``DEFAULT_PREFERENCES``) enables ``in_app`` for ``event_type``, and
1215
+ ``email_pending`` is set when ``email`` is enabled. Use this instead of
1216
+ calling ``create_event_notifications`` in a loop when one event fans out to
1217
+ many recipients with a *different* per-row subject/body/deep-link (e.g.
1218
+ sprint-close carryover, ADR-0232 #1470): a loop would issue one preference
1219
+ query and one insert *per row* — and repeat the identical query for a
1220
+ recipient who appears more than once — whereas this collapses to a single
1221
+ ``NotificationPreference`` query over the unique recipient set plus one
1222
+ ``bulk_create``.
1223
+
1224
+ Args:
1225
+ event_type: A ``NotificationEventType`` value.
1226
+ project_id: The project the event occurred on (scopes every row).
1227
+ rows: ``(recipient_id, subject, body, task_id)`` tuples. ``None``
1228
+ recipients are skipped; ``task_id`` may be ``None`` for a row with no
1229
+ single task anchor (e.g. a multi-task summary).
1230
+
1231
+ Every row lands on the same ``project_id``. When one dispatch spans several
1232
+ projects, call :func:`create_event_notifications_multi_project` — which this
1233
+ delegates to — rather than looping this one, or the preference scan, the DND
1234
+ scan and the insert all repeat per project.
1235
+
1236
+ Returns:
1237
+ The number of Notification rows created.
1238
+ """
1239
+ return create_event_notifications_multi_project(
1240
+ event_type=event_type,
1241
+ rows=[(row[0], project_id, row[1], row[2], row[3]) for row in rows],
1242
+ )
1243
+
1244
+
1245
+ def create_event_notifications_multi_project(
1246
+ *,
1247
+ event_type: str,
1248
+ rows: Sequence[
1249
+ tuple[int | str | None, uuid.UUID | str | None, str, str, uuid.UUID | str | None]
1250
+ ],
1251
+ ) -> int:
1252
+ """Create per-recipient event Notification rows spanning **many** projects in
1253
+ one preference lookup, one DND lookup and one ``bulk_create``.
1254
+
1255
+ The project-spanning form of :func:`create_event_notifications_batch`, which
1256
+ delegates here: ``Notification`` already carries ``project_id`` per row, so a
1257
+ fan-out over a batch of projects has no reason to issue one insert (and one
1258
+ preference scan, and one DND scan) per project. The program settings matrix
1259
+ applies one field map to as many as ``MAX_BULK_TARGETS`` projects and emits a
1260
+ surface notice for each; looping the single-project form there cost three
1261
+ queries per project for no gain (#3335).
1262
+
1263
+ Gating is identical and per **recipient**, not per project — the
1264
+ ``NotificationPreference`` matrix and account-wide DND are account-scoped, so
1265
+ one lookup over the union of recipients answers every row.
1266
+
1267
+ Args:
1268
+ event_type: A ``NotificationEventType`` value.
1269
+ rows: ``(recipient_id, project_id, subject, body, task_id)`` tuples.
1270
+ ``None`` recipients are skipped; ``task_id`` may be ``None`` for a row
1271
+ with no single task anchor.
1272
+
1273
+ Returns:
1274
+ The number of Notification rows created.
1275
+ """
1276
+ unique_ids = {row[0] for row in rows if row[0] is not None}
1277
+ if not unique_ids:
1278
+ return 0
1279
+
1280
+ _allows, dnd_user_ids = _build_event_notification_gate(event_type, unique_ids)
1281
+
1282
+ notifications: list[Notification] = []
1283
+ for recipient_id, project_id, subject, body, task_id in rows:
1284
+ if recipient_id is None or not _allows(recipient_id, NotificationChannel.IN_APP.value):
1285
+ continue
1286
+ notifications.append(
1287
+ Notification(
1288
+ recipient_id=recipient_id,
1289
+ event_type=event_type,
1290
+ subject=subject,
1291
+ body=body,
1292
+ project_id=project_id,
1293
+ task_id=task_id,
1294
+ email_pending=_allows(recipient_id, NotificationChannel.EMAIL.value)
1295
+ and not _dnd_silences(
1296
+ event_type,
1297
+ NotificationChannel.EMAIL.value,
1298
+ dnd_enabled=recipient_id in dnd_user_ids,
1299
+ ),
1300
+ )
1301
+ )
1302
+ if not notifications:
1303
+ return 0
1304
+ _bulk_create_notifications(notifications, batch_size=NOTIFICATION_BULK_BATCH_SIZE)
1305
+ return len(notifications)
1306
+
1307
+
1308
+ # Default per-board stale threshold, mirrored on Project.stale_task_threshold_days
1309
+ # so a Project that predates the field (or has it cleared) still gets a sane cutoff.
1310
+ DEFAULT_STALE_TASK_THRESHOLD_DAYS = 7
1311
+
1312
+
1313
+ def _stale_pref_allows(
1314
+ stored: dict[Any, dict[str, bool]],
1315
+ defaults: dict[tuple[str, str], bool],
1316
+ event: str,
1317
+ user_id: Any,
1318
+ channel: str,
1319
+ ) -> bool:
1320
+ """Resolve one (user, channel) delivery decision against stored prefs + defaults.
1321
+
1322
+ A module-level helper (not a closure) so it does not capture the per-project loop
1323
+ variables in :func:`create_stale_task_notifications` — same fall-back semantics as
1324
+ :func:`create_event_notifications`: stored preference wins, else the default.
1325
+ """
1326
+ per_user = stored.get(user_id, {})
1327
+ if channel in per_user:
1328
+ return per_user[channel]
1329
+ return defaults.get((event, channel), False)
1330
+
1331
+
1332
+ def _build_stale_notification_rows(
1333
+ *,
1334
+ candidates: list[tuple[Any, str, Any]],
1335
+ already_notified: set[tuple[Any, Any]],
1336
+ stored: dict[Any, dict[str, bool]],
1337
+ defaults: dict[tuple[str, str], bool],
1338
+ event: str,
1339
+ dnd_user_ids: set[Any],
1340
+ threshold: int,
1341
+ project_id: Any,
1342
+ ) -> list[Notification]:
1343
+ """Build the unsaved stale-task Notification rows for one project.
1344
+
1345
+ Extracted from :func:`create_stale_task_notifications` so the per-project driver
1346
+ loop stays flat; this owns the per-candidate skip/allow filtering and message
1347
+ construction. Skips any ``(assignee, task)`` already carrying an unread nudge and
1348
+ any assignee whose in-app preference is off — identical semantics to the inline
1349
+ loop it replaces.
1350
+ """
1351
+ rows: list[Notification] = []
1352
+ for task_id, name, assignee_id in candidates:
1353
+ if (assignee_id, task_id) in already_notified:
1354
+ continue
1355
+ if not _stale_pref_allows(
1356
+ stored, defaults, event, assignee_id, NotificationChannel.IN_APP.value
1357
+ ):
1358
+ continue
1359
+ # Task.name is CharField(max_length=512) but Notification.subject is
1360
+ # max_length=255. bulk_create bypasses field validation, so an over-long
1361
+ # name would raise a Postgres DataError and abort the whole nightly scan
1362
+ # (this project and every later one). Truncate the name used in the
1363
+ # subject well under the limit, mirroring _sanitize_snippet's bounding.
1364
+ display_name = name if len(name) <= 200 else name[:200].rstrip() + "…"
1365
+ subject = f'"{display_name}" has gone stale'
1366
+ body = (
1367
+ f'Your task "{display_name}" has sat in the same status for more than '
1368
+ f"{threshold} days. If it is still active, move it forward; "
1369
+ f"otherwise update its status so the board reflects reality."
1370
+ )
1371
+ rows.append(
1372
+ Notification(
1373
+ recipient_id=assignee_id,
1374
+ event_type=event,
1375
+ subject=subject,
1376
+ body=body,
1377
+ project_id=project_id,
1378
+ task_id=task_id,
1379
+ email_pending=_stale_pref_allows(
1380
+ stored, defaults, event, assignee_id, NotificationChannel.EMAIL.value
1381
+ )
1382
+ and not _dnd_silences(
1383
+ event,
1384
+ NotificationChannel.EMAIL.value,
1385
+ dnd_enabled=assignee_id in dnd_user_ids,
1386
+ ),
1387
+ )
1388
+ )
1389
+ return rows
1390
+
1391
+
1392
+ def create_stale_task_notifications(
1393
+ *,
1394
+ now: datetime.datetime | None = None,
1395
+ ) -> int:
1396
+ """Scan every project for stale non-terminal tasks and nudge their assignees.
1397
+
1398
+ ADR-0200. A task is *stale* when it has sat in its current status for more than
1399
+ the owning project's ``stale_task_threshold_days`` (default 7) and that status is
1400
+ non-terminal — every ``TaskStatus`` except ``COMPLETE``. Staleness is defined by
1401
+ *status column*, not ``percent_complete``: a card in ``REVIEW`` coerces
1402
+ ``percent_complete`` to 100 (it is functionally done, pending sign-off) yet is the
1403
+ flagship "task I forgot in Review" case, so it must stay in scope. The threshold is
1404
+ per-project, so the cutoff is evaluated one project at a time.
1405
+
1406
+ Only the task's **assignee** is notified — an unassigned card has no single owner
1407
+ to nudge (those are surfaced by the board-card chip instead). Delivery is gated by
1408
+ each assignee's global ``NotificationPreference`` for ``(task.stale, channel)``,
1409
+ exactly like :func:`create_event_notifications`, but resolved and written in bulk
1410
+ to keep the daily scan off the N+1 path.
1411
+
1412
+ Idempotent: a ``(recipient, task)`` that already has an **unread, un-archived**
1413
+ ``task.stale`` notification is skipped, so re-running the scan (retry, manual
1414
+ re-queue, or the next daily tick) creates zero duplicates.
1415
+
1416
+ Args:
1417
+ now: Injected clock for deterministic testing; defaults to ``timezone.now()``.
1418
+
1419
+ Returns:
1420
+ The total number of Notification rows created across all projects.
1421
+ """
1422
+ from trueppm_api.apps.projects.lifecycle import visible_projects
1423
+ from trueppm_api.apps.projects.models import Project, Task, TaskStatus
1424
+
1425
+ now = now or timezone.now()
1426
+ event = NotificationEventType.TASK_STALE.value
1427
+ defaults = {(et, ch): enabled for et, ch, enabled in DEFAULT_PREFERENCES}
1428
+ non_terminal = [s for s in TaskStatus.values if s != TaskStatus.COMPLETE]
1429
+
1430
+ total = 0
1431
+ # Drafts are excluded (#2962/#3128): this is an install-wide nightly fan-out, and
1432
+ # every card in a plan nobody has committed to is "stale" by construction — the
1433
+ # plan is still being written. Nudging an assignee about it would be the exclusion
1434
+ # list's stated failure, delivered daily.
1435
+ for project in (
1436
+ visible_projects(Project.objects.filter(is_deleted=False))
1437
+ .only("id", "stale_task_threshold_days")
1438
+ .iterator()
1439
+ ):
1440
+ threshold = project.stale_task_threshold_days or DEFAULT_STALE_TASK_THRESHOLD_DAYS
1441
+ cutoff = now - datetime.timedelta(days=threshold)
1442
+ candidates = list(
1443
+ Task.objects.filter(
1444
+ project_id=project.id,
1445
+ is_deleted=False,
1446
+ status__in=non_terminal,
1447
+ status_changed_at__lt=cutoff,
1448
+ assignee_id__isnull=False,
1449
+ ).values_list("id", "name", "assignee_id")
1450
+ )
1451
+ if not candidates:
1452
+ continue
1453
+
1454
+ task_ids = [row[0] for row in candidates]
1455
+ already_notified = set(
1456
+ Notification.objects.filter(
1457
+ event_type=event,
1458
+ is_read=False,
1459
+ is_archived=False,
1460
+ task_id__in=task_ids,
1461
+ ).values_list("recipient_id", "task_id")
1462
+ )
1463
+
1464
+ assignee_ids = {row[2] for row in candidates}
1465
+ stored: dict[Any, dict[str, bool]] = {}
1466
+ for pref in NotificationPreference.objects.filter(
1467
+ user_id__in=assignee_ids, event_type=event
1468
+ ):
1469
+ stored.setdefault(pref.user_id, {})[pref.channel] = pref.enabled
1470
+ # Account-wide DND (#1707): task.stale is not a bypass event, so DND
1471
+ # suppresses its email nudge while the durable in-app row still lands.
1472
+ dnd_user_ids = load_dnd_user_ids(assignee_ids)
1473
+
1474
+ rows = _build_stale_notification_rows(
1475
+ candidates=candidates,
1476
+ already_notified=already_notified,
1477
+ stored=stored,
1478
+ defaults=defaults,
1479
+ event=event,
1480
+ dnd_user_ids=dnd_user_ids,
1481
+ threshold=threshold,
1482
+ project_id=project.id,
1483
+ )
1484
+ if rows:
1485
+ # batch_size caps the INSERT statement size so a pathologically large
1486
+ # single-project backlog is chunked rather than one unbounded statement.
1487
+ _bulk_create_notifications(rows, batch_size=500)
1488
+ total += len(rows)
1489
+ return total