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,1197 @@
1
+ """Notifications data model (ADR-0075).
2
+
3
+ Three entities power the unified @mention surface that #311 ships first and
4
+ #476 (deferred Notes) will inherit unchanged:
5
+
6
+ - Mention — polymorphic append-only @mention record. Source is one of a small
7
+ set of nullable FKs (today: TaskComment; #476 adds TaskNote). Target is
8
+ either a user (direct mention) or a group key (snapshot expanded to per-
9
+ user Notification rows at write time).
10
+ - Notification — per-recipient inbox row. Single feed surface so a future
11
+ #476 note-mention lands in the same UI as today's comment-mention.
12
+ - NotificationPreference — per-user `(event_type, channel)` toggle. ADR-0049's
13
+ NOTIFICATION_CHANNELS registry is *registered but not yet consumed*: channel
14
+ validation runs off the ProjectNotificationChannel TextChoices (see
15
+ serializers._VALID_CHANNELS), so an Enterprise-registered slack_dm/teams_dm/sms
16
+ would currently be rejected. Wiring the registry is #3252.
17
+ - ProjectNotificationPreference — per-(project, user) routing matrix plus
18
+ daily quiet-hours window. Backs the Project > Notifications settings page
19
+ (#522); orthogonal to NotificationPreference, which is the *global* mention
20
+ feed default.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import datetime
26
+ import uuid
27
+ from typing import Any
28
+
29
+ from django.conf import settings
30
+ from django.db import models
31
+
32
+ # Lazy FK target — Django resolves the string, so no migration is produced.
33
+ _PROJECT_MODEL = "projects.Project"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Enums
37
+ # ---------------------------------------------------------------------------
38
+
39
+
40
+ class MentionScope(models.TextChoices):
41
+ """Visibility scope of a @mention's source content.
42
+
43
+ `PROJECT_VISIBLE` is the only value used by #311 (comments); #476 will
44
+ populate `TEAM_ONLY` for team-private Decisions and the visibility gate
45
+ on the Decisions view. The scope field exists on day one so #476's gate
46
+ isn't bolted on as an afterthought.
47
+ """
48
+
49
+ PROJECT_VISIBLE = "project_visible", "Project visible"
50
+ TEAM_ONLY = "team_only", "Team only"
51
+ PRIVATE = "private", "Private"
52
+
53
+
54
+ class NotificationEventType(models.TextChoices):
55
+ """Event types that produce notifications.
56
+
57
+ Mapped to NotificationPreference rows so users can independently toggle
58
+ in_app / email per event type. The own-task events (#639, ADR-0085) reuse
59
+ the WebhookEventType string values where one exists (task.assigned,
60
+ task.due_date_changed) so event names share one source of truth across the
61
+ webhook and notification subsystems; comment_on_my_task is notification-only.
62
+ @mentions of me are covered by MENTION_INDIVIDUAL / MENTION_GROUP and the
63
+ existing mention path — task.mentioned is not duplicated here.
64
+ """
65
+
66
+ MENTION_INDIVIDUAL = "mention_individual", "Direct @mention"
67
+ MENTION_GROUP = "mention_group", "Group @mention"
68
+ # Own-task events (#639) — a user's reach over work assigned to or owned by them.
69
+ TASK_ASSIGNED = "task.assigned", "Assigned to me"
70
+ TASK_DUE_DATE_CHANGED = "task.due_date_changed", "Due date changed on my task"
71
+ COMMENT_ON_MY_TASK = "comment_on_my_task", "Comment on my task"
72
+ # Contributor signal (#855, #476) — fires when a task I own is flagged blocked
73
+ # by someone else. One of the two events in the SIGNAL_ONLY_EVENTS preset.
74
+ TASK_BLOCKED = "task.blocked", "A task I own is blocked"
75
+ # Sprint-close bridge digest (#861) — the PM cohort is told when a closed
76
+ # sprint's reforecast materially shifts a bound milestone's finish. Email
77
+ # defaults ON for this one event (see DEFAULT_PREFERENCES): the issue's whole
78
+ # point is reaching the PM *outside* her session, which in-app alone can't do.
79
+ MILESTONE_FORECAST_SHIFTED = "milestone.forecast_shifted", "Milestone forecast shifted"
80
+ # Schedule-canvas reschedule of a sprint-mate's task (#497) — the rest of the
81
+ # ACTIVE sprint team learns a committed date moved. In-app only for v1.
82
+ SPRINT_TASK_RESCHEDULED = "sprint.task_rescheduled", "Task in my sprint rescheduled"
83
+ # Signal-privacy ceiling-raise ratification (#1275, ADR-0104 Amendment B) — a
84
+ # team-owned vote to widen a signal's visibility. "Opened" reaches eligible
85
+ # voters so the 72h window is discoverable off the Settings page; "resolved"
86
+ # reaches voters + the proposer with the outcome. In-app ON, email opt-in OFF
87
+ # (Priya's hard-NO was on un-opted email, not on an in-app inbox row).
88
+ SIGNAL_CEILING_PROPOSAL_OPENED = (
89
+ "signal.ceiling_proposal_opened",
90
+ "Team signal visibility proposal opened",
91
+ )
92
+ SIGNAL_CEILING_PROPOSAL_RESOLVED = (
93
+ "signal.ceiling_proposal_resolved",
94
+ "Team signal visibility proposal resolved",
95
+ )
96
+ # Project-delete team notification (#1115, VoC Option C). A project soft-delete
97
+ # is a large destructive write that removes the whole team's workspace; every
98
+ # member (bar the deleter) is told in-app who deleted it, so a project does not
99
+ # simply vanish from under them. In-app ON by default; email strictly opt-in OFF
100
+ # (Priya's hard-NO on un-opted email); NEVER push — the durable inbox row is the
101
+ # only channel, so the notification can never arrive as an unsolicited interrupt.
102
+ PROJECT_DELETED = "project.deleted", "A project I belong to was deleted"
103
+ # Stale-task daily nudge (ADR-0200). A daily scan fans this out to the assignee
104
+ # of a non-terminal task that has sat in its current status past the project's
105
+ # configurable threshold (default 7 days) — the "task I forgot in Review" slip.
106
+ # Distinct from the synchronous is_stalled board-card chip (ADR-0115, 3-day dwell):
107
+ # this is opt-in, configurable, and reaches the owner asynchronously. In-app ON,
108
+ # email opt-in OFF (Priya's un-opted-email hard-NO).
109
+ TASK_STALE = "task.stale", "A task I own has gone stale"
110
+ # Sprint-close carryover reassignment (ADR-0232, #1470). Fires to the assignee
111
+ # when close-time carry-over moves a task they own to the next sprint or the
112
+ # backlog — the "my work hopped sprints over the weekend and nobody told me"
113
+ # gap (Priya's load-bearing 🔴 in the 2026-07-01 agile VoC audit). A durable
114
+ # in-app inbox row, NOT a push: ADR-0102 §6 withholds push for board mechanics,
115
+ # but carryover is an actual reassignment of committed work (closer to
116
+ # task.assigned), so the inbox row reaches the assignee off-session without
117
+ # interrupting. In-app ON, email opt-in OFF (Priya's un-opted-email hard-NO).
118
+ TASK_MOVED_SPRINT = "task.moved_sprint", "A task I own was carried to another sprint"
119
+ # Sprint-membership scope change (ADR-0412, #1946). Fires to the project lead
120
+ # cohort (role >= ADMIN) when a task actually ENTERS or LEAVES an ACTIVE sprint
121
+ # — the "PM/admin silently added a task to the active sprint" audit gap Jordan
122
+ # (PO) and Alex (SM) raised as hard-NOs in the 2026-07-14 activity-streams VoC
123
+ # audit. Distinct from TASK_MOVED_SPRINT above (that reaches the *assignee* on
124
+ # close-time carryover); this reaches the *accountable leads* on a live-scope
125
+ # change. In-app ON, email opt-in OFF (Priya's un-opted-email hard-NO), and
126
+ # deliberately NOT in DND_BYPASS_EVENTS — a scope change is not a hard interrupt.
127
+ SPRINT_MEMBERSHIP_CHANGED = (
128
+ "sprint.membership_changed",
129
+ "A task entered or left an active sprint",
130
+ )
131
+ # Project-finish shift (#1911, third #82 schedule-notification rule — companion
132
+ # to #1668's dependency-slip / became-critical pair). Fires when a
133
+ # ProjectForecastSnapshot's cpm_finish moves by more than the project's
134
+ # configurable Project.end_date_shift_threshold_days versus the immediately
135
+ # prior snapshot. Reaches the PM/Owner cohort (role >= ADMIN), same targeting
136
+ # as MILESTONE_FORECAST_SHIFTED (#861) — see notify_project_end_date_shift.
137
+ PROJECT_END_DATE_SHIFTED = "project.end_date_shifted", "Project end date shifted"
138
+ # Amend on a committed plan (#2964). Fires when a structural edit lands on a
139
+ # project that has been committed (lifecycle=active, ADR-0845 baseline taken).
140
+ # Reaches the people whose work is affected — NEVER a block: the design is
141
+ # explicit that Amend records and tells, it does not gate. In-app ON so the
142
+ # change is visible where decisions get argued about; email opt-in OFF, the
143
+ # same un-opted-email floor every contributor signal keeps.
144
+ PLAN_AMENDED = "plan.amended", "A committed plan I work on was changed"
145
+ # Project configuration change that moves the surface everyone works on
146
+ # (#2972, epic #2743). Removing a board lane, hiding a board column, hiding a
147
+ # leaf surface, or switching the methodology preset re-arranges where every
148
+ # assignee's work appears — and before this, only the person who clicked knew.
149
+ #
150
+ # Two properties this event exists to hold, both easy to erode later:
151
+ # 1. It fires ONLY on a change that moves or hides work. A rename, a color,
152
+ # a WIP limit, an added lane — nothing. "A setting was saved" is exactly
153
+ # the notification the issue exists to refuse, so the trigger is the
154
+ # consequence, not the write.
155
+ # 2. Its body names what the change did to THIS recipient's own items
156
+ # (rendered per recipient via create_event_notifications_batch), which is
157
+ # why the notice is worth an inbox row at all.
158
+ # Reaches everyone with work in the project rather than the whole membership:
159
+ # the claim it makes is about the recipient's own items, and a member with no
160
+ # items has nothing to be told about. In-app ON, email opt-in OFF (Priya's
161
+ # un-opted-email hard-NO). Deliberately NOT in DND_BYPASS_EVENTS — a
162
+ # re-arranged board needs to be seen, not to interrupt.
163
+ PROJECT_CONFIG_CHANGED = (
164
+ "project.config_changed",
165
+ "The board or views I work on were reconfigured",
166
+ )
167
+ # Scheduled digests (ADR-0663, #2407) — the first CLOCK-driven events in this
168
+ # enum. Every other member fires from a domain event; these two fire from Beat
169
+ # on the recipient's own weekly slot (UserNotificationSettings.digest_*), which
170
+ # is why they are the only pair defaulting OFF on BOTH channels: a weekly send
171
+ # nobody asked for is exactly the volume the matrix exists to prevent. Content
172
+ # is read from already-computed facts (compute_program_rollup / _load_band) —
173
+ # a digest never derives a number the UI does not already show.
174
+ PROGRAM_HEALTH_DIGEST = "program.health_digest", "Weekly program health digest"
175
+ RESOURCE_OVERALLOCATION_DIGEST = (
176
+ "resource.overallocation_digest",
177
+ "Weekly resource overallocation digest",
178
+ )
179
+ # Membership grant / role-change (#3645). ProjectMembershipViewSet and
180
+ # ProgramMembershipViewSet could create, change and revoke access with no
181
+ # signal reaching the affected person — their only tell was a project or
182
+ # program appearing in their list. These two fire to the added or
183
+ # role-changed user only (not on revoke, which is audit-only — see the
184
+ # issue). In-app ON, email opt-in OFF, matching every other contributor
185
+ # signal in this table (Priya's un-opted-email hard-NO).
186
+ MEMBERSHIP_GRANTED = "membership.granted", "Added to a project or program"
187
+ MEMBERSHIP_ROLE_CHANGED = "membership.role_changed", "Role changed on a project or program"
188
+
189
+
190
+ class NotificationChannel(models.TextChoices):
191
+ """Delivery channels (OSS keys only).
192
+
193
+ ADR-0049's NOTIFICATION_CHANNELS registry is *intended* to open this axis to
194
+ Enterprise channels (slack_dm / teams_dm / sms), but nothing reads it yet, so
195
+ registering one does not make it accepted. Wiring it is #3252.
196
+ """
197
+
198
+ IN_APP = "in_app", "In-app"
199
+ EMAIL = "email", "Email"
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Manager — scope-respecting queryset (ADR-0075 must-fix)
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ class MentionManager(models.Manager["Mention"]):
208
+ """Manager enforcing the `Mention.objects.visible_to(user)` pattern.
209
+
210
+ All read paths must go through this method so #476's TEAM_ONLY scope is
211
+ enforced at a single point. Raw `Mention.objects.filter(...)` in viewsets
212
+ is forbidden — the rule is a review checklist item until a lint can be
213
+ added.
214
+ """
215
+
216
+ def visible_to(
217
+ self,
218
+ user: Any,
219
+ project_id: uuid.UUID | str | None = None,
220
+ ) -> models.QuerySet[Mention]:
221
+ """Return mentions the user is permitted to see.
222
+
223
+ Rules:
224
+ - Always restricted to projects the user is a member of (anchored by
225
+ ProjectMembership; non-deleted only).
226
+ - Within those projects, PROJECT_VISIBLE mentions are visible to all
227
+ members. TEAM_ONLY mentions (future #476) are visible only to the
228
+ mentioner, the mentioned user, and members of the mentioned group.
229
+ PRIVATE mentions are visible only to mentioner + mentioned_user.
230
+
231
+ NOTE: Currently UNUSED in the 0.2 viewsets — Mention visibility is
232
+ enforced indirectly via NotificationViewSet's `recipient=request.user`
233
+ filter and the absence of a direct Mention list endpoint. This method
234
+ is pre-positioned for #476, which will expose a Decision rollup view
235
+ that queries Mentions directly. Do NOT remove as dead code; it's the
236
+ single point of TEAM_ONLY scope enforcement when that scope is wired.
237
+ """
238
+ from trueppm_api.apps.access.models import ProjectMembership
239
+
240
+ if not user or not getattr(user, "is_authenticated", False):
241
+ return self.none()
242
+
243
+ member_projects = ProjectMembership.objects.filter(user=user, is_deleted=False).values_list(
244
+ "project_id", flat=True
245
+ )
246
+ qs = self.get_queryset().filter(project_id__in=list(member_projects))
247
+
248
+ if project_id is not None:
249
+ qs = qs.filter(project_id=project_id)
250
+
251
+ # Scope gate. PROJECT_VISIBLE is the only scope used in 0.2 (#311);
252
+ # the other two branches are inert today but the structure exists so
253
+ # #476 can populate them without a schema change or rewrite.
254
+ return qs.filter(
255
+ models.Q(scope=MentionScope.PROJECT_VISIBLE)
256
+ | models.Q(scope=MentionScope.TEAM_ONLY, mentioned_user=user)
257
+ | models.Q(scope=MentionScope.TEAM_ONLY, mentioner=user)
258
+ | models.Q(scope=MentionScope.PRIVATE, mentioned_user=user)
259
+ | models.Q(scope=MentionScope.PRIVATE, mentioner=user)
260
+ )
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Mention
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ class Mention(models.Model):
269
+ """Append-only @mention record. Polymorphic source via nullable FKs.
270
+
271
+ Exactly one source FK and at least one target (user OR group_key) must be
272
+ set — enforced by CheckConstraints. The `mentioned_group_key` is preserved
273
+ on the row for display ("@scrum-team was mentioned"), independently of
274
+ the per-recipient Notification rows that fan out from group resolution.
275
+ """
276
+
277
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
278
+ mentioner = models.ForeignKey(
279
+ settings.AUTH_USER_MODEL,
280
+ on_delete=models.SET_NULL,
281
+ null=True,
282
+ related_name="mentions_authored",
283
+ )
284
+
285
+ # Target — direct user OR group_key (or both, if the group resolution
286
+ # happens to include the mentioned user explicitly).
287
+ mentioned_user = models.ForeignKey(
288
+ settings.AUTH_USER_MODEL,
289
+ on_delete=models.SET_NULL,
290
+ null=True,
291
+ blank=True,
292
+ related_name="mentions_received",
293
+ )
294
+ # Empty string = no group key (Django convention DJ001: no nullable CharField).
295
+ mentioned_group_key = models.CharField(max_length=32, blank=True, default="")
296
+
297
+ # Polymorphic source. 0.2 has TaskComment only; #476 will add task_note.
298
+ # CheckConstraint requires exactly one (today: task_comment must be set).
299
+ task_comment = models.ForeignKey(
300
+ "projects.TaskComment",
301
+ on_delete=models.CASCADE,
302
+ null=True,
303
+ blank=True,
304
+ related_name="mentions",
305
+ )
306
+
307
+ project = models.ForeignKey(
308
+ _PROJECT_MODEL,
309
+ on_delete=models.CASCADE,
310
+ related_name="mentions",
311
+ )
312
+ scope = models.CharField(
313
+ max_length=32,
314
+ choices=MentionScope.choices,
315
+ default=MentionScope.PROJECT_VISIBLE,
316
+ )
317
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
318
+
319
+ objects = MentionManager()
320
+
321
+ class Meta:
322
+ db_table = "notifications_mention"
323
+ ordering = ["-created_at"]
324
+ constraints = [
325
+ models.CheckConstraint(
326
+ condition=(
327
+ models.Q(mentioned_user__isnull=False) | ~models.Q(mentioned_group_key="")
328
+ ),
329
+ name="mention_must_have_target",
330
+ ),
331
+ # In 0.2 only task_comment is a valid source; when #476 ships,
332
+ # widen this to allow task_note as well.
333
+ models.CheckConstraint(
334
+ condition=models.Q(task_comment__isnull=False),
335
+ name="mention_must_have_source",
336
+ ),
337
+ ]
338
+ indexes = [
339
+ models.Index(fields=["mentioned_user", "-created_at"], name="ix_mention_user_recent"),
340
+ models.Index(fields=["project", "-created_at"], name="ix_mention_project_recent"),
341
+ ]
342
+
343
+ def __str__(self) -> str:
344
+ target = self.mentioned_user_id or f"@{self.mentioned_group_key}"
345
+ return f"Mention(to={target}, scope={self.scope})"
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Notification — per-recipient inbox row
350
+ # ---------------------------------------------------------------------------
351
+
352
+
353
+ class Notification(models.Model):
354
+ """Per-recipient unread notification. Generated from Mention fan-out (0.2)
355
+ and future event types. Single inbox surface — every #476 mention lands
356
+ here unchanged.
357
+ """
358
+
359
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
360
+ recipient = models.ForeignKey(
361
+ settings.AUTH_USER_MODEL,
362
+ on_delete=models.CASCADE,
363
+ related_name="notifications",
364
+ )
365
+
366
+ # Source — a notification is EITHER mention-sourced (mention set, rendered
367
+ # from the comment) OR event-sourced (#639, ADR-0085 §3: event_type + the
368
+ # pre-rendered subject/body set, mention null). Both feed the one inbox.
369
+ mention = models.ForeignKey(
370
+ Mention,
371
+ on_delete=models.CASCADE,
372
+ null=True,
373
+ blank=True,
374
+ related_name="notifications",
375
+ )
376
+ # Event-sourced notifications (own-task events) — blank for mention rows.
377
+ event_type = models.CharField(max_length=64, blank=True, default="")
378
+ subject = models.CharField(max_length=255, blank=True, default="")
379
+ body = models.TextField(blank=True, default="")
380
+
381
+ # Optional deep-link target (#497/#861). Mention rows resolve their task via
382
+ # the comment; event rows have no such path, so this FK lets the inbox row
383
+ # link straight to the affected task/milestone in the schedule. SET_NULL +
384
+ # related_name="+" — a deleted task should never cascade-delete the historical
385
+ # notification, and no reverse accessor on Task is needed.
386
+ task = models.ForeignKey(
387
+ "projects.Task",
388
+ on_delete=models.SET_NULL,
389
+ null=True,
390
+ blank=True,
391
+ related_name="+",
392
+ )
393
+
394
+ # Nullable since ADR-0663: a scheduled digest spans the recipient's whole
395
+ # membership set and has no single owning project, so forcing one would mean
396
+ # picking an arbitrary project and mis-scoping the row. Every event- and
397
+ # mention-sourced row still sets it; only the account-scoped digests are NULL.
398
+ project = models.ForeignKey(
399
+ _PROJECT_MODEL,
400
+ on_delete=models.CASCADE,
401
+ null=True,
402
+ blank=True,
403
+ related_name="notifications",
404
+ )
405
+
406
+ is_read = models.BooleanField(default=False, db_index=True)
407
+ is_archived = models.BooleanField(default=False)
408
+ # Per-notification snooze (ADR-0216 §1). A row with snoozed_until > now() is
409
+ # hidden from the All/Unread inbox views (and the unread-count query) until
410
+ # the timestamp passes, then reappears — a pure query-time filter, no Celery.
411
+ # Null = not snoozed. Indexed because every non-snoozed list query filters on it.
412
+ snoozed_until = models.DateTimeField(null=True, blank=True, db_index=True)
413
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
414
+ read_at = models.DateTimeField(null=True, blank=True)
415
+
416
+ # Email delivery state (drained via drain_notification_emails Beat task).
417
+ email_pending = models.BooleanField(default=False, db_index=True)
418
+ email_sent_at = models.DateTimeField(null=True, blank=True)
419
+ email_failed_at = models.DateTimeField(null=True, blank=True)
420
+ email_attempts = models.PositiveIntegerField(default=0)
421
+
422
+ class Meta:
423
+ db_table = "notifications_notification"
424
+ ordering = ["-created_at"]
425
+ indexes = [
426
+ models.Index(fields=["recipient", "is_read", "-created_at"], name="ix_notif_unread"),
427
+ models.Index(
428
+ fields=["email_pending", "-created_at"],
429
+ name="ix_notif_email_pending",
430
+ condition=models.Q(email_pending=True),
431
+ ),
432
+ ]
433
+
434
+ def __str__(self) -> str:
435
+ return f"Notification(to={self.recipient_id}, mention={self.mention_id})"
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # NotificationPreference — per-user (event_type, channel) toggle
440
+ # ---------------------------------------------------------------------------
441
+
442
+
443
+ class NotificationPreference(models.Model):
444
+ """Per-user channel preference, per event type.
445
+
446
+ Defaults backfill on first GET (see services.get_or_create_defaults). The
447
+ channel column is a plain CharField, so the *schema* imposes no channel list —
448
+ but serializer validation does (_VALID_CHANNELS, built from the
449
+ ProjectNotificationChannel TextChoices), so an ADR-0049 Enterprise channel
450
+ (slack_dm, teams_dm, sms) does not yet round-trip. Wiring is #3252.
451
+ """
452
+
453
+ user = models.ForeignKey(
454
+ settings.AUTH_USER_MODEL,
455
+ on_delete=models.CASCADE,
456
+ related_name="notification_preferences",
457
+ )
458
+ event_type = models.CharField(max_length=64) # NotificationEventType + future
459
+ channel = models.CharField(max_length=32) # NotificationChannel + Enterprise extensions
460
+ enabled = models.BooleanField()
461
+ updated_at = models.DateTimeField(auto_now=True)
462
+
463
+ class Meta:
464
+ db_table = "notifications_preference"
465
+ constraints = [
466
+ models.UniqueConstraint(
467
+ fields=["user", "event_type", "channel"],
468
+ name="uq_notifpref_user_event_channel",
469
+ ),
470
+ ]
471
+
472
+ def __str__(self) -> str:
473
+ state = "on" if self.enabled else "off"
474
+ return f"NotificationPreference({self.event_type}/{self.channel}={state})"
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # Defaults (ADR-0075 V2 VoC — Priya's "flip email default OFF")
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ DEFAULT_PREFERENCES: list[tuple[str, str, bool]] = [
483
+ (NotificationEventType.MENTION_INDIVIDUAL, NotificationChannel.IN_APP, True),
484
+ (NotificationEventType.MENTION_INDIVIDUAL, NotificationChannel.EMAIL, False),
485
+ (NotificationEventType.MENTION_GROUP, NotificationChannel.IN_APP, True),
486
+ (NotificationEventType.MENTION_GROUP, NotificationChannel.EMAIL, False),
487
+ # Own-task events (#639): in-app ON, email OFF. Email is strictly opt-in —
488
+ # aggressive email defaults were Priya's VoC blocker (ADR-0085 §1, ADR-0075 V2).
489
+ (NotificationEventType.TASK_ASSIGNED, NotificationChannel.IN_APP, True),
490
+ (NotificationEventType.TASK_ASSIGNED, NotificationChannel.EMAIL, False),
491
+ (NotificationEventType.TASK_DUE_DATE_CHANGED, NotificationChannel.IN_APP, True),
492
+ (NotificationEventType.TASK_DUE_DATE_CHANGED, NotificationChannel.EMAIL, False),
493
+ (NotificationEventType.COMMENT_ON_MY_TASK, NotificationChannel.IN_APP, True),
494
+ (NotificationEventType.COMMENT_ON_MY_TASK, NotificationChannel.EMAIL, False),
495
+ # Blocked signal (#855): in-app ON, email OFF — same opt-in-email rule. This
496
+ # event is one of the two kept ON by the Signal-only preset below.
497
+ (NotificationEventType.TASK_BLOCKED, NotificationChannel.IN_APP, True),
498
+ (NotificationEventType.TASK_BLOCKED, NotificationChannel.EMAIL, False),
499
+ # #861 — the bridge digest is the deliberate exception to the email-OFF
500
+ # default: a PM who is not logged in when the team closes a sprint at 7pm
501
+ # must still be pushed the milestone-confidence shift, or "automatic" still
502
+ # demands she remember to log in (the issue's core blocker). Still fully
503
+ # matrix-overridable — a PM can mute either channel.
504
+ (NotificationEventType.MILESTONE_FORECAST_SHIFTED, NotificationChannel.IN_APP, True),
505
+ (NotificationEventType.MILESTONE_FORECAST_SHIFTED, NotificationChannel.EMAIL, True),
506
+ # #497 — in-app only for v1 (email digest/push explicitly out of scope).
507
+ (NotificationEventType.SPRINT_TASK_RESCHEDULED, NotificationChannel.IN_APP, True),
508
+ (NotificationEventType.SPRINT_TASK_RESCHEDULED, NotificationChannel.EMAIL, False),
509
+ # #1275 / ADR-0104 Amendment B — ceiling-raise proposal discovery. In-app ON so
510
+ # voters see the ask without navigating to Settings; email strictly opt-in OFF
511
+ # (Priya's hard-NO is preserved — un-opted email noise, not the in-app inbox).
512
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_OPENED, NotificationChannel.IN_APP, True),
513
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_OPENED, NotificationChannel.EMAIL, False),
514
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_RESOLVED, NotificationChannel.IN_APP, True),
515
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_RESOLVED, NotificationChannel.EMAIL, False),
516
+ # #1115 — project-delete team notification. In-app ON so every member learns
517
+ # their project was deleted; email opt-in OFF (Priya's un-opted-email hard-NO).
518
+ (NotificationEventType.PROJECT_DELETED, NotificationChannel.IN_APP, True),
519
+ (NotificationEventType.PROJECT_DELETED, NotificationChannel.EMAIL, False),
520
+ # ADR-0200 — stale-task daily nudge. In-app ON so the owner sees the reminder in
521
+ # their inbox; email opt-in OFF (Priya's un-opted-email hard-NO). A daily digest
522
+ # of forgotten cards must not become un-asked-for email.
523
+ (NotificationEventType.TASK_STALE, NotificationChannel.IN_APP, True),
524
+ (NotificationEventType.TASK_STALE, NotificationChannel.EMAIL, False),
525
+ # ADR-0232 (#1470) — sprint-close carryover reassignment. In-app ON so the
526
+ # assignee learns their committed work moved across the close→plan seam; email
527
+ # opt-in OFF (Priya's un-opted-email hard-NO).
528
+ (NotificationEventType.TASK_MOVED_SPRINT, NotificationChannel.IN_APP, True),
529
+ (NotificationEventType.TASK_MOVED_SPRINT, NotificationChannel.EMAIL, False),
530
+ # ADR-0412 (#1946) — sprint-membership scope change. In-app ON so the project
531
+ # leads see a mid-sprint injection/removal in their inbox; email opt-in OFF
532
+ # (Priya's un-opted-email hard-NO), matching every other contributor signal.
533
+ (NotificationEventType.SPRINT_MEMBERSHIP_CHANGED, NotificationChannel.IN_APP, True),
534
+ (NotificationEventType.SPRINT_MEMBERSHIP_CHANGED, NotificationChannel.EMAIL, False),
535
+ # #2964 — an amend to committed work. In-app ON so the people whose plan
536
+ # changed see it; email opt-in OFF, matching every other contributor signal.
537
+ (NotificationEventType.PLAN_AMENDED, NotificationChannel.IN_APP, True),
538
+ (NotificationEventType.PLAN_AMENDED, NotificationChannel.EMAIL, False),
539
+ # #1911 — project end-date shift. In-app ON so the PM/Owner cohort sees the
540
+ # slip in their inbox; email opt-in OFF (Priya's un-opted-email hard-NO),
541
+ # matching every other schedule-drift signal in this table.
542
+ (NotificationEventType.PROJECT_END_DATE_SHIFTED, NotificationChannel.IN_APP, True),
543
+ (NotificationEventType.PROJECT_END_DATE_SHIFTED, NotificationChannel.EMAIL, False),
544
+ # #2972 — a board lane removed, a column or view hidden, or the methodology
545
+ # preset switched. In-app ON so the people whose work moved learn it moved
546
+ # without waiting to notice; email opt-in OFF, the same un-opted-email floor
547
+ # every contributor signal in this table keeps.
548
+ (NotificationEventType.PROJECT_CONFIG_CHANGED, NotificationChannel.IN_APP, True),
549
+ (NotificationEventType.PROJECT_CONFIG_CHANGED, NotificationChannel.EMAIL, False),
550
+ # ADR-0663 (#2407) — the scheduled digests are the ONLY rows in this table that
551
+ # default OFF on both channels, and deliberately so. Every entry above fires
552
+ # from something the user did or something that happened to their work, so an
553
+ # in-app row is welcome by default; a weekly clock-driven send is traffic the
554
+ # user has to actually want. Defaulting in_app OFF also makes the feature inert
555
+ # on upgrade: create_event_notifications skips a recipient whose in_app is off,
556
+ # so no digest row exists for anyone until they opt in. Flipping either of these
557
+ # to True is a product decision with a volume cost (ADR-0663 §Risks), not a
558
+ # tidy-up.
559
+ (NotificationEventType.PROGRAM_HEALTH_DIGEST, NotificationChannel.IN_APP, False),
560
+ (NotificationEventType.PROGRAM_HEALTH_DIGEST, NotificationChannel.EMAIL, False),
561
+ (NotificationEventType.RESOURCE_OVERALLOCATION_DIGEST, NotificationChannel.IN_APP, False),
562
+ (NotificationEventType.RESOURCE_OVERALLOCATION_DIGEST, NotificationChannel.EMAIL, False),
563
+ # #3645 — membership grant / role-change. In-app ON so the affected person
564
+ # sees who changed their access and to what; email opt-in OFF, the same
565
+ # un-opted-email floor every contributor signal in this table keeps.
566
+ (NotificationEventType.MEMBERSHIP_GRANTED, NotificationChannel.IN_APP, True),
567
+ (NotificationEventType.MEMBERSHIP_GRANTED, NotificationChannel.EMAIL, False),
568
+ (NotificationEventType.MEMBERSHIP_ROLE_CHANGED, NotificationChannel.IN_APP, True),
569
+ (NotificationEventType.MEMBERSHIP_ROLE_CHANGED, NotificationChannel.EMAIL, False),
570
+ ]
571
+
572
+
573
+ # ---------------------------------------------------------------------------
574
+ # Signal-only preset (#855) — the contributor-friendly minimal profile
575
+ # ---------------------------------------------------------------------------
576
+ # Priya (Team Member) gave a hard-NO on noisy notifications. "Signal-only" is the
577
+ # one-click profile that keeps ON only the two events that mean "something needs
578
+ # my attention" — a task of mine got blocked, or a deadline moved — and turns
579
+ # everything else OFF. It is applied by POST /me/notification-preferences/
580
+ # apply-preset/ (a wholesale write of the per-(event,channel) rows), NOT a new
581
+ # stored model: the matrix stays the single source of truth so the existing
582
+ # data-driven settings page (ADR-0085) keeps rendering whatever rows the server
583
+ # returns. The "everything" preset restores DEFAULT_PREFERENCES.
584
+ SIGNAL_ONLY_EVENTS: frozenset[str] = frozenset(
585
+ {
586
+ NotificationEventType.TASK_BLOCKED,
587
+ NotificationEventType.TASK_DUE_DATE_CHANGED,
588
+ }
589
+ )
590
+
591
+
592
+ # ---------------------------------------------------------------------------
593
+ # Do-Not-Disturb bypass set (#1707, ADR-0292)
594
+ # ---------------------------------------------------------------------------
595
+ # The account-wide DND switch (UserNotificationSettings.dnd_enabled) silences
596
+ # transient channels (email/push) for routine events. These four events ALWAYS
597
+ # deliver on every channel regardless of DND — the "needs my attention now"
598
+ # signals a muted bell must never swallow: a task of mine got blocked, a team
599
+ # signal-visibility vote opened or resolved, or a milestone forecast shifted.
600
+ #
601
+ # This is deliberately NOT SIGNAL_ONLY_EVENTS: that set is the contributor
602
+ # settings *preset* (task.blocked + task.due_date_changed) and is the wrong
603
+ # membership for a safety bypass — it would let a routine due-date change punch
604
+ # through DND while silencing a signal-ceiling proposal. The two sets are
605
+ # distinct on purpose; keep this membership in step as new critical event types
606
+ # are added (it is the load-bearing safety contract, #1707).
607
+ DND_BYPASS_EVENTS: frozenset[str] = frozenset(
608
+ {
609
+ NotificationEventType.TASK_BLOCKED,
610
+ NotificationEventType.SIGNAL_CEILING_PROPOSAL_OPENED,
611
+ NotificationEventType.SIGNAL_CEILING_PROPOSAL_RESOLVED,
612
+ NotificationEventType.MILESTONE_FORECAST_SHIFTED,
613
+ }
614
+ )
615
+
616
+
617
+ # ---------------------------------------------------------------------------
618
+ # Project-scoped notification preferences (#522)
619
+ # ---------------------------------------------------------------------------
620
+
621
+
622
+ class ProjectNotificationEventType(models.TextChoices):
623
+ """Project-scoped notification events surfaced on Project > Notifications.
624
+
625
+ Disjoint from `NotificationEventType` (which today only covers @mention
626
+ fan-out for the inbox surface). The project page covers the broader set
627
+ of routing decisions a PM makes per-project — task assignment, slips,
628
+ risk creation, sprint lifecycle, etc.
629
+ """
630
+
631
+ TASK_ASSIGNED = "task_assigned", "Task assigned to me"
632
+ TASK_OVERDUE = "task_overdue", "Task I own is overdue"
633
+ COMMENT_MENTION = "comment_mention", "Mention (@) in a comment"
634
+ STATUS_CHANGE = "status_change", "Task moves to another column"
635
+ BUDGET_ALERT = "budget_alert", "Budget threshold crossed"
636
+ RISK_CREATED = "risk_created", "Risk created or escalated"
637
+ MILESTONE_REACHED = "milestone_reached", "Milestone reached"
638
+ SPRINT_START = "sprint_start", "Sprint started"
639
+ SPRINT_END = "sprint_end", "Sprint closed"
640
+
641
+
642
+ #: Project-scoped events that a delivery path actually consults (#2904).
643
+ #: ``COMMENT_MENTION`` is the only one: ``notifications/services.py`` gates the
644
+ #: mention fan-out on it. Everything else in the enum is declared below.
645
+ PROJECT_NOTIFICATION_DISPATCHED_EVENTS: frozenset[str] = frozenset(
646
+ {ProjectNotificationEventType.COMMENT_MENTION.value}
647
+ )
648
+
649
+ #: Members that exist in the matrix but that **nothing dispatches** — the settings
650
+ #: page renders them, and toggling one has no effect in either direction
651
+ #: (TODO(#3016) wire the dispatchers).
652
+ #:
653
+ #: They are kept rather than deleted because they are a stated product intent, and
654
+ #: the API reports them in ``event_delivery`` as ``false`` so a client can say so
655
+ #: instead of implying a delivery that never happens. Crucially they default **OFF** on
656
+ #: every channel: eight events defaulting ON across in-app, email and Slack told an
657
+ #: admin that eight kinds of notification were being delivered, which made turning
658
+ #: one *off* the only action with any apparent meaning — and it had none either.
659
+ #:
660
+ #: This set and :data:`PROJECT_NOTIFICATION_DISPATCHED_EVENTS` must together cover
661
+ #: the enum exactly; ``test_project_notification_dispatch_coverage`` enforces it, so
662
+ #: a member cannot be added without someone deciding which side it is on.
663
+ PROJECT_NOTIFICATION_UNDISPATCHED_EVENTS: frozenset[str] = frozenset(
664
+ {
665
+ ProjectNotificationEventType.TASK_ASSIGNED.value,
666
+ ProjectNotificationEventType.TASK_OVERDUE.value,
667
+ ProjectNotificationEventType.STATUS_CHANGE.value,
668
+ ProjectNotificationEventType.BUDGET_ALERT.value,
669
+ ProjectNotificationEventType.RISK_CREATED.value,
670
+ ProjectNotificationEventType.MILESTONE_REACHED.value,
671
+ ProjectNotificationEventType.SPRINT_START.value,
672
+ ProjectNotificationEventType.SPRINT_END.value,
673
+ }
674
+ )
675
+
676
+
677
+ class ProjectNotificationChannel(models.TextChoices):
678
+ """Columns of the project notification matrix — not a claim that each delivers.
679
+
680
+ Which of these TruePPM actually delivers on is not restated here: it is
681
+ :data:`PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS` and its complement
682
+ :data:`PROJECT_NOTIFICATION_UNDELIVERABLE_CHANNELS` below, the one place that
683
+ classification is made and the pair a coverage test holds against this enum.
684
+ ``slack`` and ``mobile_push`` are on the undeliverable side: nothing sends to
685
+ them and **no setting anywhere turns them on** — there is no integration to
686
+ configure, which is what the pre-#3378 wording here claimed. They remain as
687
+ columns so a member can record routing intent that applies once delivery ships.
688
+
689
+ This axis is *not* the ADR-0049 ``NOTIFICATION_CHANNELS`` registry — that is the
690
+ account-wide ``NotificationPreference.channel`` vocabulary, registered but not
691
+ yet consumed here (see the module docstring); whether the two converge is #3252.
692
+
693
+ A toggle in the matrix is therefore user intent, and the API says which columns
694
+ that intent can reach today via ``channel_delivery`` (#3378).
695
+ """
696
+
697
+ IN_APP = "in_app", "In-app"
698
+ EMAIL = "email", "Email"
699
+ SLACK = "slack", "Slack"
700
+ MOBILE_PUSH = "mobile_push", "Mobile push"
701
+
702
+
703
+ #: Matrix columns TruePPM has a delivery path for (#3378).
704
+ #:
705
+ #: A literal, deliberately. The obvious move — deriving it from ADR-0049's
706
+ #: ``NOTIFICATION_CHANNELS`` registrations — is wrong twice over. The registry is
707
+ #: the vocabulary of the *account-wide* ``NotificationPreference.channel`` surface,
708
+ #: not of this matrix, and it is registered but not yet consumed (see the module
709
+ #: docstring); whether the two axes converge is #3252's open question, so pinning
710
+ #: this to it would encode a decision nobody has made. And the registry is
711
+ #: populated at ``AppConfig.ready()``, so a value read from it would make the
712
+ #: published OpenAPI schema depend on which apps happened to be loaded when it was
713
+ #: generated — including Enterprise's, which would change the *OSS* schema.
714
+ #:
715
+ #: What keeps the literal honest is
716
+ #: ``tests/apps/notifications/test_project_notification_channel_coverage.py``: the
717
+ #: two sets must cover :class:`ProjectNotificationChannel` exactly and be disjoint,
718
+ #: and an AST walk asserts a channel declared undeliverable is named by no dispatch
719
+ #: site.
720
+ PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS: frozenset[str] = frozenset(
721
+ {
722
+ ProjectNotificationChannel.IN_APP.value,
723
+ ProjectNotificationChannel.EMAIL.value,
724
+ }
725
+ )
726
+
727
+ #: Columns the matrix renders that **nothing delivers on** (TODO(#3252)).
728
+ #:
729
+ #: Not "the integration is not configured" — there is nothing to configure. No code
730
+ #: in ``apps/notifications`` sends to either, and no setting anywhere turns them on.
731
+ #: They are kept as columns because they are a stated product intent and a member's
732
+ #: stored routing choice survives until delivery ships; the API reports them in
733
+ #: ``channel_delivery`` as ``false`` so a client can label the column instead of
734
+ #: rendering a control that cannot do anything.
735
+ PROJECT_NOTIFICATION_UNDELIVERABLE_CHANNELS: frozenset[str] = frozenset(
736
+ {
737
+ ProjectNotificationChannel.SLACK.value,
738
+ ProjectNotificationChannel.MOBILE_PUSH.value,
739
+ }
740
+ )
741
+
742
+
743
+ # Default matrix — applied lazily on first GET (no per-user backfill on join).
744
+ # A cell defaults ON only where both halves of the claim hold: the event has a
745
+ # dispatcher (PROJECT_NOTIFICATION_DISPATCHED_EVENTS) and the channel has a
746
+ # delivery path (PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS). Everything else
747
+ # defaults OFF, because a default of True is a claim that something will be
748
+ # delivered.
749
+ _T = ProjectNotificationEventType
750
+ _C = ProjectNotificationChannel
751
+
752
+
753
+ def _ALL_OFF() -> dict[str, bool]:
754
+ """Every channel off — the default for an event nothing dispatches (#2904)."""
755
+ return {_C.IN_APP: False, _C.EMAIL: False, _C.SLACK: False, _C.MOBILE_PUSH: False}
756
+
757
+
758
+ PROJECT_NOTIFICATION_DEFAULT_MATRIX: dict[str, dict[str, bool]] = {
759
+ # Only COMMENT_MENTION has a dispatch path, so it is the only row that may
760
+ # default ON — a default of True is a claim that something will be delivered.
761
+ # Slack and mobile push stay OFF even here: the event IS dispatched, but those
762
+ # two columns are in PROJECT_NOTIFICATION_UNDELIVERABLE_CHANNELS, so ON would be
763
+ # the same false claim one axis over. Flip them ON in the same change that ships
764
+ # their delivery (TODO(#3252)).
765
+ #
766
+ # This changes what a NEW row is seeded with, and nothing else. The JSONField
767
+ # default is materialized in full at row creation and every read path prefers the
768
+ # stored value, so a member who has already opened this page keeps whatever they
769
+ # have — deliberately. Force-clearing a stored cell would destroy a routing
770
+ # intent the whole surface exists to preserve, and it is no longer misleading
771
+ # either way: `channel_delivery` now labels the column on every response.
772
+ _T.COMMENT_MENTION: {
773
+ _C.IN_APP: True,
774
+ _C.EMAIL: True,
775
+ _C.SLACK: False,
776
+ _C.MOBILE_PUSH: False,
777
+ },
778
+ # TODO(#3016): every row below is dispatched by nothing. They default OFF on
779
+ # every channel so the settings matrix does not promise a delivery that never
780
+ # comes; the API reports them in event_delivery as false so a client can label
781
+ # them. Flip a row back to True in the same change that wires its dispatcher.
782
+ _T.TASK_ASSIGNED: _ALL_OFF(),
783
+ _T.TASK_OVERDUE: _ALL_OFF(),
784
+ _T.STATUS_CHANGE: _ALL_OFF(),
785
+ _T.BUDGET_ALERT: _ALL_OFF(),
786
+ _T.RISK_CREATED: _ALL_OFF(),
787
+ _T.MILESTONE_REACHED: _ALL_OFF(),
788
+ _T.SPRINT_START: _ALL_OFF(),
789
+ _T.SPRINT_END: _ALL_OFF(),
790
+ }
791
+
792
+
793
+ def _default_matrix() -> dict[str, dict[str, bool]]:
794
+ """Deep-copy of the default matrix used as the JSONField default.
795
+
796
+ Django requires a callable; returning the literal would share mutable
797
+ state across rows.
798
+ """
799
+ return {evt: dict(chans) for evt, chans in PROJECT_NOTIFICATION_DEFAULT_MATRIX.items()}
800
+
801
+
802
+ #: Every event type the ``event_delivery`` map reports on, as plain strings.
803
+ #:
804
+ #: The literal key set of :data:`PROJECT_NOTIFICATION_DEFAULT_MATRIX`, which is what
805
+ #: :func:`project_notification_event_delivery` iterates. Named here so the published
806
+ #: OpenAPI schema for ``event_delivery`` can enumerate the same keys from the same
807
+ #: source rather than restating them — a new event type then reaches the declared
808
+ #: contract in the edit that adds it, instead of leaving the schema quietly short a
809
+ #: property (#3396, #3399).
810
+ PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS: tuple[str, ...] = tuple(
811
+ sorted(str(event) for event in PROJECT_NOTIFICATION_DEFAULT_MATRIX)
812
+ )
813
+
814
+
815
+ def project_notification_event_delivery() -> dict[str, bool]:
816
+ """``{event_type: is a dispatcher wired}`` for every matrix row (#2904).
817
+
818
+ Derived from :data:`PROJECT_NOTIFICATION_DISPATCHED_EVENTS` rather than restating
819
+ the classification, so the coverage test that pins that set also pins this map.
820
+
821
+ Lives beside the two constants rather than in the view that returns it because
822
+ the serializer that *declares* the response shape needs the same key set, and a
823
+ second copy in the schema layer is exactly the drift #3399 asked to close.
824
+ """
825
+ return {
826
+ event: event in PROJECT_NOTIFICATION_DISPATCHED_EVENTS
827
+ for event in PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS
828
+ }
829
+
830
+
831
+ #: Every channel the ``channel_delivery`` map reports on, as plain strings (#3378).
832
+ #:
833
+ #: The matrix's own column vocabulary — :class:`ProjectNotificationChannel` — which
834
+ #: is a different, closed set from the ADR-0049 registry keys: Enterprise registers
835
+ #: ``slack_dm`` / ``teams_dm`` / ``sms`` there and none of them is a column here.
836
+ #: Named so the published OpenAPI schema enumerates the same keys from the same
837
+ #: source rather than restating them.
838
+ PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS: tuple[str, ...] = tuple(
839
+ sorted(str(channel.value) for channel in ProjectNotificationChannel)
840
+ )
841
+
842
+
843
+ def project_notification_channel_delivery() -> dict[str, bool]:
844
+ """``{channel: does TruePPM deliver on it}`` for every matrix column (#3378).
845
+
846
+ The per-column counterpart to :func:`project_notification_event_delivery`, and
847
+ the reason it exists is the same: the web client was hard-coding
848
+ ``['slack', 'mobile_push']`` and its own comment said the copy should not
849
+ survive. Whether a channel delivers is server state — it changes in the release a
850
+ delivery path lands — so a client-side copy drifts the moment one does.
851
+
852
+ Derived from :data:`PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS` rather than
853
+ restating the classification, so the coverage test that pins that set also pins
854
+ this map. The two maps are deliberately independent axes: a cell is live only
855
+ when its event is dispatched **and** its channel delivers, which is why a
856
+ per-row badge could never have covered this — ``comment_mention`` *is*
857
+ dispatched and its Slack cell still delivers nothing.
858
+
859
+ Server-global: the answer does not vary by user or by project. It rides on the
860
+ per-user preference document only so a client can label the grid in one render.
861
+ """
862
+ return {
863
+ channel: channel in PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS
864
+ for channel in PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS
865
+ }
866
+
867
+
868
+ class ProjectNotificationPreference(models.Model):
869
+ """Per-(project, user) notification routing matrix and quiet-hours window.
870
+
871
+ One row per user per project. The matrix is a `{event_type: {channel: bool}}`
872
+ JSON document — the row count stays small (one per user per project) and
873
+ PATCH is a single UPDATE rather than a 36-row diff. Missing event/channel
874
+ keys fall back to the default matrix on read, so a stale row won't break
875
+ when the event set grows.
876
+
877
+ schema_version tracks the shape of ``matrix`` (ADR-0086 / ADR-0204, #1916 —
878
+ this surface was named by #522 but missed the #645 registry sweep). On read,
879
+ ``ProjectNotificationPreferenceSerializer`` runs the payload through the
880
+ forward-migration registry (``schema_migrations.migrate_payload``) keyed on
881
+ this column, mirroring ``BoardSavedView.schema_version``. Rows created before
882
+ this field existed are backfilled to ``schema_version=1`` by the migration
883
+ default; every event type in ``PROJECT_NOTIFICATION_DEFAULT_MATRIX`` shipped
884
+ together at #522, so no existing row is actually missing a key and the v0
885
+ path is a no-op for them today — it exists so a *future* event-type addition
886
+ (or an externally-imported row that skipped ``validate`` at write time) has a
887
+ forward-migration path instead of relying solely on the view-layer
888
+ ``_merge_matrix`` overlay.
889
+ """
890
+
891
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
892
+ project = models.ForeignKey(
893
+ _PROJECT_MODEL,
894
+ on_delete=models.CASCADE,
895
+ related_name="notification_preferences_by_user",
896
+ )
897
+ user = models.ForeignKey(
898
+ settings.AUTH_USER_MODEL,
899
+ on_delete=models.CASCADE,
900
+ related_name="project_notification_preferences",
901
+ )
902
+ matrix = models.JSONField(default=_default_matrix)
903
+ schema_version = models.IntegerField(
904
+ default=1,
905
+ help_text="Matrix shape version; upgraded on read via the migration registry (ADR-0086).",
906
+ )
907
+
908
+ # Per-user-per-project kill-switch (#589). When True, all notification
909
+ # dispatch for this user on this project is suppressed regardless of the
910
+ # matrix — an opt-out path for members who haven't dialed in their
911
+ # routing yet. The matrix remains preserved so unpausing restores prior
912
+ # preferences exactly.
913
+ paused = models.BooleanField(default=False)
914
+
915
+ quiet_hours_enabled = models.BooleanField(default=True)
916
+ quiet_hours_from = models.TimeField(default=datetime.time(20, 0))
917
+ quiet_hours_until = models.TimeField(default=datetime.time(7, 0))
918
+
919
+ updated_at = models.DateTimeField(auto_now=True)
920
+
921
+ class Meta:
922
+ db_table = "notifications_project_preference"
923
+ constraints = [
924
+ models.UniqueConstraint(
925
+ fields=["project", "user"],
926
+ name="uq_projnotifpref_project_user",
927
+ ),
928
+ ]
929
+ indexes = [
930
+ models.Index(fields=["project", "user"], name="ix_projnotifpref_proj_user"),
931
+ ]
932
+
933
+ def __str__(self) -> str:
934
+ return f"ProjectNotificationPreference(project={self.project_id}, user={self.user_id})"
935
+
936
+
937
+ class UserNotificationSettings(models.Model):
938
+ """Per-user, account-wide notification settings — the Do-Not-Disturb switch
939
+ (#1707, ADR-0292).
940
+
941
+ One row per user, lazily created on first access
942
+ (:func:`services.get_or_create_notification_settings`) — no backfill on user
943
+ create, mirroring :func:`services.get_or_create_default_preferences`; the
944
+ absence of a row reads as DND off. Distinct in grain from the per-(event,
945
+ channel) :class:`NotificationPreference` matrix and the per-project
946
+ :class:`ProjectNotificationPreference`: this is a single account-wide switch
947
+ that silences transient channels (email/push) across every project, exactly
948
+ as quiet hours gate only transient channels — the durable in-app inbox row is
949
+ never silenced and :data:`DND_BYPASS_EVENTS` always deliver.
950
+
951
+ A plain ``Model`` (no ``server_version``): a personal installation preference,
952
+ never synced or broadcast, mirroring ``ProjectNotificationPreference`` and
953
+ ``profiles.UserProfile``. Shaped to grow a scheduled quiet-hours window + a
954
+ per-user timezone later; only the DND switch ships now (ADR-0292 §4).
955
+ """
956
+
957
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
958
+ user = models.OneToOneField(
959
+ settings.AUTH_USER_MODEL,
960
+ on_delete=models.CASCADE,
961
+ related_name="notification_settings",
962
+ )
963
+ # Account-wide Do-Not-Disturb. When True, transient channels (email/push) are
964
+ # suppressed for all non-bypass events; the durable in-app inbox row is
965
+ # unaffected and DND_BYPASS_EVENTS always deliver on every channel.
966
+ dnd_enabled = models.BooleanField(default=False)
967
+ # Weekly digest slot (ADR-0663, #2407) — the "per-user timezone + scheduled
968
+ # window" this model was documented from the start as being shaped to grow
969
+ # (ADR-0292 §4). ONE slot covers both digest event types: a schedule per event
970
+ # would double the settings surface to serve a case nobody asked for.
971
+ #
972
+ # The slot is only ever *read* by the Beat sweep; it is inert for a user who
973
+ # has not enabled a digest preference, so the defaults below are a starting
974
+ # point for the picker rather than an implied subscription.
975
+ digest_weekday = models.IntegerField(
976
+ default=6,
977
+ choices=[
978
+ (0, "Monday"),
979
+ (1, "Tuesday"),
980
+ (2, "Wednesday"),
981
+ (3, "Thursday"),
982
+ (4, "Friday"),
983
+ (5, "Saturday"),
984
+ (6, "Sunday"),
985
+ ],
986
+ help_text="Day of week for scheduled digests, 0=Monday..6=Sunday (Python weekday()).",
987
+ )
988
+ digest_hour = models.IntegerField(
989
+ default=17,
990
+ help_text="Local hour (0-23) at which scheduled digests are sent.",
991
+ )
992
+ # Blank = fall back to settings.TIME_ZONE. Empty string is the single unset
993
+ # state (project DJ001 convention — no nullable CharFields).
994
+ digest_timezone = models.CharField(
995
+ max_length=64,
996
+ blank=True,
997
+ default="",
998
+ help_text="IANA timezone for the digest slot; blank uses the server timezone.",
999
+ )
1000
+ updated_at = models.DateTimeField(auto_now=True)
1001
+
1002
+ class Meta:
1003
+ db_table = "notifications_user_settings"
1004
+ verbose_name = "user notification settings"
1005
+ verbose_name_plural = "user notification settings"
1006
+
1007
+ def __str__(self) -> str:
1008
+ state = "on" if self.dnd_enabled else "off"
1009
+ return f"UserNotificationSettings(user={self.user_id}, dnd={state})"
1010
+
1011
+
1012
+ class NotificationDigestRun(models.Model):
1013
+ """Idempotency ledger for scheduled digests (ADR-0663, #2407).
1014
+
1015
+ One row per ``(user, event_type, period_start)``. The Beat sweep
1016
+ ``get_or_create``s the row **before** building content and skips when
1017
+ ``created`` is False, so a double-fire, a manual re-queue, or an overlapping
1018
+ sweep cannot mail a second copy of the same week's digest. The unique
1019
+ constraint is what makes that check atomic across concurrent workers — the
1020
+ Redis lock on the task is belt-and-braces, not the primary guard.
1021
+
1022
+ Creating the row *before* the content build means a mid-build crash leaves a
1023
+ "sent" marker for a digest that never arrived. That is deliberate and fails
1024
+ closed: a missing weekly digest is recoverable by logging in, whereas a
1025
+ duplicate erodes trust in a channel whose entire value is that it is quiet.
1026
+
1027
+ A plain ``Model`` (no ``server_version``): an operational record, never synced
1028
+ to mobile and never broadcast, mirroring :class:`UserNotificationSettings`.
1029
+ """
1030
+
1031
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1032
+ user = models.ForeignKey(
1033
+ settings.AUTH_USER_MODEL,
1034
+ on_delete=models.CASCADE,
1035
+ related_name="digest_runs",
1036
+ )
1037
+ event_type = models.CharField(max_length=64)
1038
+ # User-local week-start date of the send. Date (not datetime) so the ledger
1039
+ # grain matches the weekly cadence and a DST-shifted resend collides.
1040
+ period_start = models.DateField()
1041
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
1042
+ # How many inbox rows the send produced. 0 is a legitimate value — an honest
1043
+ # "nothing at risk this week" digest is still a send (ADR-0663 §4).
1044
+ notification_count = models.PositiveIntegerField(default=0)
1045
+
1046
+ class Meta:
1047
+ db_table = "notifications_digest_run"
1048
+ constraints = [
1049
+ models.UniqueConstraint(
1050
+ fields=["user", "event_type", "period_start"],
1051
+ name="uniq_digest_run_user_event_period",
1052
+ ),
1053
+ ]
1054
+ indexes = [
1055
+ # Nightly purge scans by age alone.
1056
+ models.Index(fields=["created_at"], name="digestrun_created_idx"),
1057
+ ]
1058
+
1059
+ def __str__(self) -> str:
1060
+ return f"NotificationDigestRun(user={self.user_id}, {self.event_type}, {self.period_start})"
1061
+
1062
+
1063
+ # ---------------------------------------------------------------------------
1064
+ # Workspace SMTP configuration (#712, ADR-0213)
1065
+ # ---------------------------------------------------------------------------
1066
+
1067
+
1068
+ class EmailTransportMode(models.TextChoices):
1069
+ """Outbound-mail transport for the workspace.
1070
+
1071
+ ``CLOUD`` is the unconfigured default — the send path falls back to the
1072
+ process-global Django ``EMAIL_BACKEND`` (Helm env), so a fresh install
1073
+ behaves exactly as it did before #712. The other three build an SMTP
1074
+ connection from this row; ``SENDGRID`` / ``SES`` are SMTP relays with a
1075
+ provider-fixed host so 0.4 needs no new backend class (ADR-0213 §2).
1076
+ """
1077
+
1078
+ CLOUD = "cloud", "TruePPM cloud (Django settings)"
1079
+ SMTP = "smtp", "Custom SMTP"
1080
+ SENDGRID = "sendgrid", "SendGrid"
1081
+ SES = "ses", "Amazon SES"
1082
+
1083
+
1084
+ class EmailSecurity(models.TextChoices):
1085
+ """SMTP connection security."""
1086
+
1087
+ NONE = "none", "None"
1088
+ TLS = "tls", "STARTTLS"
1089
+ SSL = "ssl", "SSL/TLS"
1090
+
1091
+
1092
+ class WorkspaceEmailSettings(models.Model):
1093
+ """Singleton writable SMTP configuration for the workspace (#712, ADR-0213).
1094
+
1095
+ Installation config, not a synced/board-scoped resource, so there is no
1096
+ ``server_version``. Single-row is enforced by the unique ``singleton_key``
1097
+ (the ``workspace.Workspace`` / ADR-0081 precedent) and the row is lazily
1098
+ created on first access via :meth:`load` — no seed migration.
1099
+
1100
+ The SMTP password is encrypted at rest with Fernet (``password_ciphertext``)
1101
+ reusing ``apps.integrations.encryption`` and is **never** returned by the
1102
+ API — the serializer exposes only :attr:`password_is_set`. Decryption
1103
+ happens server-side only, at send time, in ``resolve_email_connection``.
1104
+ """
1105
+
1106
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1107
+ # Always 1 — the unique constraint makes a second row impossible.
1108
+ singleton_key = models.PositiveSmallIntegerField(default=1, editable=False, unique=True)
1109
+
1110
+ transport_mode = models.CharField(
1111
+ max_length=16,
1112
+ choices=EmailTransportMode.choices,
1113
+ default=EmailTransportMode.CLOUD,
1114
+ )
1115
+
1116
+ # SMTP transport parameters (unused when transport_mode == CLOUD).
1117
+ host = models.CharField(max_length=255, blank=True, default="")
1118
+ port = models.PositiveIntegerField(default=587)
1119
+ security = models.CharField(
1120
+ max_length=8,
1121
+ choices=EmailSecurity.choices,
1122
+ default=EmailSecurity.TLS,
1123
+ )
1124
+ username = models.CharField(max_length=255, blank=True, default="")
1125
+ # Fernet-encrypted SMTP password / provider API key. Never serialized out.
1126
+ password_ciphertext = models.BinaryField(blank=True, default=b"")
1127
+
1128
+ # From identity + deliverability.
1129
+ from_name = models.CharField(max_length=255, blank=True, default="")
1130
+ from_email = models.EmailField(blank=True, default="")
1131
+ reply_to = models.EmailField(blank=True, default="")
1132
+ dkim_selector = models.CharField(max_length=63, blank=True, default="")
1133
+
1134
+ # Delivery limits. throttle_per_min == 0 means "no throttle".
1135
+ max_recipients = models.PositiveIntegerField(default=50)
1136
+ throttle_per_min = models.PositiveIntegerField(default=0)
1137
+
1138
+ # Bounce webhook — NOT WIRED. Removed from the serializer and the Email
1139
+ # settings UI in #2860: there is no bounce-ingest endpoint anywhere in this
1140
+ # app, and nothing has ever POSTed to this URL or read it back. It persisted,
1141
+ # validated through the SSRF egress guard, rendered back and was documented,
1142
+ # which is exactly what made it look like a working control.
1143
+ #
1144
+ # The column is RETAINED rather than dropped so no operator's stored value is
1145
+ # destroyed; #2872 builds the ingest endpoint and re-exposes the field. Do not
1146
+ # re-add it to the serializer before that lands.
1147
+ bounce_webhook_url = models.URLField(max_length=512, blank=True, default="")
1148
+
1149
+ updated_at = models.DateTimeField(auto_now=True)
1150
+ updated_by = models.ForeignKey(
1151
+ settings.AUTH_USER_MODEL,
1152
+ on_delete=models.SET_NULL,
1153
+ null=True,
1154
+ blank=True,
1155
+ related_name="+",
1156
+ )
1157
+
1158
+ class Meta:
1159
+ db_table = "notifications_workspace_email_settings"
1160
+ verbose_name = "workspace email settings"
1161
+ verbose_name_plural = "workspace email settings"
1162
+
1163
+ def __str__(self) -> str:
1164
+ return f"WorkspaceEmailSettings(transport={self.transport_mode})"
1165
+
1166
+ @classmethod
1167
+ def load(cls) -> WorkspaceEmailSettings:
1168
+ """Return the singleton, creating it on first access (no seed migration)."""
1169
+ obj, _ = cls.objects.get_or_create(singleton_key=1)
1170
+ return obj
1171
+
1172
+ @property
1173
+ def password_is_set(self) -> bool:
1174
+ """Whether an encrypted SMTP password is stored (safe to expose)."""
1175
+ return bool(self.password_ciphertext)
1176
+
1177
+ def set_password(self, plaintext: str) -> None:
1178
+ """Fernet-encrypt and store the SMTP password / provider API key.
1179
+
1180
+ An empty ``plaintext`` clears the stored secret. Callers that want the
1181
+ "blank means keep existing" semantics must guard before calling this —
1182
+ the serializer does (#712 rotate-vs-keep).
1183
+ """
1184
+ from trueppm_api.apps.integrations.encryption import encrypt_secret
1185
+
1186
+ if not plaintext:
1187
+ self.password_ciphertext = b""
1188
+ return
1189
+ self.password_ciphertext = encrypt_secret(plaintext)
1190
+
1191
+ def get_password(self) -> str:
1192
+ """Decrypt and return the stored password, or "" when none is set."""
1193
+ if not self.password_ciphertext:
1194
+ return ""
1195
+ from trueppm_api.apps.integrations.encryption import decrypt_secret
1196
+
1197
+ return decrypt_secret(bytes(self.password_ciphertext))