trueppm-api 0.4.0b2__py3-none-any.whl

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