trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1148 @@
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
+
28
+ from django.conf import settings
29
+ from django.db import models
30
+
31
+ # Lazy FK target — Django resolves the string, so no migration is produced.
32
+ _PROJECT_MODEL = "projects.Project"
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Enums
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ class MentionScope(models.TextChoices):
40
+ """Visibility scope of a @mention's source content.
41
+
42
+ `PROJECT_VISIBLE` is the only value used by #311 (comments); #476 will
43
+ populate `TEAM_ONLY` for team-private Decisions and the visibility gate
44
+ on the Decisions view. The scope field exists on day one so #476's gate
45
+ isn't bolted on as an afterthought.
46
+ """
47
+
48
+ PROJECT_VISIBLE = "project_visible", "Project visible"
49
+ TEAM_ONLY = "team_only", "Team only"
50
+ PRIVATE = "private", "Private"
51
+
52
+
53
+ class NotificationEventType(models.TextChoices):
54
+ """Event types that produce notifications.
55
+
56
+ Mapped to NotificationPreference rows so users can independently toggle
57
+ in_app / email per event type. The own-task events (#639, ADR-0085) reuse
58
+ the WebhookEventType string values where one exists (task.assigned,
59
+ task.due_date_changed) so event names share one source of truth across the
60
+ webhook and notification subsystems; comment_on_my_task is notification-only.
61
+ @mentions of me are covered by MENTION_INDIVIDUAL / MENTION_GROUP and the
62
+ existing mention path — task.mentioned is not duplicated here.
63
+ """
64
+
65
+ MENTION_INDIVIDUAL = "mention_individual", "Direct @mention"
66
+ MENTION_GROUP = "mention_group", "Group @mention"
67
+ # Own-task events (#639) — a user's reach over work assigned to or owned by them.
68
+ TASK_ASSIGNED = "task.assigned", "Assigned to me"
69
+ TASK_DUE_DATE_CHANGED = "task.due_date_changed", "Due date changed on my task"
70
+ COMMENT_ON_MY_TASK = "comment_on_my_task", "Comment on my task"
71
+ # Contributor signal (#855, #476) — fires when a task I own is flagged blocked
72
+ # by someone else. One of the two events in the SIGNAL_ONLY_EVENTS preset.
73
+ TASK_BLOCKED = "task.blocked", "A task I own is blocked"
74
+ # Sprint-close bridge digest (#861) — the PM cohort is told when a closed
75
+ # sprint's reforecast materially shifts a bound milestone's finish. Email
76
+ # defaults ON for this one event (see DEFAULT_PREFERENCES): the issue's whole
77
+ # point is reaching the PM *outside* her session, which in-app alone can't do.
78
+ MILESTONE_FORECAST_SHIFTED = "milestone.forecast_shifted", "Milestone forecast shifted"
79
+ # Schedule-canvas reschedule of a sprint-mate's task (#497) — the rest of the
80
+ # ACTIVE sprint team learns a committed date moved. In-app only for v1.
81
+ SPRINT_TASK_RESCHEDULED = "sprint.task_rescheduled", "Task in my sprint rescheduled"
82
+ # Signal-privacy ceiling-raise ratification (#1275, ADR-0104 Amendment B) — a
83
+ # team-owned vote to widen a signal's visibility. "Opened" reaches eligible
84
+ # voters so the 72h window is discoverable off the Settings page; "resolved"
85
+ # reaches voters + the proposer with the outcome. In-app ON, email opt-in OFF
86
+ # (Priya's hard-NO was on un-opted email, not on an in-app inbox row).
87
+ SIGNAL_CEILING_PROPOSAL_OPENED = (
88
+ "signal.ceiling_proposal_opened",
89
+ "Team signal visibility proposal opened",
90
+ )
91
+ SIGNAL_CEILING_PROPOSAL_RESOLVED = (
92
+ "signal.ceiling_proposal_resolved",
93
+ "Team signal visibility proposal resolved",
94
+ )
95
+ # Project-delete team notification (#1115, VoC Option C). A project soft-delete
96
+ # is a large destructive write that removes the whole team's workspace; every
97
+ # member (bar the deleter) is told in-app who deleted it, so a project does not
98
+ # simply vanish from under them. In-app ON by default; email strictly opt-in OFF
99
+ # (Priya's hard-NO on un-opted email); NEVER push — the durable inbox row is the
100
+ # only channel, so the notification can never arrive as an unsolicited interrupt.
101
+ PROJECT_DELETED = "project.deleted", "A project I belong to was deleted"
102
+ # Stale-task daily nudge (ADR-0200). A daily scan fans this out to the assignee
103
+ # of a non-terminal task that has sat in its current status past the project's
104
+ # configurable threshold (default 7 days) — the "task I forgot in Review" slip.
105
+ # Distinct from the synchronous is_stalled board-card chip (ADR-0115, 3-day dwell):
106
+ # this is opt-in, configurable, and reaches the owner asynchronously. In-app ON,
107
+ # email opt-in OFF (Priya's un-opted-email hard-NO).
108
+ TASK_STALE = "task.stale", "A task I own has gone stale"
109
+ # Sprint-close carryover reassignment (ADR-0232, #1470). Fires to the assignee
110
+ # when close-time carry-over moves a task they own to the next sprint or the
111
+ # backlog — the "my work hopped sprints over the weekend and nobody told me"
112
+ # gap (Priya's load-bearing 🔴 in the 2026-07-01 agile VoC audit). A durable
113
+ # in-app inbox row, NOT a push: ADR-0102 §6 withholds push for board mechanics,
114
+ # but carryover is an actual reassignment of committed work (closer to
115
+ # task.assigned), so the inbox row reaches the assignee off-session without
116
+ # interrupting. In-app ON, email opt-in OFF (Priya's un-opted-email hard-NO).
117
+ TASK_MOVED_SPRINT = "task.moved_sprint", "A task I own was carried to another sprint"
118
+ # Sprint-membership scope change (ADR-0412, #1946). Fires to the project lead
119
+ # cohort (role >= ADMIN) when a task actually ENTERS or LEAVES an ACTIVE sprint
120
+ # — the "PM/admin silently added a task to the active sprint" audit gap Jordan
121
+ # (PO) and Alex (SM) raised as hard-NOs in the 2026-07-14 activity-streams VoC
122
+ # audit. Distinct from TASK_MOVED_SPRINT above (that reaches the *assignee* on
123
+ # close-time carryover); this reaches the *accountable leads* on a live-scope
124
+ # change. In-app ON, email opt-in OFF (Priya's un-opted-email hard-NO), and
125
+ # deliberately NOT in DND_BYPASS_EVENTS — a scope change is not a hard interrupt.
126
+ SPRINT_MEMBERSHIP_CHANGED = (
127
+ "sprint.membership_changed",
128
+ "A task entered or left an active sprint",
129
+ )
130
+ # Project-finish shift (#1911, third #82 schedule-notification rule — companion
131
+ # to #1668's dependency-slip / became-critical pair). Fires when a
132
+ # ProjectForecastSnapshot's cpm_finish moves by more than the project's
133
+ # configurable Project.end_date_shift_threshold_days versus the immediately
134
+ # prior snapshot. Reaches the PM/Owner cohort (role >= ADMIN), same targeting
135
+ # as MILESTONE_FORECAST_SHIFTED (#861) — see notify_project_end_date_shift.
136
+ PROJECT_END_DATE_SHIFTED = "project.end_date_shifted", "Project end date shifted"
137
+ # Amend on a committed plan (#2964). Fires when a structural edit lands on a
138
+ # project that has been committed (lifecycle=active, ADR-0845 baseline taken).
139
+ # Reaches the people whose work is affected — NEVER a block: the design is
140
+ # explicit that Amend records and tells, it does not gate. In-app ON so the
141
+ # change is visible where decisions get argued about; email opt-in OFF, the
142
+ # same un-opted-email floor every contributor signal keeps.
143
+ PLAN_AMENDED = "plan.amended", "A committed plan I work on was changed"
144
+ # Project configuration change that moves the surface everyone works on
145
+ # (#2972, epic #2743). Removing a board lane, hiding a board column, hiding a
146
+ # leaf surface, or switching the methodology preset re-arranges where every
147
+ # assignee's work appears — and before this, only the person who clicked knew.
148
+ #
149
+ # Two properties this event exists to hold, both easy to erode later:
150
+ # 1. It fires ONLY on a change that moves or hides work. A rename, a color,
151
+ # a WIP limit, an added lane — nothing. "A setting was saved" is exactly
152
+ # the notification the issue exists to refuse, so the trigger is the
153
+ # consequence, not the write.
154
+ # 2. Its body names what the change did to THIS recipient's own items
155
+ # (rendered per recipient via create_event_notifications_batch), which is
156
+ # why the notice is worth an inbox row at all.
157
+ # Reaches everyone with work in the project rather than the whole membership:
158
+ # the claim it makes is about the recipient's own items, and a member with no
159
+ # items has nothing to be told about. In-app ON, email opt-in OFF (Priya's
160
+ # un-opted-email hard-NO). Deliberately NOT in DND_BYPASS_EVENTS — a
161
+ # re-arranged board needs to be seen, not to interrupt.
162
+ PROJECT_CONFIG_CHANGED = (
163
+ "project.config_changed",
164
+ "The board or views I work on were reconfigured",
165
+ )
166
+ # Scheduled digests (ADR-0663, #2407) — the first CLOCK-driven events in this
167
+ # enum. Every other member fires from a domain event; these two fire from Beat
168
+ # on the recipient's own weekly slot (UserNotificationSettings.digest_*), which
169
+ # is why they are the only pair defaulting OFF on BOTH channels: a weekly send
170
+ # nobody asked for is exactly the volume the matrix exists to prevent. Content
171
+ # is read from already-computed facts (compute_program_rollup / _load_band) —
172
+ # a digest never derives a number the UI does not already show.
173
+ PROGRAM_HEALTH_DIGEST = "program.health_digest", "Weekly program health digest"
174
+ RESOURCE_OVERALLOCATION_DIGEST = (
175
+ "resource.overallocation_digest",
176
+ "Weekly resource overallocation digest",
177
+ )
178
+ # Membership grant / role-change (#3645). ProjectMembershipViewSet and
179
+ # ProgramMembershipViewSet could create, change and revoke access with no
180
+ # signal reaching the affected person — their only tell was a project or
181
+ # program appearing in their list. These two fire to the added or
182
+ # role-changed user only (not on revoke, which is audit-only — see the
183
+ # issue). In-app ON, email opt-in OFF, matching every other contributor
184
+ # signal in this table (Priya's un-opted-email hard-NO).
185
+ MEMBERSHIP_GRANTED = "membership.granted", "Added to a project or program"
186
+ MEMBERSHIP_ROLE_CHANGED = "membership.role_changed", "Role changed on a project or program"
187
+
188
+
189
+ class NotificationChannel(models.TextChoices):
190
+ """Delivery channels (OSS keys only).
191
+
192
+ ADR-0049's NOTIFICATION_CHANNELS registry is *intended* to open this axis to
193
+ Enterprise channels (slack_dm / teams_dm / sms), but nothing reads it yet, so
194
+ registering one does not make it accepted. Wiring it is #3252.
195
+ """
196
+
197
+ IN_APP = "in_app", "In-app"
198
+ EMAIL = "email", "Email"
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Manager — scope-respecting queryset (ADR-0075 must-fix)
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ class MentionManager(models.Manager["Mention"]):
207
+ """Default manager for ``Mention``.
208
+
209
+ Mention visibility is enforced through ``NotificationViewSet``'s
210
+ ``recipient=request.user`` filter; there is no direct Mention list endpoint.
211
+ """
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Mention
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ class Mention(models.Model):
220
+ """Append-only @mention record. Polymorphic source via nullable FKs.
221
+
222
+ Exactly one source FK and at least one target (user OR group_key) must be
223
+ set — enforced by CheckConstraints. The `mentioned_group_key` is preserved
224
+ on the row for display ("@scrum-team was mentioned"), independently of
225
+ the per-recipient Notification rows that fan out from group resolution.
226
+ """
227
+
228
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
229
+ mentioner = models.ForeignKey(
230
+ settings.AUTH_USER_MODEL,
231
+ on_delete=models.SET_NULL,
232
+ null=True,
233
+ related_name="mentions_authored",
234
+ )
235
+
236
+ # Target — direct user OR group_key (or both, if the group resolution
237
+ # happens to include the mentioned user explicitly).
238
+ mentioned_user = models.ForeignKey(
239
+ settings.AUTH_USER_MODEL,
240
+ on_delete=models.SET_NULL,
241
+ null=True,
242
+ blank=True,
243
+ related_name="mentions_received",
244
+ )
245
+ # Empty string = no group key (Django convention DJ001: no nullable CharField).
246
+ mentioned_group_key = models.CharField(max_length=32, blank=True, default="")
247
+
248
+ # Polymorphic source. 0.2 has TaskComment only; #476 will add task_note.
249
+ # CheckConstraint requires exactly one (today: task_comment must be set).
250
+ task_comment = models.ForeignKey(
251
+ "projects.TaskComment",
252
+ on_delete=models.CASCADE,
253
+ null=True,
254
+ blank=True,
255
+ related_name="mentions",
256
+ )
257
+
258
+ project = models.ForeignKey(
259
+ _PROJECT_MODEL,
260
+ on_delete=models.CASCADE,
261
+ related_name="mentions",
262
+ )
263
+ scope = models.CharField(
264
+ max_length=32,
265
+ choices=MentionScope.choices,
266
+ default=MentionScope.PROJECT_VISIBLE,
267
+ )
268
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
269
+
270
+ objects = MentionManager()
271
+
272
+ class Meta:
273
+ db_table = "notifications_mention"
274
+ ordering = ["-created_at"]
275
+ constraints = [
276
+ models.CheckConstraint(
277
+ condition=(
278
+ models.Q(mentioned_user__isnull=False) | ~models.Q(mentioned_group_key="")
279
+ ),
280
+ name="mention_must_have_target",
281
+ ),
282
+ # In 0.2 only task_comment is a valid source; when #476 ships,
283
+ # widen this to allow task_note as well.
284
+ models.CheckConstraint(
285
+ condition=models.Q(task_comment__isnull=False),
286
+ name="mention_must_have_source",
287
+ ),
288
+ ]
289
+ indexes = [
290
+ models.Index(fields=["mentioned_user", "-created_at"], name="ix_mention_user_recent"),
291
+ models.Index(fields=["project", "-created_at"], name="ix_mention_project_recent"),
292
+ ]
293
+
294
+ def __str__(self) -> str:
295
+ target = self.mentioned_user_id or f"@{self.mentioned_group_key}"
296
+ return f"Mention(to={target}, scope={self.scope})"
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Notification — per-recipient inbox row
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ class Notification(models.Model):
305
+ """Per-recipient unread notification. Generated from Mention fan-out (0.2)
306
+ and future event types. Single inbox surface — every #476 mention lands
307
+ here unchanged.
308
+ """
309
+
310
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
311
+ recipient = models.ForeignKey(
312
+ settings.AUTH_USER_MODEL,
313
+ on_delete=models.CASCADE,
314
+ related_name="notifications",
315
+ )
316
+
317
+ # Source — a notification is EITHER mention-sourced (mention set, rendered
318
+ # from the comment) OR event-sourced (#639, ADR-0085 §3: event_type + the
319
+ # pre-rendered subject/body set, mention null). Both feed the one inbox.
320
+ mention = models.ForeignKey(
321
+ Mention,
322
+ on_delete=models.CASCADE,
323
+ null=True,
324
+ blank=True,
325
+ related_name="notifications",
326
+ )
327
+ # Event-sourced notifications (own-task events) — blank for mention rows.
328
+ event_type = models.CharField(max_length=64, blank=True, default="")
329
+ subject = models.CharField(max_length=255, blank=True, default="")
330
+ body = models.TextField(blank=True, default="")
331
+
332
+ # Optional deep-link target (#497/#861). Mention rows resolve their task via
333
+ # the comment; event rows have no such path, so this FK lets the inbox row
334
+ # link straight to the affected task/milestone in the schedule. SET_NULL +
335
+ # related_name="+" — a deleted task should never cascade-delete the historical
336
+ # notification, and no reverse accessor on Task is needed.
337
+ task = models.ForeignKey(
338
+ "projects.Task",
339
+ on_delete=models.SET_NULL,
340
+ null=True,
341
+ blank=True,
342
+ related_name="+",
343
+ )
344
+
345
+ # Nullable since ADR-0663: a scheduled digest spans the recipient's whole
346
+ # membership set and has no single owning project, so forcing one would mean
347
+ # picking an arbitrary project and mis-scoping the row. Every event- and
348
+ # mention-sourced row still sets it; only the account-scoped digests are NULL.
349
+ project = models.ForeignKey(
350
+ _PROJECT_MODEL,
351
+ on_delete=models.CASCADE,
352
+ null=True,
353
+ blank=True,
354
+ related_name="notifications",
355
+ )
356
+
357
+ is_read = models.BooleanField(default=False, db_index=True)
358
+ is_archived = models.BooleanField(default=False)
359
+ # Per-notification snooze (ADR-0216 §1). A row with snoozed_until > now() is
360
+ # hidden from the All/Unread inbox views (and the unread-count query) until
361
+ # the timestamp passes, then reappears — a pure query-time filter, no Celery.
362
+ # Null = not snoozed. Indexed because every non-snoozed list query filters on it.
363
+ snoozed_until = models.DateTimeField(null=True, blank=True, db_index=True)
364
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
365
+ read_at = models.DateTimeField(null=True, blank=True)
366
+
367
+ # Email delivery state (drained via drain_notification_emails Beat task).
368
+ email_pending = models.BooleanField(default=False, db_index=True)
369
+ email_sent_at = models.DateTimeField(null=True, blank=True)
370
+ email_failed_at = models.DateTimeField(null=True, blank=True)
371
+ email_attempts = models.PositiveIntegerField(default=0)
372
+
373
+ class Meta:
374
+ db_table = "notifications_notification"
375
+ ordering = ["-created_at"]
376
+ indexes = [
377
+ models.Index(fields=["recipient", "is_read", "-created_at"], name="ix_notif_unread"),
378
+ models.Index(
379
+ fields=["email_pending", "-created_at"],
380
+ name="ix_notif_email_pending",
381
+ condition=models.Q(email_pending=True),
382
+ ),
383
+ ]
384
+
385
+ def __str__(self) -> str:
386
+ return f"Notification(to={self.recipient_id}, mention={self.mention_id})"
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # NotificationPreference — per-user (event_type, channel) toggle
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ class NotificationPreference(models.Model):
395
+ """Per-user channel preference, per event type.
396
+
397
+ Defaults backfill on first GET (see services.get_or_create_defaults). The
398
+ channel column is a plain CharField, so the *schema* imposes no channel list —
399
+ but serializer validation does (_VALID_CHANNELS, built from the
400
+ ProjectNotificationChannel TextChoices), so an ADR-0049 Enterprise channel
401
+ (slack_dm, teams_dm, sms) does not yet round-trip. Wiring is #3252.
402
+ """
403
+
404
+ user = models.ForeignKey(
405
+ settings.AUTH_USER_MODEL,
406
+ on_delete=models.CASCADE,
407
+ related_name="notification_preferences",
408
+ )
409
+ event_type = models.CharField(max_length=64) # NotificationEventType + future
410
+ channel = models.CharField(max_length=32) # NotificationChannel + Enterprise extensions
411
+ enabled = models.BooleanField()
412
+ updated_at = models.DateTimeField(auto_now=True)
413
+
414
+ class Meta:
415
+ db_table = "notifications_preference"
416
+ constraints = [
417
+ models.UniqueConstraint(
418
+ fields=["user", "event_type", "channel"],
419
+ name="uq_notifpref_user_event_channel",
420
+ ),
421
+ ]
422
+
423
+ def __str__(self) -> str:
424
+ state = "on" if self.enabled else "off"
425
+ return f"NotificationPreference({self.event_type}/{self.channel}={state})"
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # Defaults (ADR-0075 V2 VoC — Priya's "flip email default OFF")
430
+ # ---------------------------------------------------------------------------
431
+
432
+
433
+ DEFAULT_PREFERENCES: list[tuple[str, str, bool]] = [
434
+ (NotificationEventType.MENTION_INDIVIDUAL, NotificationChannel.IN_APP, True),
435
+ (NotificationEventType.MENTION_INDIVIDUAL, NotificationChannel.EMAIL, False),
436
+ (NotificationEventType.MENTION_GROUP, NotificationChannel.IN_APP, True),
437
+ (NotificationEventType.MENTION_GROUP, NotificationChannel.EMAIL, False),
438
+ # Own-task events (#639): in-app ON, email OFF. Email is strictly opt-in —
439
+ # aggressive email defaults were Priya's VoC blocker (ADR-0085 §1, ADR-0075 V2).
440
+ (NotificationEventType.TASK_ASSIGNED, NotificationChannel.IN_APP, True),
441
+ (NotificationEventType.TASK_ASSIGNED, NotificationChannel.EMAIL, False),
442
+ (NotificationEventType.TASK_DUE_DATE_CHANGED, NotificationChannel.IN_APP, True),
443
+ (NotificationEventType.TASK_DUE_DATE_CHANGED, NotificationChannel.EMAIL, False),
444
+ (NotificationEventType.COMMENT_ON_MY_TASK, NotificationChannel.IN_APP, True),
445
+ (NotificationEventType.COMMENT_ON_MY_TASK, NotificationChannel.EMAIL, False),
446
+ # Blocked signal (#855): in-app ON, email OFF — same opt-in-email rule. This
447
+ # event is one of the two kept ON by the Signal-only preset below.
448
+ (NotificationEventType.TASK_BLOCKED, NotificationChannel.IN_APP, True),
449
+ (NotificationEventType.TASK_BLOCKED, NotificationChannel.EMAIL, False),
450
+ # #861 — the bridge digest is the deliberate exception to the email-OFF
451
+ # default: a PM who is not logged in when the team closes a sprint at 7pm
452
+ # must still be pushed the milestone-confidence shift, or "automatic" still
453
+ # demands she remember to log in (the issue's core blocker). Still fully
454
+ # matrix-overridable — a PM can mute either channel.
455
+ (NotificationEventType.MILESTONE_FORECAST_SHIFTED, NotificationChannel.IN_APP, True),
456
+ (NotificationEventType.MILESTONE_FORECAST_SHIFTED, NotificationChannel.EMAIL, True),
457
+ # #497 — in-app only for v1 (email digest/push explicitly out of scope).
458
+ (NotificationEventType.SPRINT_TASK_RESCHEDULED, NotificationChannel.IN_APP, True),
459
+ (NotificationEventType.SPRINT_TASK_RESCHEDULED, NotificationChannel.EMAIL, False),
460
+ # #1275 / ADR-0104 Amendment B — ceiling-raise proposal discovery. In-app ON so
461
+ # voters see the ask without navigating to Settings; email strictly opt-in OFF
462
+ # (Priya's hard-NO is preserved — un-opted email noise, not the in-app inbox).
463
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_OPENED, NotificationChannel.IN_APP, True),
464
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_OPENED, NotificationChannel.EMAIL, False),
465
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_RESOLVED, NotificationChannel.IN_APP, True),
466
+ (NotificationEventType.SIGNAL_CEILING_PROPOSAL_RESOLVED, NotificationChannel.EMAIL, False),
467
+ # #1115 — project-delete team notification. In-app ON so every member learns
468
+ # their project was deleted; email opt-in OFF (Priya's un-opted-email hard-NO).
469
+ (NotificationEventType.PROJECT_DELETED, NotificationChannel.IN_APP, True),
470
+ (NotificationEventType.PROJECT_DELETED, NotificationChannel.EMAIL, False),
471
+ # ADR-0200 — stale-task daily nudge. In-app ON so the owner sees the reminder in
472
+ # their inbox; email opt-in OFF (Priya's un-opted-email hard-NO). A daily digest
473
+ # of forgotten cards must not become un-asked-for email.
474
+ (NotificationEventType.TASK_STALE, NotificationChannel.IN_APP, True),
475
+ (NotificationEventType.TASK_STALE, NotificationChannel.EMAIL, False),
476
+ # ADR-0232 (#1470) — sprint-close carryover reassignment. In-app ON so the
477
+ # assignee learns their committed work moved across the close→plan seam; email
478
+ # opt-in OFF (Priya's un-opted-email hard-NO).
479
+ (NotificationEventType.TASK_MOVED_SPRINT, NotificationChannel.IN_APP, True),
480
+ (NotificationEventType.TASK_MOVED_SPRINT, NotificationChannel.EMAIL, False),
481
+ # ADR-0412 (#1946) — sprint-membership scope change. In-app ON so the project
482
+ # leads see a mid-sprint injection/removal in their inbox; email opt-in OFF
483
+ # (Priya's un-opted-email hard-NO), matching every other contributor signal.
484
+ (NotificationEventType.SPRINT_MEMBERSHIP_CHANGED, NotificationChannel.IN_APP, True),
485
+ (NotificationEventType.SPRINT_MEMBERSHIP_CHANGED, NotificationChannel.EMAIL, False),
486
+ # #2964 — an amend to committed work. In-app ON so the people whose plan
487
+ # changed see it; email opt-in OFF, matching every other contributor signal.
488
+ (NotificationEventType.PLAN_AMENDED, NotificationChannel.IN_APP, True),
489
+ (NotificationEventType.PLAN_AMENDED, NotificationChannel.EMAIL, False),
490
+ # #1911 — project end-date shift. In-app ON so the PM/Owner cohort sees the
491
+ # slip in their inbox; email opt-in OFF (Priya's un-opted-email hard-NO),
492
+ # matching every other schedule-drift signal in this table.
493
+ (NotificationEventType.PROJECT_END_DATE_SHIFTED, NotificationChannel.IN_APP, True),
494
+ (NotificationEventType.PROJECT_END_DATE_SHIFTED, NotificationChannel.EMAIL, False),
495
+ # #2972 — a board lane removed, a column or view hidden, or the methodology
496
+ # preset switched. In-app ON so the people whose work moved learn it moved
497
+ # without waiting to notice; email opt-in OFF, the same un-opted-email floor
498
+ # every contributor signal in this table keeps.
499
+ (NotificationEventType.PROJECT_CONFIG_CHANGED, NotificationChannel.IN_APP, True),
500
+ (NotificationEventType.PROJECT_CONFIG_CHANGED, NotificationChannel.EMAIL, False),
501
+ # ADR-0663 (#2407) — the scheduled digests are the ONLY rows in this table that
502
+ # default OFF on both channels, and deliberately so. Every entry above fires
503
+ # from something the user did or something that happened to their work, so an
504
+ # in-app row is welcome by default; a weekly clock-driven send is traffic the
505
+ # user has to actually want. Defaulting in_app OFF also makes the feature inert
506
+ # on upgrade: create_event_notifications skips a recipient whose in_app is off,
507
+ # so no digest row exists for anyone until they opt in. Flipping either of these
508
+ # to True is a product decision with a volume cost (ADR-0663 §Risks), not a
509
+ # tidy-up.
510
+ (NotificationEventType.PROGRAM_HEALTH_DIGEST, NotificationChannel.IN_APP, False),
511
+ (NotificationEventType.PROGRAM_HEALTH_DIGEST, NotificationChannel.EMAIL, False),
512
+ (NotificationEventType.RESOURCE_OVERALLOCATION_DIGEST, NotificationChannel.IN_APP, False),
513
+ (NotificationEventType.RESOURCE_OVERALLOCATION_DIGEST, NotificationChannel.EMAIL, False),
514
+ # #3645 — membership grant / role-change. In-app ON so the affected person
515
+ # sees who changed their access and to what; email opt-in OFF, the same
516
+ # un-opted-email floor every contributor signal in this table keeps.
517
+ (NotificationEventType.MEMBERSHIP_GRANTED, NotificationChannel.IN_APP, True),
518
+ (NotificationEventType.MEMBERSHIP_GRANTED, NotificationChannel.EMAIL, False),
519
+ (NotificationEventType.MEMBERSHIP_ROLE_CHANGED, NotificationChannel.IN_APP, True),
520
+ (NotificationEventType.MEMBERSHIP_ROLE_CHANGED, NotificationChannel.EMAIL, False),
521
+ ]
522
+
523
+
524
+ # ---------------------------------------------------------------------------
525
+ # Signal-only preset (#855) — the contributor-friendly minimal profile
526
+ # ---------------------------------------------------------------------------
527
+ # Priya (Team Member) gave a hard-NO on noisy notifications. "Signal-only" is the
528
+ # one-click profile that keeps ON only the two events that mean "something needs
529
+ # my attention" — a task of mine got blocked, or a deadline moved — and turns
530
+ # everything else OFF. It is applied by POST /me/notification-preferences/
531
+ # apply-preset/ (a wholesale write of the per-(event,channel) rows), NOT a new
532
+ # stored model: the matrix stays the single source of truth so the existing
533
+ # data-driven settings page (ADR-0085) keeps rendering whatever rows the server
534
+ # returns. The "everything" preset restores DEFAULT_PREFERENCES.
535
+ SIGNAL_ONLY_EVENTS: frozenset[str] = frozenset(
536
+ {
537
+ NotificationEventType.TASK_BLOCKED,
538
+ NotificationEventType.TASK_DUE_DATE_CHANGED,
539
+ }
540
+ )
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Do-Not-Disturb bypass set (#1707, ADR-0292)
545
+ # ---------------------------------------------------------------------------
546
+ # The account-wide DND switch (UserNotificationSettings.dnd_enabled) silences
547
+ # transient channels (email/push) for routine events. These four events ALWAYS
548
+ # deliver on every channel regardless of DND — the "needs my attention now"
549
+ # signals a muted bell must never swallow: a task of mine got blocked, a team
550
+ # signal-visibility vote opened or resolved, or a milestone forecast shifted.
551
+ #
552
+ # This is deliberately NOT SIGNAL_ONLY_EVENTS: that set is the contributor
553
+ # settings *preset* (task.blocked + task.due_date_changed) and is the wrong
554
+ # membership for a safety bypass — it would let a routine due-date change punch
555
+ # through DND while silencing a signal-ceiling proposal. The two sets are
556
+ # distinct on purpose; keep this membership in step as new critical event types
557
+ # are added (it is the load-bearing safety contract, #1707).
558
+ DND_BYPASS_EVENTS: frozenset[str] = frozenset(
559
+ {
560
+ NotificationEventType.TASK_BLOCKED,
561
+ NotificationEventType.SIGNAL_CEILING_PROPOSAL_OPENED,
562
+ NotificationEventType.SIGNAL_CEILING_PROPOSAL_RESOLVED,
563
+ NotificationEventType.MILESTONE_FORECAST_SHIFTED,
564
+ }
565
+ )
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Project-scoped notification preferences (#522)
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ class ProjectNotificationEventType(models.TextChoices):
574
+ """Project-scoped notification events surfaced on Project > Notifications.
575
+
576
+ Disjoint from `NotificationEventType` (which today only covers @mention
577
+ fan-out for the inbox surface). The project page covers the broader set
578
+ of routing decisions a PM makes per-project — task assignment, slips,
579
+ risk creation, sprint lifecycle, etc.
580
+ """
581
+
582
+ TASK_ASSIGNED = "task_assigned", "Task assigned to me"
583
+ TASK_OVERDUE = "task_overdue", "Task I own is overdue"
584
+ COMMENT_MENTION = "comment_mention", "Mention (@) in a comment"
585
+ STATUS_CHANGE = "status_change", "Task moves to another column"
586
+ BUDGET_ALERT = "budget_alert", "Budget threshold crossed"
587
+ RISK_CREATED = "risk_created", "Risk created or escalated"
588
+ MILESTONE_REACHED = "milestone_reached", "Milestone reached"
589
+ SPRINT_START = "sprint_start", "Sprint started"
590
+ SPRINT_END = "sprint_end", "Sprint closed"
591
+
592
+
593
+ #: Project-scoped events that a delivery path actually consults (#2904).
594
+ #: ``COMMENT_MENTION`` is the only one: ``notifications/services.py`` gates the
595
+ #: mention fan-out on it. Everything else in the enum is declared below.
596
+ PROJECT_NOTIFICATION_DISPATCHED_EVENTS: frozenset[str] = frozenset(
597
+ {ProjectNotificationEventType.COMMENT_MENTION.value}
598
+ )
599
+
600
+ #: Members that exist in the matrix but that **nothing dispatches** — the settings
601
+ #: page renders them, and toggling one has no effect in either direction
602
+ #: (TODO(#3016) wire the dispatchers).
603
+ #:
604
+ #: They are kept rather than deleted because they are a stated product intent, and
605
+ #: the API reports them in ``event_delivery`` as ``false`` so a client can say so
606
+ #: instead of implying a delivery that never happens. Crucially they default **OFF** on
607
+ #: every channel: eight events defaulting ON across in-app, email and Slack told an
608
+ #: admin that eight kinds of notification were being delivered, which made turning
609
+ #: one *off* the only action with any apparent meaning — and it had none either.
610
+ #:
611
+ #: This set and :data:`PROJECT_NOTIFICATION_DISPATCHED_EVENTS` must together cover
612
+ #: the enum exactly; ``test_project_notification_dispatch_coverage`` enforces it, so
613
+ #: a member cannot be added without someone deciding which side it is on.
614
+ PROJECT_NOTIFICATION_UNDISPATCHED_EVENTS: frozenset[str] = frozenset(
615
+ {
616
+ ProjectNotificationEventType.TASK_ASSIGNED.value,
617
+ ProjectNotificationEventType.TASK_OVERDUE.value,
618
+ ProjectNotificationEventType.STATUS_CHANGE.value,
619
+ ProjectNotificationEventType.BUDGET_ALERT.value,
620
+ ProjectNotificationEventType.RISK_CREATED.value,
621
+ ProjectNotificationEventType.MILESTONE_REACHED.value,
622
+ ProjectNotificationEventType.SPRINT_START.value,
623
+ ProjectNotificationEventType.SPRINT_END.value,
624
+ }
625
+ )
626
+
627
+
628
+ class ProjectNotificationChannel(models.TextChoices):
629
+ """Columns of the project notification matrix — not a claim that each delivers.
630
+
631
+ Which of these TruePPM actually delivers on is not restated here: it is
632
+ :data:`PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS` and its complement
633
+ :data:`PROJECT_NOTIFICATION_UNDELIVERABLE_CHANNELS` below, the one place that
634
+ classification is made and the pair a coverage test holds against this enum.
635
+ ``slack`` and ``mobile_push`` are on the undeliverable side: nothing sends to
636
+ them and **no setting anywhere turns them on** — there is no integration to
637
+ configure, which is what the pre-#3378 wording here claimed. They remain as
638
+ columns so a member can record routing intent that applies once delivery ships.
639
+
640
+ This axis is *not* the ADR-0049 ``NOTIFICATION_CHANNELS`` registry — that is the
641
+ account-wide ``NotificationPreference.channel`` vocabulary, registered but not
642
+ yet consumed here (see the module docstring); whether the two converge is #3252.
643
+
644
+ A toggle in the matrix is therefore user intent, and the API says which columns
645
+ that intent can reach today via ``channel_delivery`` (#3378).
646
+ """
647
+
648
+ IN_APP = "in_app", "In-app"
649
+ EMAIL = "email", "Email"
650
+ SLACK = "slack", "Slack"
651
+ MOBILE_PUSH = "mobile_push", "Mobile push"
652
+
653
+
654
+ #: Matrix columns TruePPM has a delivery path for (#3378).
655
+ #:
656
+ #: A literal, deliberately. The obvious move — deriving it from ADR-0049's
657
+ #: ``NOTIFICATION_CHANNELS`` registrations — is wrong twice over. The registry is
658
+ #: the vocabulary of the *account-wide* ``NotificationPreference.channel`` surface,
659
+ #: not of this matrix, and it is registered but not yet consumed (see the module
660
+ #: docstring); whether the two axes converge is #3252's open question, so pinning
661
+ #: this to it would encode a decision nobody has made. And the registry is
662
+ #: populated at ``AppConfig.ready()``, so a value read from it would make the
663
+ #: published OpenAPI schema depend on which apps happened to be loaded when it was
664
+ #: generated — including Enterprise's, which would change the *OSS* schema.
665
+ #:
666
+ #: What keeps the literal honest is
667
+ #: ``tests/apps/notifications/test_project_notification_channel_coverage.py``: the
668
+ #: two sets must cover :class:`ProjectNotificationChannel` exactly and be disjoint,
669
+ #: and an AST walk asserts a channel declared undeliverable is named by no dispatch
670
+ #: site.
671
+ PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS: frozenset[str] = frozenset(
672
+ {
673
+ ProjectNotificationChannel.IN_APP.value,
674
+ ProjectNotificationChannel.EMAIL.value,
675
+ }
676
+ )
677
+
678
+ #: Columns the matrix renders that **nothing delivers on** (TODO(#3252)).
679
+ #:
680
+ #: Not "the integration is not configured" — there is nothing to configure. No code
681
+ #: in ``apps/notifications`` sends to either, and no setting anywhere turns them on.
682
+ #: They are kept as columns because they are a stated product intent and a member's
683
+ #: stored routing choice survives until delivery ships; the API reports them in
684
+ #: ``channel_delivery`` as ``false`` so a client can label the column instead of
685
+ #: rendering a control that cannot do anything.
686
+ PROJECT_NOTIFICATION_UNDELIVERABLE_CHANNELS: frozenset[str] = frozenset(
687
+ {
688
+ ProjectNotificationChannel.SLACK.value,
689
+ ProjectNotificationChannel.MOBILE_PUSH.value,
690
+ }
691
+ )
692
+
693
+
694
+ # Default matrix — applied lazily on first GET (no per-user backfill on join).
695
+ # A cell defaults ON only where both halves of the claim hold: the event has a
696
+ # dispatcher (PROJECT_NOTIFICATION_DISPATCHED_EVENTS) and the channel has a
697
+ # delivery path (PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS). Everything else
698
+ # defaults OFF, because a default of True is a claim that something will be
699
+ # delivered.
700
+ _T = ProjectNotificationEventType
701
+ _C = ProjectNotificationChannel
702
+
703
+
704
+ def _all_off() -> dict[str, bool]:
705
+ """Every channel off — the default for an event nothing dispatches (#2904)."""
706
+ return {_C.IN_APP: False, _C.EMAIL: False, _C.SLACK: False, _C.MOBILE_PUSH: False}
707
+
708
+
709
+ PROJECT_NOTIFICATION_DEFAULT_MATRIX: dict[str, dict[str, bool]] = {
710
+ # Only COMMENT_MENTION has a dispatch path, so it is the only row that may
711
+ # default ON — a default of True is a claim that something will be delivered.
712
+ # Slack and mobile push stay OFF even here: the event IS dispatched, but those
713
+ # two columns are in PROJECT_NOTIFICATION_UNDELIVERABLE_CHANNELS, so ON would be
714
+ # the same false claim one axis over. Flip them ON in the same change that ships
715
+ # their delivery (TODO(#3252)).
716
+ #
717
+ # This changes what a NEW row is seeded with, and nothing else. The JSONField
718
+ # default is materialized in full at row creation and every read path prefers the
719
+ # stored value, so a member who has already opened this page keeps whatever they
720
+ # have — deliberately. Force-clearing a stored cell would destroy a routing
721
+ # intent the whole surface exists to preserve, and it is no longer misleading
722
+ # either way: `channel_delivery` now labels the column on every response.
723
+ _T.COMMENT_MENTION: {
724
+ _C.IN_APP: True,
725
+ _C.EMAIL: True,
726
+ _C.SLACK: False,
727
+ _C.MOBILE_PUSH: False,
728
+ },
729
+ # TODO(#3016): every row below is dispatched by nothing. They default OFF on
730
+ # every channel so the settings matrix does not promise a delivery that never
731
+ # comes; the API reports them in event_delivery as false so a client can label
732
+ # them. Flip a row back to True in the same change that wires its dispatcher.
733
+ _T.TASK_ASSIGNED: _all_off(),
734
+ _T.TASK_OVERDUE: _all_off(),
735
+ _T.STATUS_CHANGE: _all_off(),
736
+ _T.BUDGET_ALERT: _all_off(),
737
+ _T.RISK_CREATED: _all_off(),
738
+ _T.MILESTONE_REACHED: _all_off(),
739
+ _T.SPRINT_START: _all_off(),
740
+ _T.SPRINT_END: _all_off(),
741
+ }
742
+
743
+
744
+ def _default_matrix() -> dict[str, dict[str, bool]]:
745
+ """Deep-copy of the default matrix used as the JSONField default.
746
+
747
+ Django requires a callable; returning the literal would share mutable
748
+ state across rows.
749
+ """
750
+ return {evt: dict(chans) for evt, chans in PROJECT_NOTIFICATION_DEFAULT_MATRIX.items()}
751
+
752
+
753
+ #: Every event type the ``event_delivery`` map reports on, as plain strings.
754
+ #:
755
+ #: The literal key set of :data:`PROJECT_NOTIFICATION_DEFAULT_MATRIX`, which is what
756
+ #: :func:`project_notification_event_delivery` iterates. Named here so the published
757
+ #: OpenAPI schema for ``event_delivery`` can enumerate the same keys from the same
758
+ #: source rather than restating them — a new event type then reaches the declared
759
+ #: contract in the edit that adds it, instead of leaving the schema quietly short a
760
+ #: property (#3396, #3399).
761
+ PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS: tuple[str, ...] = tuple(
762
+ sorted(str(event) for event in PROJECT_NOTIFICATION_DEFAULT_MATRIX)
763
+ )
764
+
765
+
766
+ def project_notification_event_delivery() -> dict[str, bool]:
767
+ """``{event_type: is a dispatcher wired}`` for every matrix row (#2904).
768
+
769
+ Derived from :data:`PROJECT_NOTIFICATION_DISPATCHED_EVENTS` rather than restating
770
+ the classification, so the coverage test that pins that set also pins this map.
771
+
772
+ Lives beside the two constants rather than in the view that returns it because
773
+ the serializer that *declares* the response shape needs the same key set, and a
774
+ second copy in the schema layer is exactly the drift #3399 asked to close.
775
+ """
776
+ return {
777
+ event: event in PROJECT_NOTIFICATION_DISPATCHED_EVENTS
778
+ for event in PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS
779
+ }
780
+
781
+
782
+ #: Every channel the ``channel_delivery`` map reports on, as plain strings (#3378).
783
+ #:
784
+ #: The matrix's own column vocabulary — :class:`ProjectNotificationChannel` — which
785
+ #: is a different, closed set from the ADR-0049 registry keys: Enterprise registers
786
+ #: ``slack_dm`` / ``teams_dm`` / ``sms`` there and none of them is a column here.
787
+ #: Named so the published OpenAPI schema enumerates the same keys from the same
788
+ #: source rather than restating them.
789
+ PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS: tuple[str, ...] = tuple(
790
+ sorted(str(channel.value) for channel in ProjectNotificationChannel)
791
+ )
792
+
793
+
794
+ def project_notification_channel_delivery() -> dict[str, bool]:
795
+ """``{channel: does TruePPM deliver on it}`` for every matrix column (#3378).
796
+
797
+ The per-column counterpart to :func:`project_notification_event_delivery`, and
798
+ the reason it exists is the same: the web client was hard-coding
799
+ ``['slack', 'mobile_push']`` and its own comment said the copy should not
800
+ survive. Whether a channel delivers is server state — it changes in the release a
801
+ delivery path lands — so a client-side copy drifts the moment one does.
802
+
803
+ Derived from :data:`PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS` rather than
804
+ restating the classification, so the coverage test that pins that set also pins
805
+ this map. The two maps are deliberately independent axes: a cell is live only
806
+ when its event is dispatched **and** its channel delivers, which is why a
807
+ per-row badge could never have covered this — ``comment_mention`` *is*
808
+ dispatched and its Slack cell still delivers nothing.
809
+
810
+ Server-global: the answer does not vary by user or by project. It rides on the
811
+ per-user preference document only so a client can label the grid in one render.
812
+ """
813
+ return {
814
+ channel: channel in PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS
815
+ for channel in PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS
816
+ }
817
+
818
+
819
+ class ProjectNotificationPreference(models.Model):
820
+ """Per-(project, user) notification routing matrix and quiet-hours window.
821
+
822
+ One row per user per project. The matrix is a `{event_type: {channel: bool}}`
823
+ JSON document — the row count stays small (one per user per project) and
824
+ PATCH is a single UPDATE rather than a 36-row diff. Missing event/channel
825
+ keys fall back to the default matrix on read, so a stale row won't break
826
+ when the event set grows.
827
+
828
+ schema_version tracks the shape of ``matrix`` (ADR-0086 / ADR-0204, #1916 —
829
+ this surface was named by #522 but missed the #645 registry sweep). On read,
830
+ ``ProjectNotificationPreferenceSerializer`` runs the payload through the
831
+ forward-migration registry (``schema_migrations.migrate_payload``) keyed on
832
+ this column, mirroring ``BoardSavedView.schema_version``. Rows created before
833
+ this field existed are backfilled to ``schema_version=1`` by the migration
834
+ default; every event type in ``PROJECT_NOTIFICATION_DEFAULT_MATRIX`` shipped
835
+ together at #522, so no existing row is actually missing a key and the v0
836
+ path is a no-op for them today — it exists so a *future* event-type addition
837
+ (or an externally-imported row that skipped ``validate`` at write time) has a
838
+ forward-migration path instead of relying solely on the view-layer
839
+ ``_merge_matrix`` overlay.
840
+ """
841
+
842
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
843
+ project = models.ForeignKey(
844
+ _PROJECT_MODEL,
845
+ on_delete=models.CASCADE,
846
+ related_name="notification_preferences_by_user",
847
+ )
848
+ user = models.ForeignKey(
849
+ settings.AUTH_USER_MODEL,
850
+ on_delete=models.CASCADE,
851
+ related_name="project_notification_preferences",
852
+ )
853
+ matrix = models.JSONField(default=_default_matrix)
854
+ schema_version = models.IntegerField(
855
+ default=1,
856
+ help_text="Matrix shape version; upgraded on read via the migration registry (ADR-0086).",
857
+ )
858
+
859
+ # Per-user-per-project kill-switch (#589). When True, all notification
860
+ # dispatch for this user on this project is suppressed regardless of the
861
+ # matrix — an opt-out path for members who haven't dialed in their
862
+ # routing yet. The matrix remains preserved so unpausing restores prior
863
+ # preferences exactly.
864
+ paused = models.BooleanField(default=False)
865
+
866
+ quiet_hours_enabled = models.BooleanField(default=True)
867
+ quiet_hours_from = models.TimeField(default=datetime.time(20, 0))
868
+ quiet_hours_until = models.TimeField(default=datetime.time(7, 0))
869
+
870
+ updated_at = models.DateTimeField(auto_now=True)
871
+
872
+ class Meta:
873
+ db_table = "notifications_project_preference"
874
+ constraints = [
875
+ models.UniqueConstraint(
876
+ fields=["project", "user"],
877
+ name="uq_projnotifpref_project_user",
878
+ ),
879
+ ]
880
+ indexes = [
881
+ models.Index(fields=["project", "user"], name="ix_projnotifpref_proj_user"),
882
+ ]
883
+
884
+ def __str__(self) -> str:
885
+ return f"ProjectNotificationPreference(project={self.project_id}, user={self.user_id})"
886
+
887
+
888
+ class UserNotificationSettings(models.Model):
889
+ """Per-user, account-wide notification settings — the Do-Not-Disturb switch
890
+ (#1707, ADR-0292).
891
+
892
+ One row per user, lazily created on first access
893
+ (:func:`services.get_or_create_notification_settings`) — no backfill on user
894
+ create, mirroring :func:`services.get_or_create_default_preferences`; the
895
+ absence of a row reads as DND off. Distinct in grain from the per-(event,
896
+ channel) :class:`NotificationPreference` matrix and the per-project
897
+ :class:`ProjectNotificationPreference`: this is a single account-wide switch
898
+ that silences transient channels (email/push) across every project, exactly
899
+ as quiet hours gate only transient channels — the durable in-app inbox row is
900
+ never silenced and :data:`DND_BYPASS_EVENTS` always deliver.
901
+
902
+ A plain ``Model`` (no ``server_version``): a personal installation preference,
903
+ never synced or broadcast, mirroring ``ProjectNotificationPreference`` and
904
+ ``profiles.UserProfile``. Shaped to grow a scheduled quiet-hours window + a
905
+ per-user timezone later; only the DND switch ships now (ADR-0292 §4).
906
+ """
907
+
908
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
909
+ user = models.OneToOneField(
910
+ settings.AUTH_USER_MODEL,
911
+ on_delete=models.CASCADE,
912
+ related_name="notification_settings",
913
+ )
914
+ # Account-wide Do-Not-Disturb. When True, transient channels (email/push) are
915
+ # suppressed for all non-bypass events; the durable in-app inbox row is
916
+ # unaffected and DND_BYPASS_EVENTS always deliver on every channel.
917
+ dnd_enabled = models.BooleanField(default=False)
918
+ # Weekly digest slot (ADR-0663, #2407) — the "per-user timezone + scheduled
919
+ # window" this model was documented from the start as being shaped to grow
920
+ # (ADR-0292 §4). ONE slot covers both digest event types: a schedule per event
921
+ # would double the settings surface to serve a case nobody asked for.
922
+ #
923
+ # The slot is only ever *read* by the Beat sweep; it is inert for a user who
924
+ # has not enabled a digest preference, so the defaults below are a starting
925
+ # point for the picker rather than an implied subscription.
926
+ digest_weekday = models.IntegerField(
927
+ default=6,
928
+ choices=[
929
+ (0, "Monday"),
930
+ (1, "Tuesday"),
931
+ (2, "Wednesday"),
932
+ (3, "Thursday"),
933
+ (4, "Friday"),
934
+ (5, "Saturday"),
935
+ (6, "Sunday"),
936
+ ],
937
+ help_text="Day of week for scheduled digests, 0=Monday..6=Sunday (Python weekday()).",
938
+ )
939
+ digest_hour = models.IntegerField(
940
+ default=17,
941
+ help_text="Local hour (0-23) at which scheduled digests are sent.",
942
+ )
943
+ # Blank = fall back to settings.TIME_ZONE. Empty string is the single unset
944
+ # state (project DJ001 convention — no nullable CharFields).
945
+ digest_timezone = models.CharField(
946
+ max_length=64,
947
+ blank=True,
948
+ default="",
949
+ help_text="IANA timezone for the digest slot; blank uses the server timezone.",
950
+ )
951
+ updated_at = models.DateTimeField(auto_now=True)
952
+
953
+ class Meta:
954
+ db_table = "notifications_user_settings"
955
+ verbose_name = "user notification settings"
956
+ verbose_name_plural = "user notification settings"
957
+
958
+ def __str__(self) -> str:
959
+ state = "on" if self.dnd_enabled else "off"
960
+ return f"UserNotificationSettings(user={self.user_id}, dnd={state})"
961
+
962
+
963
+ class NotificationDigestRun(models.Model):
964
+ """Idempotency ledger for scheduled digests (ADR-0663, #2407).
965
+
966
+ One row per ``(user, event_type, period_start)``. The Beat sweep
967
+ ``get_or_create``s the row **before** building content and skips when
968
+ ``created`` is False, so a double-fire, a manual re-queue, or an overlapping
969
+ sweep cannot mail a second copy of the same week's digest. The unique
970
+ constraint is what makes that check atomic across concurrent workers — the
971
+ Redis lock on the task is belt-and-braces, not the primary guard.
972
+
973
+ Creating the row *before* the content build means a mid-build crash leaves a
974
+ "sent" marker for a digest that never arrived. That is deliberate and fails
975
+ closed: a missing weekly digest is recoverable by logging in, whereas a
976
+ duplicate erodes trust in a channel whose entire value is that it is quiet.
977
+
978
+ A plain ``Model`` (no ``server_version``): an operational record, never synced
979
+ to mobile and never broadcast, mirroring :class:`UserNotificationSettings`.
980
+ """
981
+
982
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
983
+ user = models.ForeignKey(
984
+ settings.AUTH_USER_MODEL,
985
+ on_delete=models.CASCADE,
986
+ related_name="digest_runs",
987
+ )
988
+ event_type = models.CharField(max_length=64)
989
+ # User-local week-start date of the send. Date (not datetime) so the ledger
990
+ # grain matches the weekly cadence and a DST-shifted resend collides.
991
+ period_start = models.DateField()
992
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
993
+ # How many inbox rows the send produced. 0 is a legitimate value — an honest
994
+ # "nothing at risk this week" digest is still a send (ADR-0663 §4).
995
+ notification_count = models.PositiveIntegerField(default=0)
996
+
997
+ class Meta:
998
+ db_table = "notifications_digest_run"
999
+ constraints = [
1000
+ models.UniqueConstraint(
1001
+ fields=["user", "event_type", "period_start"],
1002
+ name="uniq_digest_run_user_event_period",
1003
+ ),
1004
+ ]
1005
+ indexes = [
1006
+ # Nightly purge scans by age alone.
1007
+ models.Index(fields=["created_at"], name="digestrun_created_idx"),
1008
+ ]
1009
+
1010
+ def __str__(self) -> str:
1011
+ return f"NotificationDigestRun(user={self.user_id}, {self.event_type}, {self.period_start})"
1012
+
1013
+
1014
+ # ---------------------------------------------------------------------------
1015
+ # Workspace SMTP configuration (#712, ADR-0213)
1016
+ # ---------------------------------------------------------------------------
1017
+
1018
+
1019
+ class EmailTransportMode(models.TextChoices):
1020
+ """Outbound-mail transport for the workspace.
1021
+
1022
+ ``CLOUD`` is the unconfigured default — the send path falls back to the
1023
+ process-global Django ``EMAIL_BACKEND`` (Helm env), so a fresh install
1024
+ behaves exactly as it did before #712. The other three build an SMTP
1025
+ connection from this row; ``SENDGRID`` / ``SES`` are SMTP relays with a
1026
+ provider-fixed host so 0.4 needs no new backend class (ADR-0213 §2).
1027
+ """
1028
+
1029
+ CLOUD = "cloud", "TruePPM cloud (Django settings)"
1030
+ SMTP = "smtp", "Custom SMTP"
1031
+ SENDGRID = "sendgrid", "SendGrid"
1032
+ SES = "ses", "Amazon SES"
1033
+
1034
+
1035
+ class EmailSecurity(models.TextChoices):
1036
+ """SMTP connection security."""
1037
+
1038
+ NONE = "none", "None"
1039
+ TLS = "tls", "STARTTLS"
1040
+ SSL = "ssl", "SSL/TLS"
1041
+
1042
+
1043
+ class WorkspaceEmailSettings(models.Model):
1044
+ """Singleton writable SMTP configuration for the workspace (#712, ADR-0213).
1045
+
1046
+ Installation config, not a synced/board-scoped resource, so there is no
1047
+ ``server_version``. Single-row is enforced by the unique ``singleton_key``
1048
+ (the ``workspace.Workspace`` / ADR-0081 precedent) and the row is lazily
1049
+ created on first access via :meth:`load` — no seed migration.
1050
+
1051
+ The SMTP password is encrypted at rest with Fernet (``password_ciphertext``)
1052
+ reusing ``apps.integrations.encryption`` and is **never** returned by the
1053
+ API — the serializer exposes only :attr:`password_is_set`. Decryption
1054
+ happens server-side only, at send time, in ``resolve_email_connection``.
1055
+ """
1056
+
1057
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
1058
+ # Always 1 — the unique constraint makes a second row impossible.
1059
+ singleton_key = models.PositiveSmallIntegerField(default=1, editable=False, unique=True)
1060
+
1061
+ transport_mode = models.CharField(
1062
+ max_length=16,
1063
+ choices=EmailTransportMode.choices,
1064
+ default=EmailTransportMode.CLOUD,
1065
+ )
1066
+
1067
+ # SMTP transport parameters (unused when transport_mode == CLOUD).
1068
+ host = models.CharField(max_length=255, blank=True, default="")
1069
+ port = models.PositiveIntegerField(default=587)
1070
+ security = models.CharField(
1071
+ max_length=8,
1072
+ choices=EmailSecurity.choices,
1073
+ default=EmailSecurity.TLS,
1074
+ )
1075
+ username = models.CharField(max_length=255, blank=True, default="")
1076
+ # Fernet-encrypted SMTP password / provider API key. Never serialized out.
1077
+ password_ciphertext = models.BinaryField(blank=True, default=b"")
1078
+
1079
+ # From identity + deliverability.
1080
+ from_name = models.CharField(max_length=255, blank=True, default="")
1081
+ from_email = models.EmailField(blank=True, default="")
1082
+ reply_to = models.EmailField(blank=True, default="")
1083
+ dkim_selector = models.CharField(max_length=63, blank=True, default="")
1084
+
1085
+ # Delivery limits. throttle_per_min == 0 means "no throttle".
1086
+ max_recipients = models.PositiveIntegerField(default=50)
1087
+ throttle_per_min = models.PositiveIntegerField(default=0)
1088
+
1089
+ # Bounce webhook — NOT WIRED. Removed from the serializer and the Email
1090
+ # settings UI in #2860: there is no bounce-ingest endpoint anywhere in this
1091
+ # app, and nothing has ever POSTed to this URL or read it back. It persisted,
1092
+ # validated through the SSRF egress guard, rendered back and was documented,
1093
+ # which is exactly what made it look like a working control.
1094
+ #
1095
+ # The column is RETAINED rather than dropped so no operator's stored value is
1096
+ # destroyed; #2872 builds the ingest endpoint and re-exposes the field. Do not
1097
+ # re-add it to the serializer before that lands.
1098
+ bounce_webhook_url = models.URLField(max_length=512, blank=True, default="")
1099
+
1100
+ updated_at = models.DateTimeField(auto_now=True)
1101
+ updated_by = models.ForeignKey(
1102
+ settings.AUTH_USER_MODEL,
1103
+ on_delete=models.SET_NULL,
1104
+ null=True,
1105
+ blank=True,
1106
+ related_name="+",
1107
+ )
1108
+
1109
+ class Meta:
1110
+ db_table = "notifications_workspace_email_settings"
1111
+ verbose_name = "workspace email settings"
1112
+ verbose_name_plural = "workspace email settings"
1113
+
1114
+ def __str__(self) -> str:
1115
+ return f"WorkspaceEmailSettings(transport={self.transport_mode})"
1116
+
1117
+ @classmethod
1118
+ def load(cls) -> WorkspaceEmailSettings:
1119
+ """Return the singleton, creating it on first access (no seed migration)."""
1120
+ obj, _ = cls.objects.get_or_create(singleton_key=1)
1121
+ return obj
1122
+
1123
+ @property
1124
+ def password_is_set(self) -> bool:
1125
+ """Whether an encrypted SMTP password is stored (safe to expose)."""
1126
+ return bool(self.password_ciphertext)
1127
+
1128
+ def set_password(self, plaintext: str) -> None:
1129
+ """Fernet-encrypt and store the SMTP password / provider API key.
1130
+
1131
+ An empty ``plaintext`` clears the stored secret. Callers that want the
1132
+ "blank means keep existing" semantics must guard before calling this —
1133
+ the serializer does (#712 rotate-vs-keep).
1134
+ """
1135
+ from trueppm_api.apps.integrations.encryption import encrypt_secret
1136
+
1137
+ if not plaintext:
1138
+ self.password_ciphertext = b""
1139
+ return
1140
+ self.password_ciphertext = encrypt_secret(plaintext)
1141
+
1142
+ def get_password(self) -> str:
1143
+ """Decrypt and return the stored password, or "" when none is set."""
1144
+ if not self.password_ciphertext:
1145
+ return ""
1146
+ from trueppm_api.apps.integrations.encryption import decrypt_secret
1147
+
1148
+ return decrypt_secret(bytes(self.password_ciphertext))