trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,762 @@
1
+ """Celery tasks for the notifications app (ADR-0075 §F durable-execution).
2
+
3
+ Three Beat-scheduled tasks:
4
+
5
+ - ``drain_notification_emails`` — every 30 s (and on an ``on_commit`` nudge from
6
+ the fan-outs that queue email, #4191), finds Notification rows with
7
+ ``email_pending=True``, renders an email for each, sends via SMTP, and updates
8
+ delivery state. Best-effort — broker outage logs but doesn't propagate.
9
+ Re-checks the recipient's account and project-membership status at drain time,
10
+ not just at creation time (#3523, #3675) — a row queued before an off-boarding
11
+ or a membership revocation is retired unsent rather than delivered on a stale
12
+ grant. A row for a project that is merely in Trash is held (still pending), not
13
+ retired, so a restore still delivers it (#4587).
14
+
15
+ - ``archive_old_notifications`` — nightly, sets ``is_archived=True`` on any
16
+ Notification older than 90 days that has ``is_read=True``. Keeps the
17
+ index on ``(recipient, is_read, -created_at)`` shallow for the unread
18
+ query path.
19
+
20
+ - ``detect_stale_tasks`` — nightly (ADR-0200), scans every project for
21
+ non-terminal tasks that have sat in their current status past the project's
22
+ ``stale_task_threshold_days`` (default 7) and nudges the assignee via the
23
+ #639 event-notification rail. Idempotent: dedupes against existing unread
24
+ ``task.stale`` notifications, so a re-run creates zero duplicates.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import html
31
+ import logging
32
+ import textwrap
33
+ from datetime import timedelta
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ from celery.exceptions import SoftTimeLimitExceeded
37
+
38
+ from trueppm_api.core.idempotent import idempotent_task
39
+
40
+ from .delivery_limits import (
41
+ EMAIL_MAX_BATCH_SIZE,
42
+ beat_ticks_per_minute,
43
+ per_tick_cap,
44
+ reserve_send_budget,
45
+ )
46
+
47
+ if TYPE_CHECKING:
48
+ from django.core.mail.backends.base import BaseEmailBackend
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # Locked constants for email delivery — kept in this module so they're
54
+ # adjacent to the drain task that owns them.
55
+ EMAIL_MAX_RETRIES = 3
56
+ # Minimum gap between a failed attempt and the next one (#4191). First sends are
57
+ # immediate, but on_commit nudges run the drain far more often than the 30 s Beat
58
+ # tick, so without this floor a short relay outage would burn all three attempts in
59
+ # seconds. Keeps retries about one Beat tick apart (~90 s to exhaustion).
60
+ # Slightly under the 30 s tick so a Beat-only retry lands on the NEXT tick rather
61
+ # racing it (a floor equal to the tick would skip one about half the time).
62
+ EMAIL_RETRY_SPACING = timedelta(seconds=25)
63
+ # Fallback cap per drain tick, when no operator limit applies. Re-exported from
64
+ # delivery_limits, which every mail path now shares (#2887 item 3).
65
+ EMAIL_BATCH_SIZE = EMAIL_MAX_BATCH_SIZE
66
+ # Beat task name — also the key the per-minute throttle divisor is derived from, so
67
+ # the cadence and the throttle can no longer disagree (#2887 item 4).
68
+ DRAIN_TASK_NAME = "notifications.drain_notification_emails"
69
+ ARCHIVE_AFTER_DAYS = 90 # ADR-0075 §F item 6 — outbox cleanup
70
+
71
+ # Snippet rendering (#574, security review !306 LOW-1). SNIPPET_MAX_CHARS bounds
72
+ # the raw comment text before it's embedded in the email body; SNIPPET_WRAP_WIDTH
73
+ # hard-wraps it so a long unbroken string (a pasted URL, log line, or base64 blob)
74
+ # can't render as a single unbounded line in the recipient's mail client.
75
+ SNIPPET_MAX_CHARS = 280
76
+ SNIPPET_WRAP_WIDTH = 72 # classic plain-text convention; keeps quoted replies readable
77
+
78
+
79
+ @idempotent_task(
80
+ lock_key_template="drain_notification_emails",
81
+ lock_ttl=60,
82
+ on_contention="skip",
83
+ soft_time_limit=25,
84
+ time_limit=30,
85
+ acks_late=True,
86
+ reject_on_worker_lost=True,
87
+ name="notifications.drain_notification_emails",
88
+ )
89
+ def drain_notification_emails(self: object) -> None:
90
+ """Send queued email notifications.
91
+
92
+ Runs every 30 seconds via Celery Beat. Finds Notification rows where
93
+ ``email_pending=True`` and ``email_sent_at IS NULL`` and ``email_attempts
94
+ < EMAIL_MAX_RETRIES``. The fan-outs in ``services`` also nudge it from
95
+ ``transaction.on_commit`` so a new row leaves in seconds (#4191).
96
+
97
+ There is deliberately no ``created_at`` age floor. This drain is the only
98
+ code that sends a notification email — there is no per-row send task that
99
+ could be lost and need re-dispatching, which is what the webhook/export
100
+ "orphan window" guards against — and an uncommitted row is invisible to it
101
+ anyway. Exactly-once across overlapping runs comes from the singleton lock
102
+ + ``on_contention="skip"``: at most one drain runs at a time, and a nudged
103
+ or Beat run that finds the lock held skips; the next tick picks up anything
104
+ missed (ADR-0075 §Durable Execution item 3, amended by #4191).
105
+ """
106
+ _do_drain_emails()
107
+
108
+
109
+ @idempotent_task(
110
+ lock_key_template="archive_old_notifications",
111
+ lock_ttl=120,
112
+ on_contention="skip",
113
+ soft_time_limit=55,
114
+ time_limit=90,
115
+ acks_late=True,
116
+ reject_on_worker_lost=True,
117
+ name="notifications.archive_old_notifications",
118
+ )
119
+ def archive_old_notifications(self: object) -> None:
120
+ """Archive read notifications older than 90 days.
121
+
122
+ Runs nightly via Celery Beat. Sets ``is_archived=True`` on Notifications
123
+ where ``is_read=True`` and ``created_at < (now - ARCHIVE_AFTER_DAYS)``.
124
+
125
+ Archive is a soft state — rows stay in the table, are excluded from
126
+ default list queries, and remain reachable via the explicit ``archived``
127
+ filter. A future hard-purge for archived rows older than 365 days is
128
+ operational concern (not in 0.2).
129
+ """
130
+ _do_archive()
131
+
132
+
133
+ @idempotent_task(
134
+ lock_key_template="detect_stale_tasks",
135
+ lock_ttl=300,
136
+ on_contention="skip",
137
+ soft_time_limit=110,
138
+ time_limit=120,
139
+ acks_late=True,
140
+ reject_on_worker_lost=True,
141
+ name="notifications.detect_stale_tasks",
142
+ )
143
+ def detect_stale_tasks(self: object) -> None:
144
+ """Nudge assignees of stale non-terminal tasks (ADR-0200).
145
+
146
+ Runs nightly via Celery Beat. Delegates to
147
+ :func:`notifications.services.create_stale_task_notifications`, which owns the
148
+ per-project threshold query, the unread dedupe, and the preference-gated fan-out.
149
+
150
+ Idempotent by construction: the dedupe against existing unread ``task.stale``
151
+ notifications means a broker retry, a manual re-queue, or the next nightly tick
152
+ all create zero duplicates. The singleton lock (``on_contention="skip"``) also
153
+ prevents two overlapping runs. A failed run is not retried — the tasks are still
154
+ stale tomorrow, so silent re-attempt on the next tick is correct and avoids a
155
+ retry storm (ADR-0200 §Durable Execution item 8).
156
+ """
157
+ from .services import create_stale_task_notifications
158
+
159
+ created = create_stale_task_notifications()
160
+ if created:
161
+ logger.info("detect_stale_tasks: created %d stale-task notification(s)", created)
162
+
163
+
164
+ @idempotent_task(
165
+ lock_key_template="send_scheduled_digests",
166
+ lock_ttl=300,
167
+ on_contention="skip",
168
+ soft_time_limit=280,
169
+ time_limit=300,
170
+ acks_late=True,
171
+ reject_on_worker_lost=True,
172
+ max_retries=0,
173
+ name="notifications.send_scheduled_digests",
174
+ )
175
+ def send_scheduled_digests(self: object) -> None:
176
+ """Send weekly digests whose per-user slot matches this hour (ADR-0663).
177
+
178
+ Runs **hourly** via Beat even though the digests are weekly: the send time is
179
+ a per-user property (``UserNotificationSettings.digest_*``) in the user's own
180
+ timezone, so the sweep has to look every hour and match slots rather than fire
181
+ on one cluster-wide schedule.
182
+
183
+ ``max_retries=0`` — retrying is pointless here. The next tick is 60 minutes
184
+ away and re-evaluates from current state, and the ledger row is already written,
185
+ so a retry would either no-op or mail a stale snapshot. A failed run means one
186
+ skipped weekly digest, which the reader can recover by logging in
187
+ (ADR-0663 §Durable Execution item 8).
188
+ """
189
+ from .digests import send_due_digests
190
+
191
+ created = send_due_digests()
192
+ if created:
193
+ logger.info("send_scheduled_digests: created %d digest notification(s)", created)
194
+
195
+
196
+ @idempotent_task(
197
+ lock_key_template="purge_old_digest_runs",
198
+ lock_ttl=300,
199
+ on_contention="skip",
200
+ soft_time_limit=55,
201
+ time_limit=90,
202
+ acks_late=True,
203
+ reject_on_worker_lost=True,
204
+ name="notifications.purge_old_digest_runs",
205
+ )
206
+ def purge_old_digest_runs(self: object) -> None:
207
+ """Delete digest ledger rows past the retention window (ADR-0663 item 6).
208
+
209
+ Retention is 90 days rather than the 7-day per-request outbox convention: the
210
+ ledger exists to remember a *weekly* send, so a 7-day window would forget a
211
+ fortnight-old run it must not repeat.
212
+ """
213
+ from .digests import purge_old_digest_runs as _purge
214
+
215
+ deleted = _purge()
216
+ if deleted:
217
+ logger.info("purge_old_digest_runs: deleted %d ledger row(s)", deleted)
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Drain — extracted for direct testability without Celery in the loop
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ def _drain_batch_size(email_settings: Any) -> int:
226
+ """Rows to drain this tick, honoring the operator's delivery limits (#2860).
227
+
228
+ Thin wrapper over :func:`delivery_limits.per_tick_cap` that supplies this drain's
229
+ beat cadence. The interpretation of both controls, and the shared per-minute budget
230
+ that spans every mail path, live in ``delivery_limits`` (#2887 item 3).
231
+ """
232
+ return per_tick_cap(
233
+ email_settings,
234
+ ticks_per_minute=beat_ticks_per_minute(DRAIN_TASK_NAME),
235
+ fallback=EMAIL_MAX_BATCH_SIZE,
236
+ )
237
+
238
+
239
+ def _current_project_membership_exists(*, live_project_only: bool = False) -> Any:
240
+ """Correlated ``EXISTS(ProjectMembership …)`` for a notification's (recipient, project).
241
+
242
+ Shared by :func:`_retire_pending_for_revoked_membership` and the pending-row
243
+ query in :func:`_do_drain_emails` so both express the same boundary
244
+ ``NotificationSerializer._recipient_can_see_project`` (#3510) enforces on
245
+ read — as one correlated subquery attached to whatever outer
246
+ ``Notification`` queryset calls this, never a per-row Python-side lookup
247
+ (#3675 review note: this drain processes a whole batch per 30 s tick, so an
248
+ N+1 here scales with backlog size).
249
+
250
+ ``is_deleted=False`` always applies: a soft-deleted (revoked)
251
+ ``ProjectMembership`` row does not count as current membership.
252
+
253
+ ``live_project_only=True`` additionally requires the project itself not to be in
254
+ Trash (#4587), matching the serializer predicate exactly. Only the send path
255
+ passes it: a trashed project is *recoverable*, so the drain holds the row
256
+ (``email_pending`` stays True) and sends it after a restore. The retirement pass
257
+ leaves it False so that only a real membership revocation retires queued mail —
258
+ trashing a project must not permanently discard emails a restore would owe.
259
+ """
260
+ from django.db.models import Exists, OuterRef, Q
261
+
262
+ from trueppm_api.apps.access.models import ProjectMembership
263
+
264
+ qs = ProjectMembership.objects.filter(
265
+ user_id=OuterRef("recipient_id"),
266
+ project_id=OuterRef("project_id"),
267
+ is_deleted=False,
268
+ )
269
+ if not live_project_only:
270
+ return Exists(qs)
271
+ # The "project was deleted" notice exists *because* the project is in Trash, so
272
+ # it is exempt from the trashed-project floor (revocation still blocks it) —
273
+ # same exemption the serializer applies on the read side.
274
+ return Exists(qs.filter(project__is_deleted=False)) | (
275
+ Q(event_type="project.deleted") & Exists(qs)
276
+ )
277
+
278
+
279
+ def _retire_pending_for_revoked_membership() -> int:
280
+ """Retire queued emails whose recipient has since lost project membership (#3675).
281
+
282
+ Mirrors :func:`_retire_pending_for_deactivated_recipients` (#3523) for the
283
+ membership axis rather than the account-active axis. ``_render_email`` /
284
+ ``_send_email_for_notification`` read ``Notification.subject``/``.body``
285
+ straight off the frozen row and mail them via SMTP without ever consulting
286
+ ``NotificationSerializer._recipient_can_see_project`` (#3510) — so a member
287
+ removed from ``obj.project`` during the orphan-window-plus-retry gap between
288
+ a notification's creation and its eventual successful send still received a
289
+ plaintext email naming a project/task they can no longer see through the API.
290
+ Fixing the REST read path (#3510) does not reach this channel because it
291
+ never goes through the serializer at all.
292
+
293
+ A row with no project (``project_id IS NULL`` — the ADR-0663 account-scoped
294
+ digest shape) has no membership boundary to check and is left untouched,
295
+ matching ``_recipient_can_see_project``'s own "falls open with no project"
296
+ rule.
297
+
298
+ **Retired, not failed** — same terminal-state reasoning as #3523: only
299
+ ``email_pending`` is cleared here. ``email_attempts`` and ``email_failed_at``
300
+ are deliberately left untouched, so ``observability.selectors.
301
+ notification_email_signals``'s ``failed_recent`` signal (``email_pending=
302
+ False`` AND ``email_attempts >= EMAIL_MAX_RETRIES`` AND a recent
303
+ ``email_failed_at``) never reports a deliberate privacy suppression as an
304
+ SMTP/mail-relay failure, and the row does not sit in ``queued_aging`` forever
305
+ either (it reaches a terminal state instead of parking in the backlog).
306
+
307
+ Resolved as a single ``UPDATE … WHERE NOT EXISTS (…)`` rather than one
308
+ membership query per row — the drain runs every 30 s over the whole pending
309
+ backlog, so a per-row check would multiply with backlog size.
310
+
311
+ Returns:
312
+ The number of rows retired — 0 on every tick where no pending recipient
313
+ has lost project access, the steady state.
314
+ """
315
+ from .models import Notification
316
+
317
+ retired: int = (
318
+ Notification.objects.filter(
319
+ email_pending=True,
320
+ email_sent_at__isnull=True,
321
+ project_id__isnull=False,
322
+ )
323
+ .exclude(_current_project_membership_exists())
324
+ .update(email_pending=False)
325
+ )
326
+ if retired:
327
+ logger.info(
328
+ "drain_notification_emails: retired %d queued email(s) for recipient(s) "
329
+ "no longer a member of the notification's project",
330
+ retired,
331
+ )
332
+ return retired
333
+
334
+
335
+ def _retire_pending_for_deactivated_recipients() -> int:
336
+ """Retire queued emails whose recipient has since been deactivated (#3523).
337
+
338
+ The digest sweep now excludes deactivated users at the audience (see
339
+ ``digests._opted_in_user_ids``), but rows queued *before* off-boarding are already
340
+ in the outbox — and every notification email in OSS leaves through this one drain,
341
+ so this is the single chokepoint at which the account axis covers the mention,
342
+ stale-task, event and digest rails at once.
343
+
344
+ **Retired, not merely skipped.** Excluding these rows from the candidate query
345
+ alone would leave them ``email_pending=True`` forever, and
346
+ ``observability.selectors.notification_email_signals`` reads exactly that as its
347
+ ``queued_aging`` signal — rows pending past an hour mean "Beat is dead, the worker
348
+ is gone, or the transport cannot be built". A permanent false alarm on the System
349
+ Health card is the over-claiming that selector's docstring exists to prevent, so
350
+ the row must reach a terminal state instead of parking in the backlog.
351
+
352
+ The terminal state written here is ``email_pending=False`` **only**.
353
+ ``email_attempts`` and ``email_failed_at`` are deliberately left untouched: the
354
+ same selector derives ``failed_recent`` from ``email_pending=False`` **and**
355
+ ``email_attempts >= EMAIL_MAX_RETRIES`` **and** a recent ``email_failed_at``, so
356
+ stamping either would report an off-boarding as a mail-relay failure. This is not
357
+ a delivery failure — there is no longer anyone to deliver to.
358
+
359
+ The durable in-app ``Notification`` row is left alone. It is the inbox record, and
360
+ a deactivated account cannot authenticate to read it (JWT, session and PAT all
361
+ close on ``is_active``); the email is the only channel that reaches outward.
362
+
363
+ Reactivation does not resurrect a retired row, which is the intended trade: an
364
+ off-boarding is not expected to reverse, and a week-old digest replayed on
365
+ reinstatement would state program health that has since moved on.
366
+
367
+ Returns:
368
+ The number of rows retired — 0 on every tick of a workspace with no pending
369
+ mail for a deactivated user, which is the steady state.
370
+ """
371
+ from .models import Notification
372
+
373
+ retired: int = Notification.objects.filter(
374
+ email_pending=True,
375
+ email_sent_at__isnull=True,
376
+ recipient__is_active=False,
377
+ ).update(email_pending=False)
378
+ if retired:
379
+ logger.info(
380
+ "drain_notification_emails: retired %d queued email(s) for deactivated "
381
+ "recipient(s) without sending",
382
+ retired,
383
+ )
384
+ return retired
385
+
386
+
387
+ def _do_drain_emails() -> None:
388
+ from django.utils import timezone
389
+
390
+ from .models import Notification, WorkspaceEmailSettings
391
+
392
+ now = timezone.now()
393
+ _retire_pending_for_deactivated_recipients()
394
+ _retire_pending_for_revoked_membership()
395
+
396
+ # Read the operator's delivery limits BEFORE the query, because they bound it
397
+ # (#2860). max_recipients and throttle_per_min persisted, validated and rendered
398
+ # back in Workspace → Email for three releases while this task capped every
399
+ # drain at a hardcoded 50 regardless — a control that looked applied and was not.
400
+ email_settings = WorkspaceEmailSettings.load()
401
+ # Two-stage: the per-tick cap smooths within a minute, the shared budget bounds
402
+ # the minute across *every* mail path (invite drain, password reset, export-ready)
403
+ # so no single path can spend the whole allowance (#2887 item 3).
404
+ budget = reserve_send_budget(
405
+ int(email_settings.throttle_per_min or 0), _drain_batch_size(email_settings)
406
+ )
407
+ granted = budget.granted
408
+ if granted <= 0:
409
+ return
410
+
411
+ from django.db.models import Q
412
+
413
+ pending = list(
414
+ Notification.objects.filter(
415
+ email_pending=True,
416
+ email_sent_at__isnull=True,
417
+ email_attempts__lt=EMAIL_MAX_RETRIES,
418
+ # No created_at age floor (#4191) — see drain_notification_emails.
419
+ # Fail closed on the account axis (#3523). The retirement pass above has
420
+ # already cleared these rows, so this predicate normally matches nothing;
421
+ # it is here for the row deactivated in the gap between that UPDATE and
422
+ # this SELECT, where the only safe default is not to send.
423
+ recipient__is_active=True,
424
+ )
425
+ # Fail closed on the membership axis (#3675), same reasoning: the retirement
426
+ # pass above already cleared rows whose recipient lost project access, this
427
+ # is the backstop for a revocation landing in the gap between that UPDATE and
428
+ # this SELECT. A row with no project (account-scoped digests) has no
429
+ # membership boundary and always passes.
430
+ .filter(
431
+ _current_project_membership_exists(live_project_only=True) | Q(project_id__isnull=True)
432
+ )
433
+ # Retries stay one tick apart even when nudges run the drain more often.
434
+ .filter(Q(email_failed_at__isnull=True) | Q(email_failed_at__lt=now - EMAIL_RETRY_SPACING))
435
+ .select_related("recipient", "mention", "mention__task_comment", "mention__mentioner")
436
+ .order_by("created_at")[:granted]
437
+ )
438
+
439
+ # Hand back what this tick will not spend, or an idle drain starves the other
440
+ # paths for the rest of the minute.
441
+ budget.release(granted - len(pending))
442
+
443
+ if not pending:
444
+ return
445
+
446
+ # Resolve the workspace transport + From identity ONCE per batch — the config
447
+ # is constant across the batch, so building it per message would be N SMTP
448
+ # connections + N single-row reads per tick (#712 perf, ADR-0213).
449
+ from .email_backend import (
450
+ EmailTransportError,
451
+ resolve_email_connection,
452
+ resolve_from_email,
453
+ resolve_reply_to,
454
+ )
455
+
456
+ try:
457
+ connection = resolve_email_connection(email_settings)
458
+ except EmailTransportError:
459
+ # The workspace has a transport configured that cannot be built — an
460
+ # undecryptable credential or a host the egress guard rejects (#2886 item 2).
461
+ # Deliberately return WITHOUT touching the rows: this is a configuration fault
462
+ # an operator fix clears, and burning three retries per row would permanently
463
+ # discard notifications for it. The rows stay pending, which is what the
464
+ # System Health card's aging-backlog signal reports on, and
465
+ # ``transport_status`` names the cause directly.
466
+ logger.error(
467
+ "drain_notification_emails: the configured mail transport is unusable — "
468
+ "%d row(s) left pending, no retries burned. Fix the transport in "
469
+ "Workspace -> Email.",
470
+ len(pending),
471
+ )
472
+ budget.release(len(pending))
473
+ return
474
+ from_email = resolve_from_email(email_settings)
475
+ reply_to = resolve_reply_to(email_settings)
476
+
477
+ # Open the shared connection once so all N sends reuse one socket/TLS
478
+ # handshake. Best-effort: if the transport is down, each per-message send
479
+ # still attempts (and fails) on its own, preserving the attempt/retry count.
480
+ try:
481
+ connection.open()
482
+ except Exception:
483
+ logger.warning("drain_notification_emails: could not open mail transport", exc_info=True)
484
+
485
+ sent = 0
486
+ failed = 0
487
+ try:
488
+ for notif in pending:
489
+ try:
490
+ ok = _send_email_for_notification(
491
+ notif,
492
+ connection=connection,
493
+ from_email=from_email,
494
+ reply_to=reply_to,
495
+ )
496
+ except SoftTimeLimitExceeded:
497
+ raise
498
+ except Exception:
499
+ logger.exception(
500
+ "drain_notification_emails: unexpected error for notif %s", notif.pk
501
+ )
502
+ ok = False
503
+
504
+ if ok:
505
+ Notification.objects.filter(pk=notif.pk).update(
506
+ email_pending=False,
507
+ email_sent_at=timezone.now(),
508
+ )
509
+ sent += 1
510
+ else:
511
+ # Increment attempts atomically; on attempt N=max, clear
512
+ # email_pending so the row is no longer eligible for retry.
513
+ from django.db.models import F
514
+
515
+ new_attempts = notif.email_attempts + 1
516
+ update_fields: dict[str, object] = {
517
+ "email_attempts": F("email_attempts") + 1,
518
+ "email_failed_at": timezone.now(),
519
+ }
520
+ if new_attempts >= EMAIL_MAX_RETRIES:
521
+ update_fields["email_pending"] = False
522
+ Notification.objects.filter(pk=notif.pk).update(**update_fields)
523
+ failed += 1
524
+ finally:
525
+ with contextlib.suppress(Exception):
526
+ connection.close()
527
+
528
+ logger.info(
529
+ "drain_notification_emails: sent=%d failed=%d candidates=%d",
530
+ sent,
531
+ failed,
532
+ len(pending),
533
+ )
534
+
535
+
536
+ def _do_archive() -> None:
537
+ from django.utils import timezone
538
+
539
+ from .models import Notification
540
+
541
+ cutoff = timezone.now() - timedelta(days=ARCHIVE_AFTER_DAYS)
542
+ archived = Notification.objects.filter(
543
+ is_read=True,
544
+ is_archived=False,
545
+ created_at__lt=cutoff,
546
+ ).update(is_archived=True)
547
+ if archived:
548
+ logger.info(
549
+ "archive_old_notifications: archived %d row(s) older than %dd",
550
+ archived,
551
+ ARCHIVE_AFTER_DAYS,
552
+ )
553
+
554
+
555
+ # ---------------------------------------------------------------------------
556
+ # Email rendering — minimal Diátaxis-friendly subject + plain-text body
557
+ # ---------------------------------------------------------------------------
558
+
559
+
560
+ def _send_email_for_notification(
561
+ notif: object,
562
+ *,
563
+ connection: BaseEmailBackend | None = None,
564
+ from_email: str | None = None,
565
+ reply_to: list[str] | None = None,
566
+ ) -> bool:
567
+ """Render and send the email for a single Notification.
568
+
569
+ Returns True on successful SMTP delivery, False on any failure (SMTP error,
570
+ missing user email, missing source mention). The caller increments the
571
+ attempt counter — this function is pure-attempt, no state mutation.
572
+
573
+ The workspace transport (``connection``), ``from_email``, and ``reply_to``
574
+ are resolved **once per drain batch** and passed in (#712 perf: the config is
575
+ constant across a batch, so a per-message ``load()`` + SMTP connect would be
576
+ 50 redundant DB reads and SMTP/TLS handshakes per tick). When omitted they are
577
+ resolved here so a direct/one-off caller still works.
578
+ """
579
+ from django.core.mail import EmailMessage
580
+
581
+ from .email_backend import (
582
+ resolve_email_connection,
583
+ resolve_from_email,
584
+ resolve_reply_to,
585
+ )
586
+ from .models import Notification
587
+
588
+ notif_obj: Notification = notif # type: ignore[assignment]
589
+ recipient = notif_obj.recipient
590
+ recipient_email = getattr(recipient, "email", "") or ""
591
+ if not recipient_email:
592
+ logger.warning(
593
+ "drain_notification_emails: recipient %s has no email, skipping notif %s",
594
+ getattr(recipient, "pk", "?"),
595
+ notif_obj.pk,
596
+ )
597
+ return False
598
+
599
+ # A notification is mention-sourced OR event-sourced (#639, ADR-0085 §3);
600
+ # _render_email handles both shapes and returns ("", "") if neither yields
601
+ # renderable content (e.g. the source comment was deleted).
602
+ subject, body = _render_email(notif_obj)
603
+ if not subject or not body:
604
+ return False
605
+
606
+ # Dynamic transport + From identity from the workspace SMTP config (#712,
607
+ # ADR-0213). Falls back to the global backend / DEFAULT_FROM_EMAIL when the
608
+ # workspace transport is unconfigured (cloud), so this is a no-op for installs
609
+ # that never touch the writable config.
610
+ if connection is None:
611
+ connection = resolve_email_connection()
612
+ if from_email is None:
613
+ from_email = resolve_from_email()
614
+ if reply_to is None:
615
+ reply_to = resolve_reply_to()
616
+ msg = EmailMessage(
617
+ subject=subject,
618
+ body=body,
619
+ from_email=from_email,
620
+ to=[recipient_email],
621
+ reply_to=reply_to or None,
622
+ headers=_unsubscribe_headers() or None,
623
+ connection=connection,
624
+ )
625
+ try:
626
+ msg.send(fail_silently=False)
627
+ # TODO(#4421): swallows SoftTimeLimitExceeded; the drain-loop guard
628
+ # is ineffective until this is fixed
629
+ except Exception:
630
+ logger.warning(
631
+ "drain_notification_emails: SMTP send failed for notif %s",
632
+ notif_obj.pk,
633
+ exc_info=True,
634
+ )
635
+ return False
636
+ return True
637
+
638
+
639
+ def _render_email(notif: object) -> tuple[str, str]:
640
+ """Render a Notification into (subject, body) plain-text strings.
641
+
642
+ Kept deliberately minimal — the UI is the primary surface; email is a
643
+ secondary "ping the inbox" channel for users who opt in (default OFF).
644
+ A richer HTML template lands when the executive-digest issue ships
645
+ (trueppm-enterprise#112).
646
+ """
647
+ from .models import Notification
648
+
649
+ notif_obj: Notification = notif # type: ignore[assignment]
650
+ mention = notif_obj.mention
651
+ if mention is None:
652
+ # Event-sourced notification (#639): the subject/body were rendered at
653
+ # dispatch time and frozen on the row. Empty subject => nothing to send.
654
+ return notif_obj.subject, notif_obj.body
655
+
656
+ mentioner = mention.mentioner
657
+ mentioner_name = (
658
+ mentioner.get_full_name() or mentioner.username if mentioner is not None else "Someone"
659
+ )
660
+
661
+ comment = mention.task_comment
662
+ if comment is None or comment.is_deleted:
663
+ return "", ""
664
+
665
+ task = comment.task
666
+ task_name = task.name if task else "a task"
667
+ snippet = _sanitize_snippet(comment.body or "")
668
+
669
+ if mention.mentioned_group_key:
670
+ subject = f"{mentioner_name} mentioned @{mention.mentioned_group_key} on {task_name}"
671
+ else:
672
+ subject = f"{mentioner_name} mentioned you on {task_name}"
673
+
674
+ body_lines = [
675
+ f'{mentioner_name} mentioned you in a comment on "{task_name}":',
676
+ "",
677
+ snippet,
678
+ "",
679
+ "Open the task in TruePPM to reply or acknowledge.",
680
+ "",
681
+ "—",
682
+ _notification_prefs_line(),
683
+ ]
684
+ body = "\n".join(body_lines)
685
+ return subject, body
686
+
687
+
688
+ def _notification_prefs_line() -> str:
689
+ """Footer sentence pointing at the notification-preferences page.
690
+
691
+ Mirrors :func:`_unsubscribe_headers`: an absolute URL when
692
+ ``FRONTEND_BASE_URL`` is configured, otherwise prose with no path at all. A
693
+ bare ``/me/settings/notifications/`` is not a valid URL and is not reachable
694
+ by a recipient reading the email outside the app — the recipient already has
695
+ an account, so "in your Account settings" is a real fallback, unlike the
696
+ workspace-invite email, which has no in-app destination to point at.
697
+ """
698
+ from django.conf import settings
699
+
700
+ base = getattr(settings, "FRONTEND_BASE_URL", "").rstrip("/")
701
+ if base:
702
+ return (
703
+ f"You can change which notifications send email at {base}/me/settings/notifications/."
704
+ )
705
+ return "You can change which notifications send email from your Account settings in TruePPM."
706
+
707
+
708
+ def _sanitize_snippet(raw: str) -> str:
709
+ """Bound and format a comment snippet for embedding in the plain-text email body.
710
+
711
+ Truncates to ``SNIPPET_MAX_CHARS``, HTML-escapes as defense-in-depth (the
712
+ body is sent as ``text/plain`` today, so this isn't an XSS vector, but
713
+ escaping now means a future HTML template — e.g. the executive-digest work
714
+ in trueppm-enterprise#112 — can reuse this helper without introducing a new
715
+ escaping gap), and hard-wraps at ``SNIPPET_WRAP_WIDTH`` so a long unbroken
716
+ string (a pasted URL, log line, or base64 blob) can't render as a single
717
+ unbounded line in the recipient's mail client (#574, security review !306
718
+ LOW-1). ``quote=False`` on the escape leaves straight/curly quotes and
719
+ apostrophes untouched — those are common in ordinary prose and carry no
720
+ markup risk in body text, only ``&``/``<``/``>`` do.
721
+ """
722
+ text = (raw or "").strip()
723
+ if len(text) > SNIPPET_MAX_CHARS:
724
+ text = text[:SNIPPET_MAX_CHARS].rstrip() + "..."
725
+ escaped = html.escape(text, quote=False)
726
+ return textwrap.fill(
727
+ escaped,
728
+ width=SNIPPET_WRAP_WIDTH,
729
+ break_long_words=True,
730
+ break_on_hyphens=False,
731
+ )
732
+
733
+
734
+ def _unsubscribe_headers() -> dict[str, str]:
735
+ """Build the RFC 2369 ``List-Unsubscribe`` header.
736
+
737
+ Points at the user's notification-preferences page — TruePPM has no
738
+ per-notification unsubscribe token today, so this is "one click to the
739
+ login-gated preferences page" rather than a true no-auth unsubscribe. That is a
740
+ valid ``List-Unsubscribe`` on its own; RFC 2369 does not require the target to be
741
+ unauthenticated.
742
+
743
+ ``List-Unsubscribe-Post: List-Unsubscribe=One-Click`` is deliberately **not**
744
+ emitted (#2887 item 6). That header is an RFC 8058 promise that an unauthenticated
745
+ ``POST`` to the URL unsubscribes the recipient with no further interaction, and
746
+ there is no such handler anywhere in this app — the URL is a login-gated SPA route.
747
+ Gmail's and Yahoo's bulk-sender requirements are checked by *exercising* the POST,
748
+ so advertising it without a conforming endpoint is a deliverability liability,
749
+ inverting the reason it was added. Re-add it in the same change that ships a signed
750
+ unsubscribe token and its endpoint, never before.
751
+
752
+ Returns an empty dict — omitting the header entirely — when the deployer hasn't
753
+ configured ``FRONTEND_BASE_URL``, since a bare relative path is not a valid header
754
+ value.
755
+ """
756
+ from django.conf import settings
757
+
758
+ base = getattr(settings, "FRONTEND_BASE_URL", "").rstrip("/")
759
+ if not base:
760
+ return {}
761
+ prefs_url = f"{base}/me/settings/notifications"
762
+ return {"List-Unsubscribe": f"<{prefs_url}>"}