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,1094 @@
1
+ """DRF serializers for the notifications app (ADR-0075)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import zoneinfo
8
+ from functools import partial
9
+ from typing import Any, cast
10
+
11
+ from drf_spectacular.utils import extend_schema_field
12
+ from rest_framework import serializers
13
+
14
+ from trueppm_api.apps.projects.schema_migrations import migrate_payload
15
+
16
+ from .categories import category_for
17
+ from .delivery_limits import EMAIL_MAX_BATCH_SIZE
18
+ from .models import (
19
+ PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS,
20
+ PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS,
21
+ EmailSecurity,
22
+ EmailTransportMode,
23
+ Mention,
24
+ Notification,
25
+ NotificationPreference,
26
+ ProjectNotificationChannel,
27
+ ProjectNotificationEventType,
28
+ ProjectNotificationPreference,
29
+ UserNotificationSettings,
30
+ WorkspaceEmailSettings,
31
+ project_notification_channel_delivery,
32
+ project_notification_event_delivery,
33
+ )
34
+ from .schema_migrations import SURFACE_PROJECT_NOTIFICATION_MATRIX
35
+ from .services import (
36
+ QUIET_HOURS_TZ_SOURCE_FALLBACK,
37
+ QUIET_HOURS_TZ_SOURCE_PROJECT,
38
+ QUIET_HOURS_TZ_SOURCE_SERVER,
39
+ QUIET_HOURS_TZ_SOURCE_WORKSPACE,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # DKIM selector: a single DNS label component set (letters, digits, dot, dash,
45
+ # underscore), capped to the model's 63-char column. Restricting this prevents
46
+ # DNS-name injection into the deliverability health lookup (security review M3).
47
+ _DKIM_SELECTOR_RE = re.compile(r"^[A-Za-z0-9._-]{1,63}$")
48
+
49
+ # Transports that require a stored/submitted credential. SMTP is the one
50
+ # exception with a carve-out: it is the only transport that may run with NO
51
+ # credential at all (see `_is_no_auth_smtp`, #4190) — an internal relay that
52
+ # accepts unauthenticated connections (Postfix `mynetworks`, an in-cluster
53
+ # relay), which the docs page has always named as a valid "Security: None"
54
+ # use case without any in-app way to configure it. SendGrid and SES are
55
+ # provider-issued credentials with no unauthenticated mode, so they keep
56
+ # requiring one unconditionally.
57
+ _CREDENTIAL_MODES = {
58
+ EmailTransportMode.SMTP,
59
+ EmailTransportMode.SENDGRID,
60
+ EmailTransportMode.SES,
61
+ }
62
+ # Transports where the admin supplies the relay host (SendGrid's host is fixed).
63
+ _HOST_REQUIRED_MODES = {EmailTransportMode.SMTP, EmailTransportMode.SES}
64
+ # Transports where the admin supplies the SMTP AUTH username. SendGrid is excluded
65
+ # because its username is server-fixed to the literal "apikey" (the key travels as
66
+ # the password), and CLOUD stores no credentials at all. SMTP's entry here is
67
+ # skipped when `_is_no_auth_smtp` holds (#4190).
68
+ _USERNAME_REQUIRED_MODES = {EmailTransportMode.SMTP, EmailTransportMode.SES}
69
+
70
+
71
+ def _effective(attrs: dict[str, Any], instance: Any, field: str, default: Any = "") -> Any:
72
+ """Value ``field`` will hold after this write.
73
+
74
+ A PATCH only carries the fields being changed, so validation has to reason
75
+ about the *merged* record: incoming value if the request set one, otherwise
76
+ whatever is already stored.
77
+ """
78
+ if field in attrs:
79
+ return attrs[field]
80
+ return getattr(instance, field, default)
81
+
82
+
83
+ def _is_no_auth_smtp(*, mode: Any, username: str, incoming_password: str) -> bool:
84
+ """True when this save is an explicit, credential-free SMTP config (#4190).
85
+
86
+ Only SMTP may run unauthenticated — a relay on a trusted network that
87
+ accepts unauthenticated connections (Postfix ``mynetworks``, an in-cluster
88
+ relay). Both ``username`` AND ``incoming_password`` must be blank.
89
+ ``incoming_password`` is deliberately the *submitted* value, not the
90
+ merged/effective one: password carries rotate-vs-keep secrecy (an
91
+ omitted/blank submission normally means "keep the stored secret"), so
92
+ reading the effective value here would read "I didn't touch the password
93
+ field" as "no auth" even when a credential is already stored under the
94
+ row — exactly the stale-secret survival this fix closes. ``username`` has
95
+ no such secrecy, so its merged/effective value is safe to use elsewhere in
96
+ this module; this function is the one place that must not.
97
+ """
98
+ return mode == EmailTransportMode.SMTP and not username and not incoming_password
99
+
100
+
101
+ def _assert_transport_fields(
102
+ *,
103
+ mode: Any,
104
+ host: str,
105
+ username: str,
106
+ effective_pw: str,
107
+ needs_fresh_password: bool,
108
+ no_auth: bool,
109
+ ) -> None:
110
+ """Reject a transport config that is missing something the transport needs.
111
+
112
+ Runs before the live probe so a config that cannot possibly work is refused
113
+ without opening a connection. ``no_auth`` (#4190) short-circuits the
114
+ username/password checks for the one transport that may legitimately run
115
+ with neither: a Custom SMTP relay on a trusted network that accepts
116
+ unauthenticated connections. It is never true for SendGrid/SES, which keep
117
+ requiring a credential unconditionally.
118
+ """
119
+ # Reusing a stale secret across a transport switch is a footgun — a
120
+ # SendGrid API key is not an SES password (security review M2). Not
121
+ # applicable when switching *into* a no-auth SMTP config: there is no
122
+ # password to re-enter because none is being kept (validate() forces
123
+ # effective_pw to "" and update() clears the stored ciphertext).
124
+ if needs_fresh_password and mode in _CREDENTIAL_MODES:
125
+ raise serializers.ValidationError(
126
+ {"password": "Re-enter the password when changing the transport."}
127
+ )
128
+
129
+ if mode in _HOST_REQUIRED_MODES and not host:
130
+ raise serializers.ValidationError({"host": "Host is required for this transport."})
131
+
132
+ if no_auth:
133
+ # An explicit, unambiguous choice: both username and password are
134
+ # blank. Django's SMTP backend already skips login() when both are
135
+ # empty, so there is nothing further to validate — the probe below
136
+ # opens a plain unauthenticated connection.
137
+ return
138
+
139
+ # SMTP AUTH is a (username, password) pair, so a blank username paired
140
+ # with a non-blank password is not a weaker credential — it is a
141
+ # credential that can never be used (Django's SMTP backend calls login()
142
+ # only when both are truthy), so the probe would open an unauthenticated
143
+ # connection that succeeds and persist a config whose every real send then
144
+ # fails 530 Authentication Required. Requiring the pair here is what makes
145
+ # the validate-before-persist gate honest. (A blank username with a blank
146
+ # password is the `no_auth` branch above, not this one.)
147
+ if mode in _USERNAME_REQUIRED_MODES and not username:
148
+ hint = (
149
+ " Leave both Username and Password blank instead to use no "
150
+ "authentication (Custom SMTP only)."
151
+ if mode == EmailTransportMode.SMTP
152
+ else ""
153
+ )
154
+ raise serializers.ValidationError(
155
+ {
156
+ "username": (
157
+ "A username is required for this transport — the mail server "
158
+ "authenticates as this account. It is usually the full email "
159
+ "address that owns the password or app password." + hint
160
+ )
161
+ }
162
+ )
163
+
164
+ if not effective_pw:
165
+ raise serializers.ValidationError(
166
+ {"password": "A password is required for this transport."}
167
+ )
168
+
169
+
170
+ def _probe_or_raise(
171
+ *,
172
+ mode: Any,
173
+ host: str,
174
+ port: Any,
175
+ security: Any,
176
+ username: str,
177
+ password: str,
178
+ ) -> None:
179
+ """Validate-before-persist: open the candidate transport now.
180
+
181
+ Distinguishes an SSRF-egress-blocked host from every other connect failure
182
+ (#4189): the two used to share one generic "check the host, port, security,
183
+ and credentials" message, which sent an operator whose relay is simply on a
184
+ private network — and whose credentials are fine — looking in the wrong
185
+ place. ``EmailHostNotAllowedError``'s message is curated by
186
+ ``email_backend._assert_host_public`` to never leak the DNS-resolved
187
+ address, so it is safe to surface to the client verbatim. Every other
188
+ failure still gets the generic message only — never the underlying
189
+ smtplib exception (M1).
190
+ """
191
+ from .email_backend import EmailHostNotAllowedError, EmailTransportError, probe_transport
192
+
193
+ try:
194
+ probe_transport(
195
+ transport_mode=str(mode),
196
+ host=host,
197
+ port=int(port),
198
+ security=str(security),
199
+ username=username,
200
+ password=password,
201
+ )
202
+ except EmailHostNotAllowedError as exc:
203
+ logger.info(
204
+ "email transport probe blocked by egress guard (host=%s port=%s): %s",
205
+ host,
206
+ port,
207
+ exc,
208
+ )
209
+ raise serializers.ValidationError({"non_field_errors": [str(exc)]}) from exc
210
+ except EmailTransportError as exc:
211
+ # Curated message only — never surface the exception object. The
212
+ # SSRF-guarded host path can carry the DNS-resolved internal address
213
+ # (scrubbed from the client at email_backend._assert_host_public, and
214
+ # caught above before reaching here); log the detail server-side
215
+ # (CodeQL py/stack-trace-exposure).
216
+ logger.info("email transport probe failed (host=%s port=%s): %s", host, port, exc)
217
+ raise serializers.ValidationError(
218
+ {
219
+ "non_field_errors": [
220
+ "Could not connect to the mail server with these settings. "
221
+ "Check the host, port, security, and credentials."
222
+ ]
223
+ }
224
+ ) from exc
225
+
226
+
227
+ class MentionAuthorSerializer(serializers.Serializer[Any]):
228
+ """Nested read-only user summary for Mention.mentioner / mentioned_user."""
229
+
230
+ id = serializers.UUIDField(read_only=True)
231
+ username = serializers.CharField(read_only=True)
232
+ display_name = serializers.SerializerMethodField()
233
+
234
+ def get_display_name(self, obj: Any) -> str:
235
+ name: str = obj.get_full_name() or obj.username
236
+ return name
237
+
238
+
239
+ class MentionSerializer(serializers.ModelSerializer[Mention]):
240
+ """Read-only serializer for Mention.
241
+
242
+ Mentions are append-only — no write path through this serializer. Created
243
+ indirectly via TaskCommentSerializer.create + services.create_mention_notifications.
244
+ """
245
+
246
+ mentioner = MentionAuthorSerializer(read_only=True)
247
+ mentioned_user = MentionAuthorSerializer(read_only=True)
248
+
249
+ class Meta:
250
+ model = Mention
251
+ fields = [
252
+ "id",
253
+ "mentioner",
254
+ "mentioned_user",
255
+ "mentioned_group_key",
256
+ "scope",
257
+ "task_comment",
258
+ "created_at",
259
+ ]
260
+ read_only_fields = fields
261
+
262
+
263
+ PROJECT_DELETED_EVENT = "project.deleted"
264
+
265
+
266
+ def recipient_member_project_sets(user: Any) -> tuple[set[Any], set[Any]]:
267
+ """Return ``(live, including_trashed)`` project-id sets for ``user``'s memberships.
268
+
269
+ One query serves both: ``live`` floors out projects in Trash (#4577 — a trashed
270
+ project confers no role), ``including_trashed`` keeps them for the single event
271
+ (``project.deleted``) whose whole purpose is to tell the member about the trash.
272
+ Revoked memberships are excluded from both.
273
+ """
274
+ from trueppm_api.apps.access.models import ProjectMembership
275
+
276
+ live: set[Any] = set()
277
+ everything: set[Any] = set()
278
+ for project_id, trashed in ProjectMembership.objects.filter(
279
+ user=user, is_deleted=False
280
+ ).values_list("project_id", "project__is_deleted"):
281
+ everything.add(project_id)
282
+ if not trashed:
283
+ live.add(project_id)
284
+ return live, everything
285
+
286
+
287
+ class NotificationSerializer(serializers.ModelSerializer[Notification]):
288
+ """Per-recipient inbox row.
289
+
290
+ Writes are limited to is_read / is_archived (PATCH-only on the viewset).
291
+ All other fields are read-only.
292
+ """
293
+
294
+ mention = MentionSerializer(read_only=True, allow_null=True)
295
+ snippet = serializers.SerializerMethodField()
296
+ task_id = serializers.SerializerMethodField()
297
+ # Derived read-only category (ADR-0216 §3) — mentions | tasks | signals |
298
+ # project. Sourced from categories.category_for so the field and the
299
+ # ?category= filter share one mapping and never drift.
300
+ category = serializers.SerializerMethodField()
301
+
302
+ def get_category(self, obj: Notification) -> str:
303
+ return category_for(obj)
304
+
305
+ def get_snippet(self, obj: Notification) -> str:
306
+ """Short preview of the source mention's body.
307
+
308
+ Truncated to 200 chars. Returns empty string if the source is
309
+ unavailable (e.g. comment soft-deleted).
310
+
311
+ Cross-project gate (#514): the snippet is a project member's-eyes view
312
+ of the source comment body, so it is returned ONLY to a recipient who
313
+ is a *current* member of the mention's source project. Program-scoped
314
+ auto-groups (`@program-*`) fan a mention out to members of sibling
315
+ projects who are NOT members of the source project — for them the row
316
+ still surfaces (they know they were pinged) but the body is redacted, so
317
+ a program ping never leaks one project's comment content to another
318
+ project's team. This also honors the standing rule that a departed
319
+ member no longer sees the body. Project membership is the read boundary
320
+ in OSS; the program is a coordination unit, not a shared-content unit.
321
+ """
322
+ mention = obj.mention
323
+ if mention is None:
324
+ return ""
325
+ comment = mention.task_comment
326
+ if comment is None or comment.is_deleted:
327
+ return ""
328
+ if not self._recipient_can_see_project(obj):
329
+ return ""
330
+ body = comment.body or ""
331
+ return body[:200]
332
+
333
+ def _recipient_can_see_project(self, obj: Notification) -> bool:
334
+ """True if the notification's recipient currently belongs to ``obj.project``.
335
+
336
+ Generalizes the mention-only #514 gate (formerly
337
+ ``_recipient_can_see_source``, keyed on ``mention.project_id``) to every
338
+ notification, mention- or event-sourced (#3510): ``Notification.project_id``
339
+ is set at dispatch for both — the event-sourced fan-outs in ``services.py``
340
+ write it directly, and the mention fan-out (``_build_mention_notification``)
341
+ writes the same ``project_id`` the mention's source project resolved to — so
342
+ one project-membership check now backs the mention ``snippet``
343
+ redaction, the ``subject``/``body``/``project`` redaction in
344
+ :meth:`to_representation`, and — since #3674 — the ``task_id`` /
345
+ ``mention.task_comment`` object-reference redaction alongside it. A
346
+ notification with no project at all (the
347
+ ADR-0663 account-scoped digest rows, which span the recipient's whole
348
+ membership set rather than one project) has no project boundary to check
349
+ against, so it is always visible.
350
+
351
+ The set of the recipient's member projects is resolved once per response
352
+ and memoized on the serializer context, so the inbox list stays O(1)
353
+ queries rather than one membership query per row. Falls open only when
354
+ there is no request/user context at all (e.g. a bare unit test
355
+ serializing without a request) — every real read path (the viewset)
356
+ supplies it.
357
+ """
358
+ project_id = obj.project_id
359
+ if project_id is None:
360
+ return True
361
+ member_project_ids = self.context.get("member_project_ids")
362
+ if member_project_ids is None:
363
+ request = self.context.get("request")
364
+ user = getattr(request, "user", None) if request is not None else None
365
+ if user is None or not getattr(user, "is_authenticated", False):
366
+ return True # no user context to gate against — preserve behavior
367
+ member_project_ids, trashed_inclusive = recipient_member_project_sets(user)
368
+ # DRF's serializer context is a plain dict at runtime (typed Mapping
369
+ # in the stubs); memoize so the list path resolves membership once.
370
+ ctx = cast(dict[str, Any], self.context)
371
+ ctx["member_project_ids"] = member_project_ids
372
+ ctx["member_project_ids_incl_trashed"] = trashed_inclusive
373
+ if obj.event_type == PROJECT_DELETED_EVENT:
374
+ # The "project was deleted" notice is written *because* the project is
375
+ # now in Trash, so the trashed-project floor would blank the one message
376
+ # that tells the member where to restore it. The revoked-membership
377
+ # gate still applies to it.
378
+ return project_id in self.context.get(
379
+ "member_project_ids_incl_trashed", member_project_ids
380
+ )
381
+ return project_id in member_project_ids
382
+
383
+ def to_representation(self, instance: Notification) -> dict[str, Any]:
384
+ """Redact ``subject``/``body``/``project`` (#3510) + the source object refs (#3674).
385
+
386
+ ``subject`` and ``body`` are free text rendered from the project and task
387
+ names at dispatch time (see the ``Notification(...)`` fan-outs in
388
+ ``services.py``), and ``project`` is the FK to the row's source project.
389
+ All three are frozen at dispatch and, before this change, were served
390
+ unredacted forever after — including to a user removed from the project,
391
+ who kept an inbox that went on naming that project's tasks indefinitely.
392
+ ``snippet`` already redacted the same class of content one field over
393
+ (#514); this extends that same treatment to the rest of the row instead
394
+ of re-flooring the whole read against current membership.
395
+
396
+ Retention position (#3510): a delivered notification is a record of a
397
+ message that was sent, not a live view of the project — so the row is
398
+ kept and its content is redacted, matching ``snippet``'s existing
399
+ behavior. Redacted values reuse shapes the client already handles:
400
+ ``subject``/``body`` blank to ``""`` (their normal non-null default), and
401
+ ``project`` becomes ``None`` — already the shape of an ADR-0663
402
+ account-scoped digest row with no single owning project. No schema
403
+ change, no new client-side branch.
404
+
405
+ Object references (#3674). ``mention.task_comment`` — a raw comment UUID
406
+ — is redacted to ``None`` on the same gate, as is ``task_id`` (in
407
+ :meth:`get_task_id`). The rest of the nested ``mention`` object is
408
+ deliberately KEPT. The dividing line is not which population is reading
409
+ (see below) but what the field *is*:
410
+
411
+ * **Redacted — opaque handles into the source project's content graph.**
412
+ ``task_id`` and ``mention.task_comment`` are UUIDs. They say nothing to
413
+ a human and exist only to be handed back to another endpoint, which is
414
+ precisely the residual-IDOR surface #3674 was filed on. Nobody who
415
+ fails this gate can spend one: ``TaskViewSet`` and the comment routes
416
+ floor on current membership. So redacting them removes an object
417
+ reference and zero capability.
418
+ * **Kept — the "you were pinged" signal, which is not a project-content
419
+ reference.** ``mentioner`` is the author's username/display name;
420
+ ``mentioned_user`` is null (group mention) or the recipient themselves
421
+ (``_resolve_mention_recipients`` keys a direct mention by its own
422
+ target); ``mentioned_group_key`` is a group the recipient belongs to —
423
+ that is *how* they were reached; ``scope`` is a 3-value enum. None of
424
+ these name a task, a project, or a comment. ``mention.id`` is kept with
425
+ them: it is a handle, but to an object no endpoint serves, so keeping it
426
+ grants nothing.
427
+
428
+ Why this does NOT need to distinguish the two populations that reach this
429
+ branch. ``_recipient_can_see_project`` is one predicate over two groups:
430
+ a member whose access was revoked, and a sibling-project member pinged
431
+ through a ``@program-*`` auto-group who was never a member of the source
432
+ project and was never intentionally denied (the #514 population). They
433
+ differ in intent, but they are identical in capability — both are gated
434
+ out of every endpoint a task or comment UUID could be spent at — and the
435
+ fields where their intents *would* diverge are exactly the ones kept
436
+ above. So the honest #514 promise, "the row still surfaces, they know
437
+ they were pinged", survives intact: a redacted mention row still renders
438
+ "Authoria mentioned @program-pms". What it loses is a deep link that was
439
+ already dead for that reader (the client short-circuits on
440
+ ``project === null`` before it ever reads ``task_id``). Widening the
441
+ redaction to ``mentioner``/``mentioned_group_key`` WOULD reverse #514 —
442
+ it would leave a row that says only "something happened" — and would
443
+ need a discriminator this row does not carry; that is not done here and
444
+ is not needed for the leak #3674 names.
445
+ """
446
+ data = super().to_representation(instance)
447
+ if not self._recipient_can_see_project(instance):
448
+ data["subject"] = ""
449
+ data["body"] = ""
450
+ data["project"] = None
451
+ mention = data.get("mention")
452
+ if mention is not None:
453
+ # Reuses a shape the client already handles: a mention row whose
454
+ # source comment was soft-deleted serializes with a null FK and a
455
+ # blank snippet, which is exactly what a redacted row now looks
456
+ # like. No schema change, no new client-side branch.
457
+ mention["task_comment"] = None
458
+ return data
459
+
460
+ def get_task_id(self, obj: Notification) -> str | None:
461
+ # Deliberately comment-only, not a docstring: drf-spectacular publishes a
462
+ # SerializerMethodField's docstring as the field `description` in
463
+ # docs/api/openapi.json, which would put this internal reasoning (and
464
+ # issue numbers) in the public contract.
465
+ #
466
+ # Redacted on the same membership predicate as `snippet` (#514) and
467
+ # `subject`/`body`/`project` (#3510) — see `to_representation` for why a
468
+ # raw task UUID goes while the mention's identity fields stay. Checked
469
+ # before the FK is read so a redacted row costs no attribute walk.
470
+ if not self._recipient_can_see_project(obj):
471
+ return None
472
+ # Event-sourced rows (#497/#861) carry a direct deep-link FK; mention
473
+ # rows resolve their task through the source comment. Prefer the explicit
474
+ # FK so the inbox row links to the affected task/milestone either way.
475
+ if obj.task_id is not None:
476
+ return str(obj.task_id)
477
+ mention = obj.mention
478
+ if mention is None or mention.task_comment is None:
479
+ return None
480
+ return str(mention.task_comment.task_id)
481
+
482
+ class Meta:
483
+ model = Notification
484
+ # event_type/subject/body are the event-sourced inbox payload (#639,
485
+ # #497, #861). They were inbox-invisible before — a mention row leaves
486
+ # them blank and renders from `snippet`; an event row has no mention, so
487
+ # without these fields the client could not show its title/preview and
488
+ # fell back to "mentioned you / comment unavailable".
489
+ fields = [
490
+ "id",
491
+ "recipient",
492
+ "mention",
493
+ "event_type",
494
+ "subject",
495
+ "body",
496
+ "project",
497
+ "is_read",
498
+ "is_archived",
499
+ "snoozed_until",
500
+ "category",
501
+ "created_at",
502
+ "read_at",
503
+ "snippet",
504
+ "task_id",
505
+ ]
506
+ read_only_fields = [
507
+ "id",
508
+ "recipient",
509
+ "mention",
510
+ "event_type",
511
+ "subject",
512
+ "body",
513
+ "project",
514
+ # snoozed_until is written by the dedicated snooze action, never PATCH.
515
+ "snoozed_until",
516
+ "category",
517
+ "created_at",
518
+ "read_at",
519
+ "snippet",
520
+ "task_id",
521
+ ]
522
+
523
+
524
+ class NotificationPreferenceSerializer(serializers.ModelSerializer[NotificationPreference]):
525
+ """Per-user (event_type, channel) toggle.
526
+
527
+ PATCH on the viewset only modifies `enabled` — event_type / channel /
528
+ user are immutable.
529
+ """
530
+
531
+ class Meta:
532
+ model = NotificationPreference
533
+ fields = ["id", "event_type", "channel", "enabled", "updated_at"]
534
+ read_only_fields = ["id", "event_type", "channel", "updated_at"]
535
+
536
+
537
+ # ---------------------------------------------------------------------------
538
+ # Project-scoped preferences (#522)
539
+ # ---------------------------------------------------------------------------
540
+
541
+
542
+ _VALID_EVENT_TYPES = {choice.value for choice in ProjectNotificationEventType}
543
+ _VALID_CHANNELS = {choice.value for choice in ProjectNotificationChannel}
544
+
545
+
546
+ #: The published shape of the notification matrix (#3364).
547
+ #:
548
+ #: A bare ``JSONField`` carries no schema hint, so drf-spectacular publishes it as
549
+ #: ``{}`` — "any JSON at all". That is not a small imprecision on this field: it is
550
+ #: the only thing ``PATCH .../notification-preferences/`` exists to write, and the
551
+ #: field below rejects every key outside the two enumerations with a 400. Declaring
552
+ #: the body while leaving its one field untyped would tell an integrator "send some
553
+ #: JSON" and 400 on every guess — the same gap #3364 closes one level up.
554
+ #:
555
+ #: Derived from the enums rather than restated, so a new event type or channel
556
+ #: reaches the published contract without a second edit that can be forgotten.
557
+ #:
558
+ #: ``propertyNames`` is JSON Schema, not OpenAPI 3.0 Schema Object — this project
559
+ #: publishes ``openapi: 3.0.3`` (see ``docs/api/openapi.json``), whose meta-schema
560
+ #: has no such keyword and rejects the whole object under ``additionalProperties``'
561
+ #: ``oneOf`` the moment it appears anywhere in the tree. The two enumerations are
562
+ #: small and closed, so each key is spelled out explicitly under ``properties``
563
+ #: instead, with ``additionalProperties: False`` doing the same "no unknown key"
564
+ #: rejection ``propertyNames`` would have — this is what "9×4 grid" means literally.
565
+ _PROJECT_NOTIFICATION_MATRIX_SCHEMA: dict[str, Any] = {
566
+ "type": "object",
567
+ "description": (
568
+ "Per-event, per-channel routing grid. Keys are event types; each value is "
569
+ "an object of channel to boolean. A PATCH may send a partial grid — the "
570
+ "supplied cells are merged onto the stored ones, so a single toggle need "
571
+ "not repost the whole matrix. Any key outside these two enumerations, or "
572
+ "any non-boolean leaf, is rejected with a 400."
573
+ ),
574
+ "properties": {
575
+ event_type: {
576
+ "type": "object",
577
+ "properties": {channel: {"type": "boolean"} for channel in sorted(_VALID_CHANNELS)},
578
+ "additionalProperties": False,
579
+ }
580
+ for event_type in sorted(_VALID_EVENT_TYPES)
581
+ },
582
+ "additionalProperties": False,
583
+ }
584
+
585
+
586
+ @extend_schema_field(_PROJECT_NOTIFICATION_MATRIX_SCHEMA)
587
+ class _ProjectNotificationMatrixField(serializers.JSONField):
588
+ """JSON matrix `{event_type: {channel: bool}}` with strict key validation.
589
+
590
+ Rejects unknown event types and channels at the serializer layer so a
591
+ typo in the client doesn't silently corrupt the stored matrix. Non-bool
592
+ leaves are also rejected — there is no "tri-state" toggle.
593
+ """
594
+
595
+ def to_internal_value(self, data: Any) -> dict[str, dict[str, bool]]:
596
+ value = super().to_internal_value(data)
597
+ if not isinstance(value, dict):
598
+ raise serializers.ValidationError("matrix must be a JSON object")
599
+ validated: dict[str, dict[str, bool]] = {}
600
+ for event_type, channels in value.items():
601
+ if event_type not in _VALID_EVENT_TYPES:
602
+ raise serializers.ValidationError(f"Unknown event_type: {event_type!r}")
603
+ if not isinstance(channels, dict):
604
+ raise serializers.ValidationError(
605
+ f"matrix[{event_type!r}] must be an object of channel→bool"
606
+ )
607
+ row: dict[str, bool] = {}
608
+ for channel, enabled in channels.items():
609
+ if channel not in _VALID_CHANNELS:
610
+ raise serializers.ValidationError(f"Unknown channel: {channel!r}")
611
+ if not isinstance(enabled, bool):
612
+ raise serializers.ValidationError(
613
+ f"matrix[{event_type!r}][{channel!r}] must be a boolean"
614
+ )
615
+ row[channel] = enabled
616
+ validated[event_type] = row
617
+ return validated
618
+
619
+
620
+ class ProjectNotificationPreferenceSerializer(
621
+ serializers.ModelSerializer[ProjectNotificationPreference]
622
+ ):
623
+ """Per-(project, user) notification preferences.
624
+
625
+ The full matrix and quiet-hours window are returned in one document; the
626
+ PATCH path merges the supplied matrix onto the stored one so a partial
627
+ toggle does not have to repost the full 9×4 grid.
628
+
629
+ On read, matrix is run through the forward-migration registry
630
+ (``schema_migrations.migrate_payload``, ADR-0086 / ADR-0204, #1916): a
631
+ stale stored payload is upgraded to the current shape before any client
632
+ sees it, mirroring ``BoardSavedViewSerializer``. The view layer's
633
+ ``_merge_matrix`` overlay still runs afterward (it also drops unknown
634
+ garbage keys, #675 — a distinct concern from the version upgrade), so this
635
+ is defense-in-depth for any caller that reads this serializer directly
636
+ rather than through ``ProjectNotificationPreferenceView``.
637
+ """
638
+
639
+ matrix = _ProjectNotificationMatrixField()
640
+ # The window is stored as a bare wall-clock range, so without these two the
641
+ # governed user cannot tell what "22:00" means (#3377). Workspace.timezone is
642
+ # itself member-readable (IsWorkspaceAdmin admits any role on safe methods), so
643
+ # this is not a disclosure question — it is that resolving the window client-side
644
+ # means re-implementing a four-tier chain across three models and keeping it in
645
+ # sync, and the winning *tier* is not derivable from the values at all. If
646
+ # GET /workspace/ is ever narrowed to IsWorkspaceAdminStrict (#1724), revisit
647
+ # whether these two should follow it; today they expose nothing new.
648
+ quiet_hours_timezone = serializers.SerializerMethodField()
649
+ quiet_hours_timezone_source = serializers.SerializerMethodField()
650
+
651
+ class Meta:
652
+ model = ProjectNotificationPreference
653
+ fields = [
654
+ "matrix",
655
+ "schema_version",
656
+ "paused",
657
+ "quiet_hours_enabled",
658
+ "quiet_hours_from",
659
+ "quiet_hours_until",
660
+ "quiet_hours_timezone",
661
+ "quiet_hours_timezone_source",
662
+ "updated_at",
663
+ ]
664
+ # quiet_hours_timezone* are SerializerMethodFields — inherently read-only, and
665
+ # DRF forbids naming a declared field here.
666
+ read_only_fields = ["schema_version", "updated_at"]
667
+
668
+ def _resolved_quiet_hours_tz(self, instance: ProjectNotificationPreference) -> tuple[str, str]:
669
+ """``(IANA name, tier)`` for this row's window, memoized per project.
670
+
671
+ Memoized because both method fields call it for the same row; keyed on the
672
+ project so a serializer instance reused across projects cannot report one
673
+ project's zone for another.
674
+
675
+ ``instance.project`` costs no query because the view primes the FK cache —
676
+ ``get_or_create`` populates ``fields_cache`` on its create branch but not on
677
+ the get branch, which is the common one.
678
+
679
+ The workspace tier is left to the resolver rather than pre-loaded into
680
+ ``context``. Pre-loading it would be wrong twice over on this endpoint: it
681
+ queries even when the project sets its own timezone and the chain never
682
+ reaches the workspace, and — because the natural pre-load is
683
+ ``Workspace.load()``, a get_or_create — a *read* of this endpoint on an
684
+ install with no singleton would create the row and then report
685
+ ``source="workspace"``, where the dispatcher (which never writes) would have
686
+ reported ``"server"``. The whole point of these fields is to say what dispatch
687
+ does. ``context["workspace"]`` is still honored for a caller that has one in
688
+ hand, matching ``projects.bulk_settings``.
689
+ """
690
+ from trueppm_api.apps.notifications.services import resolve_quiet_hours_timezone
691
+
692
+ cache: dict[Any, tuple[str, str]] = getattr(self, "_quiet_hours_tz_cache", {})
693
+ key = instance.project_id
694
+ if key not in cache:
695
+ zone, source = resolve_quiet_hours_timezone(
696
+ instance.project, workspace=self.context.get("workspace")
697
+ )
698
+ cache[key] = (zone.key, source)
699
+ self._quiet_hours_tz_cache = cache
700
+ return cache[key]
701
+
702
+ @extend_schema_field(serializers.CharField())
703
+ def get_quiet_hours_timezone(self, instance: ProjectNotificationPreference) -> str:
704
+ """IANA zone the stored ``quiet_hours_from``/``_until`` are interpreted in."""
705
+ return self._resolved_quiet_hours_tz(instance)[0]
706
+
707
+ @extend_schema_field(
708
+ serializers.ChoiceField(
709
+ choices=[
710
+ QUIET_HOURS_TZ_SOURCE_PROJECT,
711
+ QUIET_HOURS_TZ_SOURCE_WORKSPACE,
712
+ QUIET_HOURS_TZ_SOURCE_SERVER,
713
+ QUIET_HOURS_TZ_SOURCE_FALLBACK,
714
+ ]
715
+ )
716
+ )
717
+ def get_quiet_hours_timezone_source(self, instance: ProjectNotificationPreference) -> str:
718
+ """Which tier supplied it: ``project``, ``workspace``, ``server`` or ``fallback``.
719
+
720
+ Provenance, not decoration — ``project`` vs ``workspace`` is the difference
721
+ between "my project lead set this" and "the workspace admin did", and
722
+ ``fallback`` says the chain found nothing usable and landed on UTC.
723
+ """
724
+ return self._resolved_quiet_hours_tz(instance)[1]
725
+
726
+ def to_representation(self, instance: ProjectNotificationPreference) -> dict[str, Any]:
727
+ """Upgrade the stored matrix to the current shape on read.
728
+
729
+ The stored payload's version is the model's ``schema_version`` column
730
+ (rows predating the convention carry the migration default of 1 and
731
+ are already at the current 9-event shape, so the chain is a no-op for
732
+ them). Passing it explicitly keeps the column as the single source of
733
+ truth for "which shape is this" rather than sniffing the payload.
734
+ """
735
+ data = super().to_representation(instance)
736
+ matrix = data.get("matrix")
737
+ if isinstance(matrix, dict):
738
+ upgraded, version = migrate_payload(
739
+ SURFACE_PROJECT_NOTIFICATION_MATRIX,
740
+ matrix,
741
+ from_version=instance.schema_version,
742
+ )
743
+ # schema_version is exposed as a sibling metadata field, not mixed
744
+ # into the matrix event/channel grid — keep matrix the clean grid.
745
+ upgraded.pop("schema_version", None)
746
+ data["matrix"] = upgraded
747
+ data["schema_version"] = version
748
+ return data
749
+
750
+
751
+ #: The published shape of ``event_delivery`` (#3396, #3399).
752
+ #:
753
+ #: Enumerated key-by-key rather than left as ``additionalProperties: {type: boolean}``
754
+ #: because the key set is closed and derivable: it is exactly
755
+ #: :data:`PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS`, the same tuple
756
+ #: :func:`project_notification_event_delivery` iterates. Deriving it is the pin
757
+ #: #3399 asked for — the declaration cannot report a different set of events than
758
+ #: the classification does, because there is only one list. An open
759
+ #: ``additionalProperties`` map would have typed the *values* and said nothing about
760
+ #: which rows a client can expect, which is the half a client actually indexes into.
761
+ #:
762
+ #: The booleans themselves are deliberately NOT pinned to today's classification.
763
+ #: Which rows are wired is runtime state that moves as dispatchers land
764
+ #: (TODO(#3016)); baking ``comment_mention: true`` into the schema would turn the
765
+ #: contract into a lie on the day the second dispatcher ships.
766
+ #:
767
+ #: ``propertyNames`` is JSON Schema and not OpenAPI 3.0 — see
768
+ #: :data:`_PROJECT_NOTIFICATION_MATRIX_SCHEMA` for why this project cannot use it.
769
+ _PROJECT_NOTIFICATION_EVENT_DELIVERY_SCHEMA: dict[str, Any] = {
770
+ "type": "object",
771
+ "description": (
772
+ "Which matrix rows are wired to a delivery path. `true` means toggling that "
773
+ "event's channels changes what gets delivered; `false` means the row is "
774
+ "rendered and stored but nothing dispatches it yet, so a client should label "
775
+ "it rather than imply a delivery that never happens. Reported as a server "
776
+ "fact so every client does not hard-code the same list and drift from it."
777
+ ),
778
+ "properties": {
779
+ event_type: {"type": "boolean"}
780
+ for event_type in PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS
781
+ },
782
+ "required": list(PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS),
783
+ "additionalProperties": False,
784
+ }
785
+
786
+
787
+ #: The published shape of ``channel_delivery`` (#3378).
788
+ #:
789
+ #: Same construction and the same reasoning as
790
+ #: :data:`_PROJECT_NOTIFICATION_EVENT_DELIVERY_SCHEMA` one axis over: the key set is
791
+ #: closed and derivable — it is exactly
792
+ #: :data:`PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS`, the tuple
793
+ #: :func:`project_notification_channel_delivery` iterates — so the declaration
794
+ #: cannot report a different set of channels than the matrix renders.
795
+ #:
796
+ #: The booleans are again NOT pinned to today's answer. Which channels deliver is
797
+ #: runtime state that moves when an ADR-0049 ``NOTIFICATION_CHANNELS`` registration
798
+ #: lands (TODO(#3252)); baking ``slack: false`` into the schema would turn the
799
+ #: contract into a lie on the day one does.
800
+ _PROJECT_NOTIFICATION_CHANNEL_DELIVERY_SCHEMA: dict[str, Any] = {
801
+ "type": "object",
802
+ "description": (
803
+ "Which matrix columns TruePPM has a delivery path for. `true` means an "
804
+ "enabled cell in that column can reach you once its event is also "
805
+ "dispatched; `false` means the "
806
+ "column is rendered and the preference stored, but nothing delivers on it "
807
+ "yet and no setting anywhere turns it on, so a client should label it "
808
+ "rather than imply a delivery that never happens. Orthogonal to "
809
+ "`event_delivery`: a cell is live only when its event is dispatched AND "
810
+ "its channel delivers. Server-global — it does not vary by user or project, "
811
+ "and rides on this per-user document only so a client can label the whole "
812
+ "grid in one render. Reported as a server fact so every client does not "
813
+ "hard-code the same list and drift from it."
814
+ ),
815
+ "properties": {
816
+ channel: {"type": "boolean"} for channel in PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS
817
+ },
818
+ "required": list(PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS),
819
+ "additionalProperties": False,
820
+ }
821
+
822
+
823
+ class ProjectNotificationPreferenceDocumentSerializer(ProjectNotificationPreferenceSerializer):
824
+ """What ``GET``/``PATCH .../notification-preferences/`` actually returns (#3396, #3399).
825
+
826
+ The stored row's fields **plus** ``event_delivery`` and ``channel_delivery``,
827
+ which the view adds on both methods and which are not model fields. Declaring
828
+ the plain serializer as the ``200`` would have been the obvious fix and a worse
829
+ one: the schema would be self-consistent, ``api:schema-drift`` would pass, and a
830
+ generated SDK would silently drop the two keys a client needs to label the rows
831
+ nothing dispatches and the columns nothing delivers on. A declared lie is more
832
+ dangerous than a declared absence.
833
+
834
+ Subclasses rather than restating the field list, so a field added to the parent
835
+ reaches the published response without a second edit. Only the two delivery maps
836
+ are named here, because only they are the difference.
837
+ """
838
+
839
+ event_delivery = serializers.SerializerMethodField()
840
+ channel_delivery = serializers.SerializerMethodField()
841
+
842
+ class Meta(ProjectNotificationPreferenceSerializer.Meta):
843
+ fields = [
844
+ *ProjectNotificationPreferenceSerializer.Meta.fields,
845
+ "event_delivery",
846
+ "channel_delivery",
847
+ ]
848
+
849
+ @extend_schema_field(_PROJECT_NOTIFICATION_EVENT_DELIVERY_SCHEMA)
850
+ def get_event_delivery(self, instance: ProjectNotificationPreference) -> dict[str, bool]:
851
+ """Row-independent: it reports server wiring, not this user's preferences."""
852
+ return project_notification_event_delivery()
853
+
854
+ @extend_schema_field(_PROJECT_NOTIFICATION_CHANNEL_DELIVERY_SCHEMA)
855
+ def get_channel_delivery(self, instance: ProjectNotificationPreference) -> dict[str, bool]:
856
+ """Column-independent, and row-independent: it reports which channels the
857
+ server delivers on at all, not anything about this user or project."""
858
+ return project_notification_channel_delivery()
859
+
860
+
861
+ class UserNotificationSettingsSerializer(serializers.ModelSerializer[UserNotificationSettings]):
862
+ """Account-wide notification settings (#1707, ADR-0292; digest slot ADR-0663).
863
+
864
+ ``dnd_enabled`` plus the weekly digest slot (``digest_weekday`` /
865
+ ``digest_hour`` / ``digest_timezone``) are writable; ``updated_at`` is
866
+ read-only. The endpoint is self-scoped (the row is always the caller's own), so
867
+ there is no ``user`` field to expose or spoof.
868
+
869
+ The slot fields are range- and tz-validated rather than clamped: a bad value
870
+ must surface as a 400 the user can act on. Silently coercing hour 25 to 23
871
+ would leave someone waiting for a digest that arrives at the wrong time with no
872
+ indication anything was wrong.
873
+ """
874
+
875
+ class Meta:
876
+ model = UserNotificationSettings
877
+ fields = [
878
+ "dnd_enabled",
879
+ "digest_weekday",
880
+ "digest_hour",
881
+ "digest_timezone",
882
+ "updated_at",
883
+ ]
884
+ read_only_fields = ["updated_at"]
885
+
886
+ def validate_digest_weekday(self, value: int) -> int:
887
+ """0=Monday .. 6=Sunday, matching Python's ``date.weekday()``."""
888
+ if not 0 <= value <= 6:
889
+ raise serializers.ValidationError("Must be between 0 (Monday) and 6 (Sunday).")
890
+ return value
891
+
892
+ def validate_digest_hour(self, value: int) -> int:
893
+ """Local hour of day, 0-23."""
894
+ if not 0 <= value <= 23:
895
+ raise serializers.ValidationError("Must be between 0 and 23.")
896
+ return value
897
+
898
+ def validate_digest_timezone(self, value: str) -> str:
899
+ """An IANA key, or blank to use the server timezone.
900
+
901
+ Validated against the running interpreter's tz database so a typo is a 400
902
+ at save time rather than a digest that silently never fires.
903
+ """
904
+ if value and value not in zoneinfo.available_timezones():
905
+ raise serializers.ValidationError(f"Unknown timezone: {value!r}")
906
+ return value
907
+
908
+
909
+ class WorkspaceEmailSettingsSerializer(serializers.ModelSerializer[WorkspaceEmailSettings]):
910
+ """Read/write serializer for the workspace SMTP singleton (#712, ADR-0213).
911
+
912
+ The password is **write-only** and never echoed — the response carries only
913
+ ``password_is_set``. On update, an omitted/blank ``password`` keeps the
914
+ stored secret (rotate-vs-keep); a non-empty value replaces it. The
915
+ validate-before-persist probe opens the candidate transport before ``save``
916
+ so a bad configuration cannot lock the workspace out of mail. All admin-
917
+ supplied network/header inputs are validated against SSRF and header
918
+ injection (security review H1/M2/M3).
919
+
920
+ Custom SMTP may be saved with **no authentication** — both ``username`` and
921
+ ``password`` blank (#4190) — for a relay on a trusted network that accepts
922
+ unauthenticated connections (Postfix ``mynetworks``, an in-cluster relay).
923
+ This is the in-app counterpart to the ``EMAIL_*`` env fallback, which has
924
+ always supported it. SendGrid and SES keep requiring a credential
925
+ unconditionally — they have no unauthenticated mode. See
926
+ ``_assert_transport_fields`` / the ``no_auth`` branch of ``validate()``.
927
+ """
928
+
929
+ # Write-only credential. required=False so other fields can be edited
930
+ # without re-submitting it; allow_blank so "" explicitly means "keep".
931
+ password = serializers.CharField(
932
+ write_only=True,
933
+ required=False,
934
+ allow_blank=True,
935
+ max_length=4096,
936
+ trim_whitespace=False,
937
+ style={"input_type": "password"},
938
+ )
939
+ password_is_set = serializers.BooleanField(read_only=True)
940
+
941
+ # Rows one drain tick may claim (#2860 rebound this from "addresses per message",
942
+ # which nothing implemented). The drain caps every tick at EMAIL_MAX_BATCH_SIZE
943
+ # regardless, because a tick runs under a 25 s soft time limit — so a larger value
944
+ # was accepted, persisted, echoed back and then silently discarded (#2887 item 2).
945
+ # Rejecting it out loud is the whole point: a control that reports a value it does
946
+ # not apply is the dead-control failure mode wearing a different hat. ``0`` means
947
+ # "no explicit cap" and falls back to EMAIL_MAX_BATCH_SIZE, which is why min_value
948
+ # is 0 rather than 1.
949
+ max_recipients = serializers.IntegerField(
950
+ required=False,
951
+ min_value=0,
952
+ max_value=EMAIL_MAX_BATCH_SIZE,
953
+ help_text=(
954
+ f"Queued emails one drain tick sends, 1-{EMAIL_MAX_BATCH_SIZE}. "
955
+ f"0 means no explicit cap ({EMAIL_MAX_BATCH_SIZE} per tick)."
956
+ ),
957
+ )
958
+
959
+ class Meta:
960
+ model = WorkspaceEmailSettings
961
+ fields = [
962
+ "transport_mode",
963
+ "host",
964
+ "port",
965
+ "security",
966
+ "username",
967
+ "password",
968
+ "password_is_set",
969
+ "from_name",
970
+ "from_email",
971
+ "reply_to",
972
+ "dkim_selector",
973
+ "max_recipients",
974
+ "throttle_per_min",
975
+ "updated_at",
976
+ ]
977
+ read_only_fields = ["updated_at"]
978
+
979
+ # -- field-level validation ------------------------------------------------
980
+
981
+ def validate_port(self, value: int) -> int:
982
+ if not (1 <= value <= 65535):
983
+ raise serializers.ValidationError("Port must be between 1 and 65535.")
984
+ return value
985
+
986
+ def validate_from_name(self, value: str) -> str:
987
+ # Header-injection guard: a CRLF in the From display name could inject
988
+ # extra headers. Django's sanitizer catches raw CRLF, but reject here
989
+ # for a clear message (security review M3).
990
+ if "\r" in value or "\n" in value:
991
+ raise serializers.ValidationError("From name must not contain line breaks.")
992
+ return value
993
+
994
+ def validate_dkim_selector(self, value: str) -> str:
995
+ if value and not _DKIM_SELECTOR_RE.match(value):
996
+ raise serializers.ValidationError(
997
+ "DKIM selector may contain only letters, digits, '.', '_', and '-'."
998
+ )
999
+ return value
1000
+
1001
+ # -- object-level validation + persist ------------------------------------
1002
+
1003
+ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
1004
+ instance = self.instance
1005
+ # Bound rather than nested: a closure's own branching is charged to its
1006
+ # enclosing function, and a module-level resolver is testable alone.
1007
+ eff = partial(_effective, attrs, instance)
1008
+
1009
+ mode = eff("transport_mode", EmailTransportMode.CLOUD)
1010
+ if mode == EmailTransportMode.CLOUD:
1011
+ # Nothing to probe — fall back to the global backend at send time.
1012
+ return attrs
1013
+
1014
+ host = (eff("host") or "").strip()
1015
+ port = eff("port", 587)
1016
+ security = eff("security", EmailSecurity.TLS)
1017
+ username = (eff("username") or "").strip()
1018
+
1019
+ incoming_pw = attrs.get("password", "")
1020
+ stored_pw = instance.get_password() if instance is not None else ""
1021
+ mode_changed = instance is not None and instance.transport_mode != mode
1022
+
1023
+ # SMTP is the one transport that may run with no SMTP AUTH at all — a
1024
+ # relay on a trusted network that accepts unauthenticated connections
1025
+ # (Postfix `mynetworks`, an in-cluster relay), which the docs page has
1026
+ # always named as a valid use for "Security: None" without any in-app
1027
+ # way to configure it (#4190). A blank username carries no rotate-vs-
1028
+ # keep secrecy (unlike password), so it always reflects what will
1029
+ # actually be persisted — but password does, so the "no auth" signal
1030
+ # has to be read off the *incoming* password, not the merged one: an
1031
+ # operator who blanks the username while leaving password untouched
1032
+ # (because a no-auth relay needs no password) must not have a stale
1033
+ # credential from a previous auth-enabled config silently survive the
1034
+ # switch. `_should_clear_password` tells `update()` to blank the
1035
+ # stored ciphertext in that case even though no password was submitted
1036
+ # this request — the alternative (leaving it in place) is exactly the
1037
+ # "silently keep sending an old password" failure this closes.
1038
+ no_auth = _is_no_auth_smtp(mode=mode, username=username, incoming_password=incoming_pw)
1039
+ if no_auth:
1040
+ effective_pw = ""
1041
+ self._should_clear_password = bool(stored_pw)
1042
+ else:
1043
+ effective_pw = incoming_pw or stored_pw
1044
+ self._should_clear_password = False
1045
+
1046
+ _assert_transport_fields(
1047
+ mode=mode,
1048
+ host=host,
1049
+ username=username,
1050
+ effective_pw=effective_pw,
1051
+ needs_fresh_password=mode_changed and not incoming_pw and not no_auth,
1052
+ no_auth=no_auth,
1053
+ )
1054
+ _probe_or_raise(
1055
+ mode=mode,
1056
+ host=host,
1057
+ port=port,
1058
+ security=security,
1059
+ username=username,
1060
+ password=effective_pw,
1061
+ )
1062
+
1063
+ return attrs
1064
+
1065
+ def update(
1066
+ self, instance: WorkspaceEmailSettings, validated_data: dict[str, Any]
1067
+ ) -> WorkspaceEmailSettings:
1068
+ password = validated_data.pop("password", None)
1069
+ for field, value in validated_data.items():
1070
+ setattr(instance, field, value)
1071
+ # Rotate only when a non-empty password was submitted; blank keeps the
1072
+ # stored ciphertext untouched (never overwrite with b"").
1073
+ if password:
1074
+ # This is a third-party SMTP relay credential (Fernet-encrypted at
1075
+ # rest), not a Django auth password — validate_password() enforces
1076
+ # user-account policy (min length, common-password blocklist) that is
1077
+ # meaningless for a provider-issued secret the operator cannot change.
1078
+ # nosemgrep: unvalidated-password
1079
+ instance.set_password(password)
1080
+ elif getattr(self, "_should_clear_password", False):
1081
+ # The operator switched this SMTP transport to "no authentication"
1082
+ # (#4190) without submitting a new password — clear whatever secret
1083
+ # was stored under the previous auth-enabled config so it can never
1084
+ # be silently reused if a username is added back later without also
1085
+ # re-entering a password. Set in validate(), consumed once here.
1086
+ # Same non-auth SMTP credential as the call above, so validate_password()
1087
+ # does not apply; an empty value clears the stored ciphertext.
1088
+ # nosemgrep: unvalidated-password
1089
+ instance.set_password("")
1090
+ request = self.context.get("request")
1091
+ if request is not None and getattr(request, "user", None) is not None:
1092
+ instance.updated_by = request.user
1093
+ instance.save()
1094
+ return instance