trueppm-api 0.4.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (759) hide show
  1. trueppm_api/__init__.py +14 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +164 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  24. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  25. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  26. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  27. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
  28. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  29. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  30. trueppm_api/apps/access/migrations/__init__.py +0 -0
  31. trueppm_api/apps/access/models.py +588 -0
  32. trueppm_api/apps/access/permissions.py +2837 -0
  33. trueppm_api/apps/access/serializers.py +946 -0
  34. trueppm_api/apps/access/services.py +667 -0
  35. trueppm_api/apps/access/signals.py +103 -0
  36. trueppm_api/apps/access/tasks.py +50 -0
  37. trueppm_api/apps/access/throttles.py +95 -0
  38. trueppm_api/apps/access/urls.py +191 -0
  39. trueppm_api/apps/access/views.py +1931 -0
  40. trueppm_api/apps/agents/__init__.py +6 -0
  41. trueppm_api/apps/agents/apps.py +11 -0
  42. trueppm_api/apps/agents/canonical.py +73 -0
  43. trueppm_api/apps/agents/deferred.py +108 -0
  44. trueppm_api/apps/agents/management/__init__.py +0 -0
  45. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  46. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  47. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  48. trueppm_api/apps/agents/middleware.py +44 -0
  49. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  50. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  51. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  52. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  53. trueppm_api/apps/agents/models.py +371 -0
  54. trueppm_api/apps/agents/refusal.py +103 -0
  55. trueppm_api/apps/agents/serializers.py +85 -0
  56. trueppm_api/apps/agents/services.py +397 -0
  57. trueppm_api/apps/agents/signals.py +30 -0
  58. trueppm_api/apps/agents/urls.py +15 -0
  59. trueppm_api/apps/agents/views.py +108 -0
  60. trueppm_api/apps/csvimport/__init__.py +12 -0
  61. trueppm_api/apps/csvimport/apps.py +11 -0
  62. trueppm_api/apps/csvimport/mapping.py +363 -0
  63. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  64. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  65. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  66. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  67. trueppm_api/apps/csvimport/models.py +111 -0
  68. trueppm_api/apps/csvimport/parser.py +1825 -0
  69. trueppm_api/apps/csvimport/services.py +48 -0
  70. trueppm_api/apps/csvimport/tasks.py +418 -0
  71. trueppm_api/apps/csvimport/template.py +37 -0
  72. trueppm_api/apps/csvimport/urls.py +50 -0
  73. trueppm_api/apps/csvimport/views.py +793 -0
  74. trueppm_api/apps/history/__init__.py +0 -0
  75. trueppm_api/apps/history/apps.py +12 -0
  76. trueppm_api/apps/history/changelog.py +359 -0
  77. trueppm_api/apps/history/diff_policy.py +177 -0
  78. trueppm_api/apps/history/serializers.py +104 -0
  79. trueppm_api/apps/history/signals.py +82 -0
  80. trueppm_api/apps/history/tasks.py +105 -0
  81. trueppm_api/apps/history/urls.py +38 -0
  82. trueppm_api/apps/history/views.py +443 -0
  83. trueppm_api/apps/idempotency/__init__.py +0 -0
  84. trueppm_api/apps/idempotency/apps.py +11 -0
  85. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  86. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  87. trueppm_api/apps/idempotency/mixins.py +226 -0
  88. trueppm_api/apps/idempotency/models.py +75 -0
  89. trueppm_api/apps/idempotency/tasks.py +61 -0
  90. trueppm_api/apps/integrations/__init__.py +13 -0
  91. trueppm_api/apps/integrations/apps.py +59 -0
  92. trueppm_api/apps/integrations/connections.py +806 -0
  93. trueppm_api/apps/integrations/encryption.py +92 -0
  94. trueppm_api/apps/integrations/external_sources.py +837 -0
  95. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  96. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  97. trueppm_api/apps/integrations/http.py +403 -0
  98. trueppm_api/apps/integrations/me_work.py +218 -0
  99. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  100. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  101. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  102. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  103. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  104. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  105. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  106. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  107. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  108. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  109. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  110. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  111. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  112. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  113. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  114. trueppm_api/apps/integrations/models.py +622 -0
  115. trueppm_api/apps/integrations/notification_channels.py +57 -0
  116. trueppm_api/apps/integrations/opengraph.py +229 -0
  117. trueppm_api/apps/integrations/outgoing.py +201 -0
  118. trueppm_api/apps/integrations/providers.py +519 -0
  119. trueppm_api/apps/integrations/registry.py +345 -0
  120. trueppm_api/apps/integrations/serializers.py +453 -0
  121. trueppm_api/apps/integrations/services.py +171 -0
  122. trueppm_api/apps/integrations/tasks.py +731 -0
  123. trueppm_api/apps/integrations/throttles.py +165 -0
  124. trueppm_api/apps/integrations/urls.py +77 -0
  125. trueppm_api/apps/integrations/views.py +1106 -0
  126. trueppm_api/apps/jiraimport/__init__.py +8 -0
  127. trueppm_api/apps/jiraimport/apps.py +11 -0
  128. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  129. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  130. trueppm_api/apps/jiraimport/models.py +70 -0
  131. trueppm_api/apps/jiraimport/parser.py +350 -0
  132. trueppm_api/apps/jiraimport/services.py +46 -0
  133. trueppm_api/apps/jiraimport/tasks.py +277 -0
  134. trueppm_api/apps/jiraimport/urls.py +15 -0
  135. trueppm_api/apps/jiraimport/views.py +153 -0
  136. trueppm_api/apps/msproject/__init__.py +0 -0
  137. trueppm_api/apps/msproject/apps.py +11 -0
  138. trueppm_api/apps/msproject/dataclasses.py +153 -0
  139. trueppm_api/apps/msproject/exporter.py +500 -0
  140. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  141. trueppm_api/apps/msproject/importer.py +1095 -0
  142. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  143. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  144. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  145. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  146. trueppm_api/apps/msproject/models.py +81 -0
  147. trueppm_api/apps/msproject/parser.py +1231 -0
  148. trueppm_api/apps/msproject/serializers.py +136 -0
  149. trueppm_api/apps/msproject/services.py +58 -0
  150. trueppm_api/apps/msproject/tasks.py +436 -0
  151. trueppm_api/apps/msproject/urls.py +39 -0
  152. trueppm_api/apps/msproject/views.py +531 -0
  153. trueppm_api/apps/notifications/__init__.py +7 -0
  154. trueppm_api/apps/notifications/apps.py +15 -0
  155. trueppm_api/apps/notifications/backfill.py +79 -0
  156. trueppm_api/apps/notifications/categories.py +112 -0
  157. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  158. trueppm_api/apps/notifications/digests.py +447 -0
  159. trueppm_api/apps/notifications/email_backend.py +330 -0
  160. trueppm_api/apps/notifications/email_health.py +128 -0
  161. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  162. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
  163. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  164. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  165. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  166. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  167. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  168. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  169. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  170. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  171. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  172. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  173. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  174. trueppm_api/apps/notifications/models.py +1197 -0
  175. trueppm_api/apps/notifications/receivers.py +155 -0
  176. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  177. trueppm_api/apps/notifications/serializers.py +961 -0
  178. trueppm_api/apps/notifications/services.py +1451 -0
  179. trueppm_api/apps/notifications/tasks.py +711 -0
  180. trueppm_api/apps/notifications/throttles.py +98 -0
  181. trueppm_api/apps/notifications/urls.py +97 -0
  182. trueppm_api/apps/notifications/views.py +904 -0
  183. trueppm_api/apps/observability/__init__.py +0 -0
  184. trueppm_api/apps/observability/apps.py +31 -0
  185. trueppm_api/apps/observability/logging.py +213 -0
  186. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  187. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  188. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  189. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  190. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  191. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  192. trueppm_api/apps/observability/models.py +141 -0
  193. trueppm_api/apps/observability/otel/__init__.py +45 -0
  194. trueppm_api/apps/observability/otel/attributes.py +247 -0
  195. trueppm_api/apps/observability/otel/export_health.py +533 -0
  196. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  197. trueppm_api/apps/observability/otel/metrics.py +551 -0
  198. trueppm_api/apps/observability/otel/provider.py +511 -0
  199. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  200. trueppm_api/apps/observability/purge_registry.py +124 -0
  201. trueppm_api/apps/observability/retention.py +139 -0
  202. trueppm_api/apps/observability/selectors.py +969 -0
  203. trueppm_api/apps/observability/serializers.py +142 -0
  204. trueppm_api/apps/observability/services.py +407 -0
  205. trueppm_api/apps/observability/tasks.py +266 -0
  206. trueppm_api/apps/observability/urls.py +27 -0
  207. trueppm_api/apps/observability/views.py +523 -0
  208. trueppm_api/apps/profiles/__init__.py +0 -0
  209. trueppm_api/apps/profiles/apps.py +15 -0
  210. trueppm_api/apps/profiles/constants.py +54 -0
  211. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  212. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  213. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  214. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  215. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  216. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  217. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  218. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  219. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  220. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  221. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  222. trueppm_api/apps/profiles/models.py +276 -0
  223. trueppm_api/apps/profiles/serializers.py +199 -0
  224. trueppm_api/apps/profiles/services.py +466 -0
  225. trueppm_api/apps/profiles/views.py +124 -0
  226. trueppm_api/apps/projects/__init__.py +0 -0
  227. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  228. trueppm_api/apps/projects/amend.py +133 -0
  229. trueppm_api/apps/projects/apps.py +38 -0
  230. trueppm_api/apps/projects/asset_feed.py +459 -0
  231. trueppm_api/apps/projects/asset_views.py +317 -0
  232. trueppm_api/apps/projects/attachment_policy.py +278 -0
  233. trueppm_api/apps/projects/authentication.py +449 -0
  234. trueppm_api/apps/projects/backfill.py +287 -0
  235. trueppm_api/apps/projects/backlog_services.py +324 -0
  236. trueppm_api/apps/projects/backlog_views.py +302 -0
  237. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  238. trueppm_api/apps/projects/blocker_services.py +510 -0
  239. trueppm_api/apps/projects/board_activity.py +462 -0
  240. trueppm_api/apps/projects/board_activity_views.py +186 -0
  241. trueppm_api/apps/projects/board_lanes.py +134 -0
  242. trueppm_api/apps/projects/bulk_settings.py +194 -0
  243. trueppm_api/apps/projects/bundled_templates.py +215 -0
  244. trueppm_api/apps/projects/calendar_settings.py +180 -0
  245. trueppm_api/apps/projects/ceremony_views.py +244 -0
  246. trueppm_api/apps/projects/commit_moment.py +204 -0
  247. trueppm_api/apps/projects/config_notice.py +1325 -0
  248. trueppm_api/apps/projects/custom_field_values.py +266 -0
  249. trueppm_api/apps/projects/decisions_services.py +93 -0
  250. trueppm_api/apps/projects/decisions_views.py +94 -0
  251. trueppm_api/apps/projects/estimation_scale.py +126 -0
  252. trueppm_api/apps/projects/export_bundle.py +353 -0
  253. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
  254. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
  255. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
  256. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
  257. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
  258. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  259. trueppm_api/apps/projects/inbound_sync.py +555 -0
  260. trueppm_api/apps/projects/iteration_label.py +85 -0
  261. trueppm_api/apps/projects/lifecycle.py +191 -0
  262. trueppm_api/apps/projects/management/__init__.py +0 -0
  263. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  264. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  265. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  266. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  267. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  268. trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
  269. trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
  270. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
  271. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  272. trueppm_api/apps/projects/mcp_settings.py +305 -0
  273. trueppm_api/apps/projects/methodology.py +178 -0
  274. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  275. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
  276. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  277. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  278. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  279. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  280. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  281. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  282. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  283. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  284. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  285. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  286. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  287. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  288. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  289. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  290. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  291. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  292. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  293. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  294. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  295. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  296. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  297. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  298. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  299. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  300. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  301. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  302. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  303. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  304. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  305. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  306. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  307. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  308. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  309. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  310. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  311. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  312. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  313. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  314. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  315. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  316. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  317. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  318. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  319. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  320. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  321. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  322. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  323. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  324. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  325. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  326. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  327. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  328. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  329. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  330. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  331. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  332. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  333. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  334. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  335. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  336. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  337. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  338. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  339. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  340. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  341. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  342. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  343. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  344. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  345. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  346. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  347. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  348. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  349. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  350. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  351. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  352. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  353. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  354. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  355. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  356. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  357. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  358. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  359. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  360. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  361. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  362. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  363. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  364. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  365. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  366. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  367. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  368. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
  369. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  370. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  371. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  372. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  373. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  374. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  375. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  376. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  377. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  378. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  379. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
  380. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  381. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  382. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  383. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  384. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  385. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
  386. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  387. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  388. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  389. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  390. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  391. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  392. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  393. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  394. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  395. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  396. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  397. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  398. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  399. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  400. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  401. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  402. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  403. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  404. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  405. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  406. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  407. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  408. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  409. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  410. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  411. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  412. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  413. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  414. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  415. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  416. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  417. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  418. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  419. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  420. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  421. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  422. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  423. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  424. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  425. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  426. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  427. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  428. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  429. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  430. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  431. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  432. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  433. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  434. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  435. trueppm_api/apps/projects/models.py +8946 -0
  436. trueppm_api/apps/projects/poker_services.py +154 -0
  437. trueppm_api/apps/projects/poker_views.py +398 -0
  438. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  439. trueppm_api/apps/projects/program_label_services.py +94 -0
  440. trueppm_api/apps/projects/program_label_views.py +189 -0
  441. trueppm_api/apps/projects/program_rollup.py +575 -0
  442. trueppm_api/apps/projects/program_schedule.py +745 -0
  443. trueppm_api/apps/projects/program_views.py +3220 -0
  444. trueppm_api/apps/projects/project_templates.py +422 -0
  445. trueppm_api/apps/projects/receivers.py +234 -0
  446. trueppm_api/apps/projects/refusal_codes.py +140 -0
  447. trueppm_api/apps/projects/reorder_services.py +145 -0
  448. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  449. trueppm_api/apps/projects/retro_board_services.py +368 -0
  450. trueppm_api/apps/projects/retro_services.py +285 -0
  451. trueppm_api/apps/projects/risk_import.py +396 -0
  452. trueppm_api/apps/projects/row_identity.py +113 -0
  453. trueppm_api/apps/projects/schema_migrations.py +228 -0
  454. trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
  455. trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
  456. trueppm_api/apps/projects/seed/__init__.py +61 -0
  457. trueppm_api/apps/projects/seed/exporter.py +1644 -0
  458. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  459. trueppm_api/apps/projects/seed/importer.py +2390 -0
  460. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  461. trueppm_api/apps/projects/seed/reldates.py +126 -0
  462. trueppm_api/apps/projects/seed/replace.py +126 -0
  463. trueppm_api/apps/projects/seed/replay.py +1691 -0
  464. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  465. trueppm_api/apps/projects/seed/samples.py +427 -0
  466. trueppm_api/apps/projects/seed/validation.py +1149 -0
  467. trueppm_api/apps/projects/serializers.py +12310 -0
  468. trueppm_api/apps/projects/services.py +6966 -0
  469. trueppm_api/apps/projects/share_serializers.py +132 -0
  470. trueppm_api/apps/projects/share_services.py +325 -0
  471. trueppm_api/apps/projects/share_views.py +374 -0
  472. trueppm_api/apps/projects/sharing_settings.py +187 -0
  473. trueppm_api/apps/projects/signal_privacy_services.py +909 -0
  474. trueppm_api/apps/projects/signal_privacy_views.py +476 -0
  475. trueppm_api/apps/projects/signals.py +132 -0
  476. trueppm_api/apps/projects/slip_conflict.py +266 -0
  477. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  478. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  479. trueppm_api/apps/projects/standup.py +304 -0
  480. trueppm_api/apps/projects/standup_views.py +99 -0
  481. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  482. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  483. trueppm_api/apps/projects/surface_visibility.py +126 -0
  484. trueppm_api/apps/projects/task_batch_services.py +379 -0
  485. trueppm_api/apps/projects/task_bulk.py +1152 -0
  486. trueppm_api/apps/projects/task_classification.py +561 -0
  487. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  488. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  489. trueppm_api/apps/projects/task_grouping.py +754 -0
  490. trueppm_api/apps/projects/tasks.py +1917 -0
  491. trueppm_api/apps/projects/template_divergence.py +168 -0
  492. trueppm_api/apps/projects/template_services.py +160 -0
  493. trueppm_api/apps/projects/template_tasks.py +258 -0
  494. trueppm_api/apps/projects/template_views.py +799 -0
  495. trueppm_api/apps/projects/throttles.py +311 -0
  496. trueppm_api/apps/projects/urls.py +1011 -0
  497. trueppm_api/apps/projects/utilization.py +885 -0
  498. trueppm_api/apps/projects/views.py +20572 -0
  499. trueppm_api/apps/projects/wbs_paths.py +119 -0
  500. trueppm_api/apps/resources/__init__.py +0 -0
  501. trueppm_api/apps/resources/apps.py +8 -0
  502. trueppm_api/apps/resources/capacity.py +121 -0
  503. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  504. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  505. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  506. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  507. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  508. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  509. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  510. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  511. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  512. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  513. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  514. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  515. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  516. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  517. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  518. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  519. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  520. trueppm_api/apps/resources/models.py +298 -0
  521. trueppm_api/apps/resources/serializers.py +426 -0
  522. trueppm_api/apps/resources/services.py +465 -0
  523. trueppm_api/apps/resources/urls.py +28 -0
  524. trueppm_api/apps/resources/views.py +1749 -0
  525. trueppm_api/apps/scheduling/__init__.py +0 -0
  526. trueppm_api/apps/scheduling/apps.py +153 -0
  527. trueppm_api/apps/scheduling/calendars.py +119 -0
  528. trueppm_api/apps/scheduling/deadletter.py +91 -0
  529. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  530. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  531. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  532. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  533. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  534. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  535. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  536. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  537. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  538. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  539. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  540. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  541. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  542. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  543. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  544. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  545. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  546. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  547. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  548. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  549. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  550. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  551. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  552. trueppm_api/apps/scheduling/models.py +516 -0
  553. trueppm_api/apps/scheduling/receivers.py +160 -0
  554. trueppm_api/apps/scheduling/risk_premium.py +283 -0
  555. trueppm_api/apps/scheduling/serializers.py +564 -0
  556. trueppm_api/apps/scheduling/services.py +1182 -0
  557. trueppm_api/apps/scheduling/signals.py +56 -0
  558. trueppm_api/apps/scheduling/tasks.py +1984 -0
  559. trueppm_api/apps/scheduling/telemetry.py +80 -0
  560. trueppm_api/apps/scheduling/units.py +35 -0
  561. trueppm_api/apps/scheduling/urls.py +57 -0
  562. trueppm_api/apps/scheduling/views.py +2564 -0
  563. trueppm_api/apps/sso/__init__.py +0 -0
  564. trueppm_api/apps/sso/apps.py +19 -0
  565. trueppm_api/apps/sso/extensions.py +130 -0
  566. trueppm_api/apps/sso/management/__init__.py +0 -0
  567. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  568. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  569. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  570. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  571. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  572. trueppm_api/apps/sso/models.py +148 -0
  573. trueppm_api/apps/sso/serializers.py +437 -0
  574. trueppm_api/apps/sso/services.py +1385 -0
  575. trueppm_api/apps/sso/urls.py +47 -0
  576. trueppm_api/apps/sso/views.py +935 -0
  577. trueppm_api/apps/sync/__init__.py +0 -0
  578. trueppm_api/apps/sync/apps.py +15 -0
  579. trueppm_api/apps/sync/backfill.py +208 -0
  580. trueppm_api/apps/sync/broadcast.py +320 -0
  581. trueppm_api/apps/sync/conflict.py +262 -0
  582. trueppm_api/apps/sync/consumers.py +471 -0
  583. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  584. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  585. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  586. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  587. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  588. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  589. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  590. trueppm_api/apps/sync/models.py +225 -0
  591. trueppm_api/apps/sync/pagination.py +231 -0
  592. trueppm_api/apps/sync/sequence.py +371 -0
  593. trueppm_api/apps/sync/serializers.py +687 -0
  594. trueppm_api/apps/sync/tasks.py +260 -0
  595. trueppm_api/apps/sync/throttles.py +175 -0
  596. trueppm_api/apps/sync/upload.py +501 -0
  597. trueppm_api/apps/sync/urls.py +17 -0
  598. trueppm_api/apps/sync/views.py +930 -0
  599. trueppm_api/apps/sync/ws_auth.py +193 -0
  600. trueppm_api/apps/taskruns/__init__.py +0 -0
  601. trueppm_api/apps/taskruns/apps.py +11 -0
  602. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  603. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  604. trueppm_api/apps/taskruns/models.py +68 -0
  605. trueppm_api/apps/taskruns/serializers.py +92 -0
  606. trueppm_api/apps/taskruns/tasks.py +60 -0
  607. trueppm_api/apps/taskruns/tracker.py +286 -0
  608. trueppm_api/apps/taskruns/urls.py +46 -0
  609. trueppm_api/apps/taskruns/views.py +195 -0
  610. trueppm_api/apps/teams/__init__.py +8 -0
  611. trueppm_api/apps/teams/apps.py +16 -0
  612. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  613. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
  614. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  615. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  616. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  617. trueppm_api/apps/teams/models.py +145 -0
  618. trueppm_api/apps/teams/permissions.py +149 -0
  619. trueppm_api/apps/teams/serializers.py +95 -0
  620. trueppm_api/apps/teams/services.py +434 -0
  621. trueppm_api/apps/teams/signals.py +61 -0
  622. trueppm_api/apps/teams/urls.py +34 -0
  623. trueppm_api/apps/teams/views.py +201 -0
  624. trueppm_api/apps/timetracking/__init__.py +0 -0
  625. trueppm_api/apps/timetracking/apps.py +9 -0
  626. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  627. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  628. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  629. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  630. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  631. trueppm_api/apps/timetracking/models.py +178 -0
  632. trueppm_api/apps/timetracking/serializers.py +175 -0
  633. trueppm_api/apps/timetracking/services.py +117 -0
  634. trueppm_api/apps/timetracking/urls.py +47 -0
  635. trueppm_api/apps/timetracking/views.py +526 -0
  636. trueppm_api/apps/webhooks/__init__.py +0 -0
  637. trueppm_api/apps/webhooks/apps.py +8 -0
  638. trueppm_api/apps/webhooks/backfill.py +90 -0
  639. trueppm_api/apps/webhooks/dispatch.py +144 -0
  640. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  641. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
  642. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  643. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  644. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  645. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  646. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  647. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  648. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  649. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
  650. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  651. trueppm_api/apps/webhooks/models.py +372 -0
  652. trueppm_api/apps/webhooks/serializers.py +318 -0
  653. trueppm_api/apps/webhooks/tasks.py +663 -0
  654. trueppm_api/apps/webhooks/urls.py +22 -0
  655. trueppm_api/apps/webhooks/views.py +383 -0
  656. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  657. trueppm_api/apps/workflow_engine/apps.py +23 -0
  658. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  659. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  660. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  661. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  662. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  663. trueppm_api/apps/workflow_engine/models.py +265 -0
  664. trueppm_api/apps/workflow_engine/tasks.py +368 -0
  665. trueppm_api/apps/workspace/__init__.py +0 -0
  666. trueppm_api/apps/workspace/apps.py +10 -0
  667. trueppm_api/apps/workspace/backfill.py +111 -0
  668. trueppm_api/apps/workspace/export.py +242 -0
  669. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  670. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  671. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  672. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  673. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  674. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  675. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  676. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  677. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  678. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  679. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  680. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  681. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  682. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  683. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  684. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  685. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  686. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  687. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  688. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  689. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  690. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  691. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  692. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  693. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  694. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  695. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  696. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  697. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  698. trueppm_api/apps/workspace/models.py +857 -0
  699. trueppm_api/apps/workspace/permissions.py +187 -0
  700. trueppm_api/apps/workspace/serializers.py +590 -0
  701. trueppm_api/apps/workspace/services.py +919 -0
  702. trueppm_api/apps/workspace/signals.py +37 -0
  703. trueppm_api/apps/workspace/tasks.py +555 -0
  704. trueppm_api/apps/workspace/urls.py +113 -0
  705. trueppm_api/apps/workspace/views.py +1502 -0
  706. trueppm_api/asgi.py +42 -0
  707. trueppm_api/celery.py +31 -0
  708. trueppm_api/core/__init__.py +0 -0
  709. trueppm_api/core/admin_site.py +206 -0
  710. trueppm_api/core/auth_views.py +916 -0
  711. trueppm_api/core/constant_time.py +51 -0
  712. trueppm_api/core/csp.py +54 -0
  713. trueppm_api/core/db.py +87 -0
  714. trueppm_api/core/db_session.py +37 -0
  715. trueppm_api/core/exception_handlers.py +157 -0
  716. trueppm_api/core/export_downloads.py +56 -0
  717. trueppm_api/core/extension_providers.py +56 -0
  718. trueppm_api/core/extension_signals.py +147 -0
  719. trueppm_api/core/idempotent.py +260 -0
  720. trueppm_api/core/metadata.py +42 -0
  721. trueppm_api/core/middleware.py +51 -0
  722. trueppm_api/core/openapi.py +949 -0
  723. trueppm_api/core/password_reset.py +500 -0
  724. trueppm_api/core/protect_conflict.py +100 -0
  725. trueppm_api/core/ratelimit.py +148 -0
  726. trueppm_api/core/redis_throttle.py +82 -0
  727. trueppm_api/core/refresh_cookie_policy.py +156 -0
  728. trueppm_api/core/request_body.py +86 -0
  729. trueppm_api/core/security_checks.py +884 -0
  730. trueppm_api/core/storage_config.py +105 -0
  731. trueppm_api/core/text_decode.py +168 -0
  732. trueppm_api/core/throttling.py +344 -0
  733. trueppm_api/core/valkey.py +214 -0
  734. trueppm_api/core/valkey_cache.py +66 -0
  735. trueppm_api/core/valkey_checks.py +104 -0
  736. trueppm_api/core/valkey_config.py +39 -0
  737. trueppm_api/core/worker_broker_wait.py +149 -0
  738. trueppm_api/core/worker_heartbeat.py +134 -0
  739. trueppm_api/fields.py +36 -0
  740. trueppm_api/permissions.py +30 -0
  741. trueppm_api/py.typed +0 -0
  742. trueppm_api/routing.py +11 -0
  743. trueppm_api/settings/__init__.py +0 -0
  744. trueppm_api/settings/base.py +2455 -0
  745. trueppm_api/settings/dev.py +273 -0
  746. trueppm_api/settings/prod.py +228 -0
  747. trueppm_api/urls.py +153 -0
  748. trueppm_api/workflows/__init__.py +38 -0
  749. trueppm_api/workflows/backends/__init__.py +6 -0
  750. trueppm_api/workflows/backends/default.py +297 -0
  751. trueppm_api/workflows/consumers/__init__.py +11 -0
  752. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  753. trueppm_api/workflows/interface.py +148 -0
  754. trueppm_api/workflows/registry.py +106 -0
  755. trueppm_api/workflows/services.py +90 -0
  756. trueppm_api/wsgi.py +11 -0
  757. trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
  758. trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
  759. trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
@@ -0,0 +1,904 @@
1
+ """ViewSets for the per-user notification inbox + preferences (ADR-0075)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any
8
+ from zoneinfo import ZoneInfo
9
+
10
+ from django.conf import settings
11
+ from django.db import transaction
12
+ from django.db.models import Q
13
+ from django.shortcuts import get_object_or_404
14
+ from django.utils import timezone
15
+ from django.utils.dateparse import parse_datetime
16
+ from drf_spectacular.types import OpenApiTypes
17
+ from drf_spectacular.utils import (
18
+ OpenApiParameter,
19
+ OpenApiResponse,
20
+ extend_schema,
21
+ extend_schema_view,
22
+ inline_serializer,
23
+ )
24
+ from rest_framework import mixins, serializers, status, viewsets
25
+ from rest_framework.decorators import action
26
+ from rest_framework.exceptions import PermissionDenied
27
+ from rest_framework.permissions import BasePermission, IsAuthenticated
28
+ from rest_framework.response import Response
29
+ from rest_framework.throttling import ScopedRateThrottle
30
+ from rest_framework.views import APIView
31
+
32
+ from trueppm_api.apps.access.permissions import (
33
+ IsProjectMember,
34
+ IsWorkspaceOperator,
35
+ )
36
+ from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
37
+ from trueppm_api.apps.projects.models import Project
38
+ from trueppm_api.apps.workspace.permissions import IsWorkspaceAdminStrict
39
+
40
+ from .categories import CATEGORIES, CATEGORY_MENTIONS, event_types_for_category
41
+ from .models import (
42
+ DEFAULT_PREFERENCES,
43
+ PROJECT_NOTIFICATION_DEFAULT_MATRIX,
44
+ SIGNAL_ONLY_EVENTS,
45
+ EmailTransportMode,
46
+ Notification,
47
+ NotificationChannel,
48
+ NotificationPreference,
49
+ ProjectNotificationChannel,
50
+ ProjectNotificationEventType,
51
+ ProjectNotificationPreference,
52
+ WorkspaceEmailSettings,
53
+ project_notification_channel_delivery,
54
+ project_notification_event_delivery,
55
+ )
56
+ from .serializers import (
57
+ NotificationPreferenceSerializer,
58
+ NotificationSerializer,
59
+ ProjectNotificationPreferenceDocumentSerializer,
60
+ ProjectNotificationPreferenceSerializer,
61
+ UserNotificationSettingsSerializer,
62
+ WorkspaceEmailSettingsSerializer,
63
+ )
64
+ from .services import get_or_create_default_preferences, get_or_create_notification_settings
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ if TYPE_CHECKING:
69
+ from django.db.models import QuerySet
70
+ from rest_framework.request import Request
71
+
72
+
73
+ # Snooze presets (ADR-0216 §1). The client may send a preset key OR an explicit
74
+ # `until` ISO datetime; presets are resolved server-side so the "tomorrow 9am"
75
+ # anchor stays consistent and testable.
76
+ SNOOZE_PRESETS: tuple[str, ...] = ("1h", "3h", "tomorrow")
77
+
78
+
79
+ def _resolve_snooze_preset(preset: str, now: datetime.datetime) -> datetime.datetime | None:
80
+ """Resolve a snooze preset key to an absolute datetime, or None if unknown.
81
+
82
+ ``1h`` / ``3h`` are relative to ``now``; ``tomorrow`` is the next day at 09:00
83
+ in the **server** timezone (``settings.TIME_ZONE``), returned as an aware UTC
84
+ datetime.
85
+
86
+ Deliberately *not* the admin-set ``Workspace.timezone`` and *not* the snoozing
87
+ user's ``profiles.Profile.timezone``. The profile value defaults to ``"auto"``
88
+ (resolved in the browser) and is scoped display-only by ADR-0410, so it is not
89
+ a server-resolvable anchor; widening this preset to either is a behavior change
90
+ with its own UX question, not a wording fix. ``Workspace.timezone`` has exactly
91
+ one reader — ``services._project_timezone``, the quiet-hours anchor (#3377).
92
+ """
93
+ if preset == "1h":
94
+ return now + datetime.timedelta(hours=1)
95
+ if preset == "3h":
96
+ return now + datetime.timedelta(hours=3)
97
+ if preset == "tomorrow":
98
+ try:
99
+ tz = datetime.UTC if not settings.TIME_ZONE else ZoneInfo(settings.TIME_ZONE)
100
+ except Exception:
101
+ tz = datetime.UTC
102
+ local_tomorrow = (now.astimezone(tz) + datetime.timedelta(days=1)).replace(
103
+ hour=9, minute=0, second=0, microsecond=0
104
+ )
105
+ return local_tomorrow.astimezone(datetime.UTC)
106
+ return None
107
+
108
+
109
+ def _resolve_snooze_until(
110
+ data: Any, now: datetime.datetime
111
+ ) -> tuple[datetime.datetime | None, Response | None]:
112
+ """Resolve the target ``snoozed_until`` from a snooze request body.
113
+
114
+ Returns ``(until, None)`` on success — ``until`` is ``None`` for an explicit
115
+ un-snooze — or ``(None, error_response)`` with a ready-to-return 400 when the
116
+ body is malformed. Extracted from :meth:`NotificationViewSet.snooze` so the
117
+ action body stays a flat resolve-then-persist sequence; the preset/until/error
118
+ branching lives here.
119
+ """
120
+ preset = data.get("preset")
121
+ if preset:
122
+ until = _resolve_snooze_preset(preset, now)
123
+ if until is None:
124
+ return None, Response(
125
+ {"detail": f"preset must be one of {list(SNOOZE_PRESETS)}."},
126
+ status=status.HTTP_400_BAD_REQUEST,
127
+ )
128
+ return until, None
129
+ if "until" in data:
130
+ raw = data.get("until")
131
+ if raw in (None, ""):
132
+ return None, None # explicit un-snooze
133
+ parsed = parse_datetime(raw) if isinstance(raw, str) else None
134
+ if parsed is None:
135
+ return None, Response(
136
+ {"detail": "until must be an ISO 8601 datetime or null."},
137
+ status=status.HTTP_400_BAD_REQUEST,
138
+ )
139
+ # Anchor a naive timestamp to the current timezone rather than letting
140
+ # Django emit a naive-datetime warning / compare wrong.
141
+ if timezone.is_naive(parsed):
142
+ parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
143
+ return parsed, None
144
+ return None, Response(
145
+ {"detail": "Provide a 'preset' or an 'until' datetime (null to un-snooze)."},
146
+ status=status.HTTP_400_BAD_REQUEST,
147
+ )
148
+
149
+
150
+ @extend_schema_view(
151
+ list=extend_schema(
152
+ parameters=[
153
+ OpenApiParameter(
154
+ name="unread_only",
155
+ type=OpenApiTypes.BOOL,
156
+ location=OpenApiParameter.QUERY,
157
+ required=False,
158
+ description=(
159
+ "When `true` (or `1`), return only unread, non-archived "
160
+ "notifications. Mutually exclusive with `archived`."
161
+ ),
162
+ ),
163
+ OpenApiParameter(
164
+ name="archived",
165
+ type=OpenApiTypes.BOOL,
166
+ location=OpenApiParameter.QUERY,
167
+ required=False,
168
+ description=(
169
+ "When `true` (or `1`), return only archived notifications. "
170
+ "The default list (neither param set) excludes archived rows."
171
+ ),
172
+ ),
173
+ OpenApiParameter(
174
+ name="snoozed",
175
+ type=OpenApiTypes.BOOL,
176
+ location=OpenApiParameter.QUERY,
177
+ required=False,
178
+ description=(
179
+ "When `true` (or `1`), return only currently-snoozed rows "
180
+ "(`snoozed_until` in the future). Every other view EXCLUDES "
181
+ "currently-snoozed rows — including the unread-count query — "
182
+ "so a deferred notification does not increment the bell badge."
183
+ ),
184
+ ),
185
+ OpenApiParameter(
186
+ name="category",
187
+ type=OpenApiTypes.STR,
188
+ location=OpenApiParameter.QUERY,
189
+ required=False,
190
+ enum=list(CATEGORIES),
191
+ description=(
192
+ "Filter to one derived category: `mentions`, `tasks`, "
193
+ "`signals`, or `project`. Orthogonal to the read-state "
194
+ "filters. `mentions` also matches mention-sourced rows."
195
+ ),
196
+ ),
197
+ ],
198
+ ),
199
+ )
200
+ class NotificationViewSet(
201
+ IdempotencyMixin,
202
+ mixins.ListModelMixin,
203
+ mixins.RetrieveModelMixin,
204
+ mixins.UpdateModelMixin,
205
+ viewsets.GenericViewSet[Notification],
206
+ ):
207
+ """Per-user notification inbox.
208
+
209
+ GET /me/notifications/ ?unread_only=true&archived=true&snoozed=true&category=tasks
210
+ GET /me/notifications/{id}/
211
+ PATCH /me/notifications/{id}/ { is_read: bool, is_archived: bool }
212
+ POST /me/notifications/{id}/snooze/ { until: iso } | { preset: 1h|3h|tomorrow }
213
+ POST /me/notifications/mark-all-read/
214
+
215
+ All reads/writes are auto-scoped to the authenticated user — no cross-user
216
+ access possible by construction (queryset filters by recipient=request.user).
217
+ """
218
+
219
+ serializer_class = NotificationSerializer
220
+ permission_classes: list[type[BasePermission]] = [IsAuthenticated]
221
+ lookup_field = "pk"
222
+ # Opted out of Idempotency-Key replay (#3510, security review). An
223
+ # ``IdempotencyMixin`` replay returns the ORIGINAL response body verbatim
224
+ # (IdempotencyKey.response_body), bypassing ``NotificationSerializer.
225
+ # to_representation`` — and with it, the #3510 subject/body/project
226
+ # redaction — entirely. A member could PATCH or snooze a notification
227
+ # while still current (capturing the unredacted row under a self-chosen
228
+ # key), then replay that same key after being removed from the project to
229
+ # read the cached, unredacted content back out — a complete, self-service
230
+ # defeat of the read-time redaction this viewset exists to enforce. Both
231
+ # mutations this viewset exposes are already naturally idempotent at the
232
+ # application level without the header (PATCH is_read/is_archived is a
233
+ # no-op on repeat, and snooze is explicitly documented as idempotent — it
234
+ # just overwrites the timestamp), so the header buys no real double-submit
235
+ # protection here to weigh against that risk.
236
+ idempotency_exempt = True
237
+
238
+ def get_serializer_context(self) -> dict[str, Any]:
239
+ """Precompute the recipient's member-project set once per response.
240
+
241
+ Backs ``NotificationSerializer._recipient_can_see_project``, which redacts
242
+ ``snippet`` (#514 — program mentions can reach sibling-project members),
243
+ ``subject``/``body``/``project`` (#3510), and the two raw object
244
+ references ``task_id`` / ``mention.task_comment`` (#3674), for a
245
+ recipient who is not a current member of the notification's project —
246
+ including one who has since been removed from it. Resolving the
247
+ membership set here keeps the inbox list at one query instead of one per
248
+ row: every one of those fields reads the same memoized set, so widening
249
+ the redaction added no per-row membership query.
250
+ """
251
+ context: dict[str, Any] = dict(super().get_serializer_context())
252
+ user = self.request.user
253
+ if user.is_authenticated:
254
+ from trueppm_api.apps.access.models import ProjectMembership
255
+
256
+ context["member_project_ids"] = set(
257
+ ProjectMembership.objects.filter(user=user, is_deleted=False).values_list(
258
+ "project_id", flat=True
259
+ )
260
+ )
261
+ return context
262
+
263
+ def get_queryset(self) -> QuerySet[Notification]:
264
+ user = self.request.user
265
+ if not user.is_authenticated:
266
+ return Notification.objects.none()
267
+ qs = Notification.objects.filter(recipient=user).select_related(
268
+ "mention",
269
+ "mention__task_comment",
270
+ "mention__mentioner",
271
+ "mention__mentioned_user",
272
+ "project",
273
+ )
274
+ params = self.request.query_params
275
+ now = timezone.now()
276
+ snoozed_only = params.get("snoozed", "").lower() in ("1", "true")
277
+ unread_only = params.get("unread_only", "").lower() in ("1", "true")
278
+ archived_only = params.get("archived", "").lower() in ("1", "true")
279
+
280
+ if snoozed_only:
281
+ # The "Snoozed" chip: rows deferred to the future, still active.
282
+ qs = qs.filter(snoozed_until__gt=now, is_archived=False)
283
+ else:
284
+ # Every non-snoozed view hides rows still inside their snooze window;
285
+ # they reappear automatically once snoozed_until passes (pure query-
286
+ # time filter, ADR-0216 §1). Applied BEFORE the read-state branch so
287
+ # the unread-count path (unread_only=true) also excludes snoozed rows
288
+ # — otherwise a deferred notification would still light the bell badge.
289
+ qs = qs.filter(Q(snoozed_until__isnull=True) | Q(snoozed_until__lte=now))
290
+ if unread_only:
291
+ qs = qs.filter(is_read=False, is_archived=False)
292
+ elif archived_only:
293
+ qs = qs.filter(is_archived=True)
294
+ else:
295
+ # Default list excludes archived
296
+ qs = qs.filter(is_archived=False)
297
+
298
+ # Derived category filter (ADR-0216 §3). The mapping lives in
299
+ # categories.py so this filter and the serializer's category field share
300
+ # one source of truth. `mentions` additionally matches mention-sourced
301
+ # rows (blank event_type + mention FK). An unknown category maps to an
302
+ # empty event-type set → matches nothing.
303
+ category = params.get("category", "").strip().lower()
304
+ if category:
305
+ event_types = event_types_for_category(category)
306
+ category_q = Q(event_type__in=event_types)
307
+ if category == CATEGORY_MENTIONS:
308
+ category_q |= Q(mention__isnull=False)
309
+ qs = qs.filter(category_q)
310
+
311
+ return qs.order_by("-created_at")
312
+
313
+ @extend_schema(
314
+ request={
315
+ "application/json": {
316
+ "type": "object",
317
+ "properties": {
318
+ "preset": {"type": "string", "enum": list(SNOOZE_PRESETS)},
319
+ "until": {
320
+ "type": "string",
321
+ "format": "date-time",
322
+ "nullable": True,
323
+ },
324
+ },
325
+ }
326
+ },
327
+ responses=NotificationSerializer,
328
+ )
329
+ @action(detail=True, methods=["post"], url_path="snooze")
330
+ def snooze(self, request: Request, pk: str | None = None) -> Response:
331
+ """Snooze (or un-snooze) a single notification (ADR-0216 §1).
332
+
333
+ Body accepts EITHER ``{"preset": "1h"|"3h"|"tomorrow"}`` OR
334
+ ``{"until": "<iso datetime>"}``. Pass ``{"until": null}`` (or an empty
335
+ body) to un-snooze — clearing ``snoozed_until`` returns the row to the
336
+ inbox immediately. Idempotent: re-snoozing overwrites the timestamp.
337
+
338
+ Looks the row up recipient-scoped directly (not via the filtered
339
+ get_queryset) so a currently-snoozed row can still be re-snoozed or
340
+ un-snoozed — the default queryset hides snoozed rows.
341
+ """
342
+ user = request.user
343
+ notification = get_object_or_404(Notification, pk=pk, recipient=user)
344
+ now = timezone.now()
345
+
346
+ until, error = _resolve_snooze_until(request.data, now)
347
+ if error is not None:
348
+ return error
349
+
350
+ notification.snoozed_until = until
351
+ notification.save(update_fields=["snoozed_until"])
352
+ serializer = self.get_serializer(notification)
353
+ return Response(serializer.data, status=status.HTTP_200_OK)
354
+
355
+ def perform_update(self, serializer: Any) -> None:
356
+ instance: Notification = serializer.instance
357
+ was_read = instance.is_read
358
+ updated = serializer.save()
359
+ # Stamp read_at on transition unread → read; never reset to None.
360
+ if updated.is_read and not was_read:
361
+ updated.read_at = timezone.now()
362
+ updated.save(update_fields=["read_at"])
363
+
364
+ @extend_schema(
365
+ summary="Mark all unread notifications read",
366
+ # Without this, drf-spectacular infers the viewset's NotificationSerializer;
367
+ # the action returns a small ``{updated: N}`` counter instead (#2127).
368
+ responses={
369
+ 200: inline_serializer(
370
+ name="MarkAllReadResult",
371
+ fields={"updated": serializers.IntegerField()},
372
+ )
373
+ },
374
+ )
375
+ @action(detail=False, methods=["post"], url_path="mark-all-read")
376
+ def mark_all_read(self, request: Request) -> Response:
377
+ """Mark every unread notification for this user as read in one update."""
378
+ user = request.user
379
+ if not user.is_authenticated:
380
+ return Response({"updated": 0}, status=status.HTTP_200_OK)
381
+ now = timezone.now()
382
+ updated = Notification.objects.filter(
383
+ recipient=user, is_read=False, is_archived=False
384
+ ).update(is_read=True, read_at=now)
385
+ return Response({"updated": updated}, status=status.HTTP_200_OK)
386
+
387
+
388
+ class NotificationPreferenceViewSet(
389
+ IdempotencyMixin,
390
+ mixins.ListModelMixin,
391
+ mixins.UpdateModelMixin,
392
+ viewsets.GenericViewSet[NotificationPreference],
393
+ ):
394
+ """Per-user (event_type, channel) toggle matrix.
395
+
396
+ GET /me/notification-preferences/
397
+ PATCH /me/notification-preferences/{id}/ { enabled: bool }
398
+
399
+ First GET backfills DEFAULT_PREFERENCES (Priya's email-OFF flip from V2 VoC)
400
+ if the user has no rows yet — lazy initialization so user-create doesn't
401
+ have to know about this table.
402
+ """
403
+
404
+ serializer_class = NotificationPreferenceSerializer
405
+ permission_classes: list[type[BasePermission]] = [IsAuthenticated]
406
+
407
+ def get_queryset(self) -> QuerySet[NotificationPreference]:
408
+ user = self.request.user
409
+ if not user.is_authenticated:
410
+ return NotificationPreference.objects.none()
411
+ return NotificationPreference.objects.filter(user=user).order_by("event_type", "channel")
412
+
413
+ def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
414
+ if request.user.is_authenticated:
415
+ get_or_create_default_preferences(request.user)
416
+ return super().list(request, *args, **kwargs)
417
+
418
+ def perform_update(self, serializer: Any) -> None:
419
+ """Disallow channel / event_type / user changes — only enabled toggle.
420
+
421
+ In practice unreachable (get_queryset already restricts to user=request.user
422
+ so cross-user PATCH 404s), but the guard is defense-in-depth.
423
+ """
424
+ instance: NotificationPreference = serializer.instance
425
+ if instance.user_id != self.request.user.pk:
426
+ # DRF's PermissionDenied → 403; bare PermissionError raises 500.
427
+ raise PermissionDenied("Cannot modify another user's preferences.")
428
+ serializer.save()
429
+
430
+ @extend_schema(
431
+ summary="Apply a notification preference preset",
432
+ # Every field of the inferred contract was wrong (#2840). Without this,
433
+ # drf-spectacular fell back to the viewset's NotificationPreferenceSerializer
434
+ # for BOTH sides: it declared a request of {event_type, channel, enabled}
435
+ # (the body is {"preset": …}) and a single-object 200 (the handler returns
436
+ # the whole matrix, many=True — a JSON array).
437
+ request=inline_serializer(
438
+ name="NotificationPreferencePresetRequest",
439
+ fields={"preset": serializers.ChoiceField(choices=["signal_only", "everything"])},
440
+ ),
441
+ responses={200: NotificationPreferenceSerializer(many=True)},
442
+ )
443
+ # The bare-array response also needs the view's paginator cleared, or
444
+ # drf-spectacular re-wraps this many=True declaration into the list route's
445
+ # envelope. That is done at the as_view() call in urls.py — this app routes by
446
+ # hand, so @action initkwargs are never applied. See the note there.
447
+ @action(detail=False, methods=["post"], url_path="apply-preset")
448
+ def apply_preset(self, request: Request) -> Response:
449
+ """Apply a wholesale preference preset (#855) and return the new matrix.
450
+
451
+ Body: ``{"preset": "signal_only" | "everything"}``.
452
+
453
+ - ``signal_only`` — the contributor profile: in-app ON for the two
454
+ attention-worthy events (task.blocked, task.due_date_changed), every
455
+ other (event, channel) row OFF. This is Priya's escape from a noisy
456
+ default without auditing the full grid cell-by-cell.
457
+ - ``everything`` — restore the enabled state from DEFAULT_PREFERENCES.
458
+
459
+ Implemented as a bulk write over the existing per-(event, channel) rows
460
+ rather than a new "profile" model, so the matrix stays the single source
461
+ of truth and the data-driven settings page (ADR-0085) needs no special
462
+ casing. Scoped to ``request.user`` by construction.
463
+ """
464
+ user = request.user
465
+ if not user.is_authenticated:
466
+ raise PermissionDenied("Authentication required.")
467
+ # Guard the top-level body shape: a non-object JSON body (list/str/scalar)
468
+ # has no ``.get``, so an unguarded access would raise AttributeError → 500.
469
+ # Treat any non-object body as a missing preset → the existing 400 (#2126).
470
+ preset = request.data.get("preset") if isinstance(request.data, dict) else None
471
+ if preset not in ("signal_only", "everything"):
472
+ return Response(
473
+ {"detail": "preset must be 'signal_only' or 'everything'."},
474
+ status=status.HTTP_400_BAD_REQUEST,
475
+ )
476
+ # Wrap get_or_create → read → bulk_update in a single atomic block so a
477
+ # concurrent second apply_preset on the same user (e.g. rapid double-click)
478
+ # cannot interleave a partial write — either the full preset lands or none
479
+ # of it does (#1317 atomicity finding).
480
+ with transaction.atomic():
481
+ # Ensure the full row set exists before we toggle (a brand-new user may
482
+ # have no rows yet — same lazy backfill the list view performs).
483
+ get_or_create_default_preferences(user)
484
+ prefs = list(NotificationPreference.objects.filter(user=user))
485
+ if preset == "signal_only":
486
+ for p in prefs:
487
+ p.enabled = (
488
+ p.channel == NotificationChannel.IN_APP
489
+ and p.event_type in SIGNAL_ONLY_EVENTS
490
+ )
491
+ else: # everything
492
+ default_map = {(e, c): enabled for (e, c, enabled) in DEFAULT_PREFERENCES}
493
+ for p in prefs:
494
+ p.enabled = default_map.get((p.event_type, p.channel), p.enabled)
495
+ NotificationPreference.objects.bulk_update(prefs, ["enabled"])
496
+ serializer = self.get_serializer(self.get_queryset(), many=True)
497
+ return Response(serializer.data, status=status.HTTP_200_OK)
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # Project-scoped notification preferences (#522)
502
+ # ---------------------------------------------------------------------------
503
+
504
+
505
+ _VALID_EVENT_TYPES = {choice.value for choice in ProjectNotificationEventType}
506
+ _VALID_CHANNELS = {choice.value for choice in ProjectNotificationChannel}
507
+
508
+
509
+ def _merge_matrix(
510
+ stored: dict[str, dict[str, bool]],
511
+ incoming: dict[str, dict[str, bool]],
512
+ ) -> dict[str, dict[str, bool]]:
513
+ """Merge `incoming` into a copy of `stored`, preferring incoming values.
514
+
515
+ The PATCH semantics are partial: a client toggling a single cell should
516
+ not have to repost the full 36-cell grid. The defaults dict is also
517
+ overlaid on the response so a freshly added event type is populated for
518
+ a user whose row predates the event.
519
+
520
+ Unknown event-type / channel keys, and non-bool leaves, are dropped (#675):
521
+ the serializer now rejects them on write, but a row persisted before that
522
+ validation shipped could still carry garbage. Filtering here keeps it out of
523
+ the GET response and the dispatcher's view even on an un-migrated row, and
524
+ matches the dispatcher's `_matrix_cell` so the echo and the effective value
525
+ agree — defense-in-depth on top of the one-shot cleanup migration.
526
+ """
527
+ merged: dict[str, dict[str, bool]] = {
528
+ evt: dict(chans) for evt, chans in PROJECT_NOTIFICATION_DEFAULT_MATRIX.items()
529
+ }
530
+ for source in (stored, incoming):
531
+ for evt, chans in source.items():
532
+ if evt not in _VALID_EVENT_TYPES or not isinstance(chans, dict):
533
+ continue
534
+ row = merged.setdefault(evt, {})
535
+ for ch, enabled in chans.items():
536
+ if ch in _VALID_CHANNELS and isinstance(enabled, bool):
537
+ row[ch] = enabled
538
+ return merged
539
+
540
+
541
+ class ProjectNotificationPreferenceView(IdempotencyMixin, APIView):
542
+ """GET/PATCH per-project notification preferences for the current user.
543
+
544
+ Mounted at ``/api/v1/projects/<pk>/notification-preferences/``. Any project
545
+ member may read and update their own preferences — there is no admin
546
+ surface to edit another member's routing (the design intentionally keeps
547
+ this user-controlled per ADR-0075's "each user owns their notification
548
+ contract" rule).
549
+
550
+ GET returns a defaults-overlaid view so a new event type added on the
551
+ server is populated even for a user whose row pre-dates the addition.
552
+ PATCH accepts a partial matrix and merges it onto the stored document.
553
+ """
554
+
555
+ # Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
556
+ # this the project-scoped classes below resolve nothing and `has_permission`
557
+ # returns True for everyone — the gate reads as live in review and enforces
558
+ # nothing. Declared per view because `pk` means something else on other routes.
559
+ project_url_kwarg = "pk"
560
+ permission_classes = [IsAuthenticated, IsProjectMember]
561
+ # Exempt from the archived-write invariant (#3414). The PATCH writes the CALLER'S
562
+ # OWN `ProjectNotificationPreference` row, not project state — ADR-0075's "each user
563
+ # owns their notification contract" rule. An archived project still emits
564
+ # notifications (mentions on its comments, digests), so a member must be able to
565
+ # silence it; gating this would freeze every member's routing at whatever it
566
+ # happened to be when the project was archived.
567
+ archived_write_exempt = (
568
+ "writes the caller's own per-project notification preference, not project "
569
+ "state; a member must still be able to silence an archived project"
570
+ )
571
+
572
+ def _get_project(self, request: Request, pk: str) -> Project:
573
+ project = get_object_or_404(Project, pk=pk, is_deleted=False)
574
+ self.check_object_permissions(request, project)
575
+ return project
576
+
577
+ def _get_or_create_pref(self, project: Project, user: Any) -> ProjectNotificationPreference:
578
+ pref, _ = ProjectNotificationPreference.objects.get_or_create(project=project, user=user)
579
+ # Prime the FK cache. get_or_create only populates it on the *create* branch
580
+ # (the get branch is a plain manager .get(), which never sets fields_cache), so
581
+ # on the common existing-row path `pref.project` would otherwise be a cold
582
+ # lazy load — and the quiet-hours timezone serializer field reads it (#3377).
583
+ pref.project = project
584
+ return pref
585
+
586
+ @extend_schema(responses={200: ProjectNotificationPreferenceDocumentSerializer})
587
+ def get(self, request: Request, pk: str) -> Response:
588
+ project = self._get_project(request, pk)
589
+ pref = self._get_or_create_pref(project, request.user)
590
+ # Overlay defaults so a newly added event type renders even when the
591
+ # stored matrix predates it.
592
+ merged = _merge_matrix(pref.matrix or {}, {})
593
+ # The document serializer, not the plain one: it carries `event_delivery` and
594
+ # `channel_delivery`, which are not model fields, and reading them from the
595
+ # same class the schema publishes is what keeps the two from drifting (#3396,
596
+ # #3399). See its docstring for why
597
+ # `responses={200: ProjectNotificationPreferenceSerializer}` would have been a
598
+ # self-consistent lie rather than a fix.
599
+ #
600
+ # `event_delivery` says which rows are actually wired to a dispatcher (#2904).
601
+ # Eight of the nine are not: the matrix rendered them, they defaulted ON
602
+ # across in-app, email and Slack, and toggling one had no effect in either
603
+ # direction. Reporting it as a server fact lets a client label those rows
604
+ # instead of implying a delivery that never happens — the alternative is
605
+ # every client hard-coding the same list and drifting from it (TODO(#3016)).
606
+ #
607
+ # `channel_delivery` is the same fact one axis over (#3378): two of the four
608
+ # columns have no delivery path at all. The web client was hard-coding
609
+ # exactly that list, with a comment saying it should not survive — which is
610
+ # the drift this endpoint exists to prevent (TODO(#3252)).
611
+ payload = ProjectNotificationPreferenceDocumentSerializer(pref).data
612
+ payload["matrix"] = merged
613
+ return Response(payload, status=status.HTTP_200_OK)
614
+
615
+ # Declared, or the operation publishes no ``requestBody`` and a generated client
616
+ # has no parameter to send the matrix through (#3364). A plain ``APIView`` has no
617
+ # ``serializer_class`` for drf-spectacular to infer a writable shape from, so
618
+ # nothing supplies this but the annotation — which is why the account-wide
619
+ # sibling below (``MyNotificationSettingsView.patch``) was declared and this one,
620
+ # the same shape one class up, was not.
621
+ @extend_schema(
622
+ request=ProjectNotificationPreferenceSerializer,
623
+ responses={200: ProjectNotificationPreferenceDocumentSerializer},
624
+ )
625
+ def patch(self, request: Request, pk: str) -> Response:
626
+ project = self._get_project(request, pk)
627
+ pref = self._get_or_create_pref(project, request.user)
628
+ serializer = ProjectNotificationPreferenceSerializer(pref, data=request.data, partial=True)
629
+ serializer.is_valid(raise_exception=True)
630
+
631
+ validated = serializer.validated_data
632
+ if "matrix" in validated:
633
+ # Merge so a partial cell update doesn't drop the rest of the grid.
634
+ merged = _merge_matrix(pref.matrix or {}, validated["matrix"])
635
+ pref.matrix = merged
636
+
637
+ for field in ("paused", "quiet_hours_enabled", "quiet_hours_from", "quiet_hours_until"):
638
+ if field in validated:
639
+ setattr(pref, field, validated[field])
640
+ pref.save()
641
+
642
+ # Built from the *bound* write serializer rather than re-reading through the
643
+ # document serializer, so the echo reflects what this request wrote. That is
644
+ # why `event_delivery` is added by hand here and not on GET — the two paths
645
+ # assemble differently but must publish the same document, which is what the
646
+ # response-conformance tests assert against the one declared component.
647
+ payload = serializer.data
648
+ payload["matrix"] = _merge_matrix(pref.matrix or {}, {})
649
+ # Same shape as GET. The web hook maps the PATCH response through the same
650
+ # deserializer and writes it to the query cache, so omitting either here would
651
+ # drop the "not delivered yet" labels the moment a user toggled anything.
652
+ payload["event_delivery"] = project_notification_event_delivery()
653
+ payload["channel_delivery"] = project_notification_channel_delivery()
654
+ return Response(payload, status=status.HTTP_200_OK)
655
+
656
+
657
+ class MyNotificationSettingsView(IdempotencyMixin, APIView):
658
+ """GET/PATCH the authenticated user's account-wide notification settings.
659
+
660
+ Mounted at ``/api/v1/me/notification-settings/`` — the authoritative read/write
661
+ surface for the Do-Not-Disturb switch (#1707, ADR-0292). The same
662
+ ``dnd_enabled`` value is projected read-only onto ``/auth/me`` so the web
663
+ current-user query carries it without a second request.
664
+
665
+ Self-scoped by construction: the row is always ``request.user``'s own
666
+ (``get_or_create`` on the caller), there is no object id in the path, and there
667
+ is no way to address another user's settings — so there is no IDOR surface and
668
+ no admin edit path, mirroring ADR-0075's "each user owns their notification
669
+ contract" rule.
670
+ """
671
+
672
+ permission_classes = [IsAuthenticated]
673
+
674
+ @extend_schema(responses={200: UserNotificationSettingsSerializer})
675
+ def get(self, request: Request) -> Response:
676
+ settings_row = get_or_create_notification_settings(request.user)
677
+ return Response(
678
+ UserNotificationSettingsSerializer(settings_row).data,
679
+ status=status.HTTP_200_OK,
680
+ )
681
+
682
+ @extend_schema(
683
+ request=UserNotificationSettingsSerializer,
684
+ responses={200: UserNotificationSettingsSerializer},
685
+ )
686
+ def patch(self, request: Request) -> Response:
687
+ settings_row = get_or_create_notification_settings(request.user)
688
+ serializer = UserNotificationSettingsSerializer(
689
+ settings_row, data=request.data, partial=True
690
+ )
691
+ serializer.is_valid(raise_exception=True)
692
+ serializer.save()
693
+ return Response(serializer.data, status=status.HTTP_200_OK)
694
+
695
+
696
+ def _email_settings_payload(obj: WorkspaceEmailSettings, *, can_edit: bool) -> dict[str, Any]:
697
+ """Serialize the singleton for the client, adding the read-only status flags.
698
+
699
+ The password is never included (write-only serializer field); the response
700
+ carries only ``password_is_set``. ``configured_via`` and ``host_configured``
701
+ preserve back-compat with the #639 read-only status hook.
702
+
703
+ ``frontend_base_url`` surfaces the install's public origin (env-only,
704
+ ``TRUEPPM_FRONTEND_BASE_URL``) so the Email settings page can show it read-only
705
+ and warn when it is unset — when empty, every emailed invite/reset deep-link
706
+ renders as a bare origin-less path and silently breaks (#2015). The value is
707
+ infra config an admin can already see elsewhere, not a secret; it is not
708
+ writable from the UI (domain changes stay a deploy concern).
709
+ """
710
+ data: dict[str, Any] = dict(WorkspaceEmailSettingsSerializer(obj).data)
711
+ data["can_edit"] = can_edit
712
+ data["configured_via"] = (
713
+ "database" if obj.transport_mode != EmailTransportMode.CLOUD else "environment"
714
+ )
715
+ data["host_configured"] = bool(obj.host)
716
+ frontend_base_url = settings.FRONTEND_BASE_URL.rstrip("/")
717
+ data["frontend_base_url"] = frontend_base_url
718
+ data["frontend_base_url_configured"] = bool(frontend_base_url)
719
+ return data
720
+
721
+
722
+ class WorkspaceEmailSettingsView(IdempotencyMixin, APIView):
723
+ """``/api/v1/workspace/email-settings/`` — writable workspace SMTP config.
724
+
725
+ Upgrades the #639 read-only status page to the writable surface (#712,
726
+ ADR-0213). ``GET`` is gated ``IsWorkspaceAdminStrict`` — a *workspace* ADMIN,
727
+ not merely someone with ADMIN on one project — because the payload discloses
728
+ installation mail-transport posture (SMTP host, from-domain, delivery
729
+ limits), the same class of org-topology disclosure the SSO config
730
+ read is gated against (#2016; mirrors ``sso.views`` / ADR-0087 §6). **Writes**
731
+ (``PUT``/``PATCH``) still require the install operator (superuser) because the
732
+ transport is installation-global — a single-project admin must not be able to
733
+ repoint all outbound mail at an attacker relay (security review C1). The
734
+ password is write-only and never echoed. A save is rejected (400) if the
735
+ candidate transport can't be opened, so a bad config can't lock the workspace
736
+ out of mail (validate-before-persist, ADR-0213 §3).
737
+ """
738
+
739
+ permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
740
+
741
+ def get_permissions(self) -> list[BasePermission]:
742
+ if self.request.method in ("PUT", "PATCH"):
743
+ return [IsAuthenticated(), IsWorkspaceOperator()]
744
+ return [IsAuthenticated(), IsWorkspaceAdminStrict()]
745
+
746
+ def get_throttles(self) -> list[Any]:
747
+ # Throttle only the write path (each write re-opens a candidate SMTP
748
+ # connection); GET is a cheap read the settings page polls.
749
+ if self.request.method in ("PUT", "PATCH"):
750
+ throttle = ScopedRateThrottle()
751
+ throttle.scope = "email_settings"
752
+ return [throttle]
753
+ return super().get_throttles()
754
+
755
+ def get(self, request: Request) -> Response:
756
+ obj = WorkspaceEmailSettings.load()
757
+ return Response(_email_settings_payload(obj, can_edit=bool(request.user.is_superuser)))
758
+
759
+ # Both writes published no ``requestBody`` while ``_update`` reads one, so the
760
+ # settings page's own save had no documented shape (#3364). Declared per method
761
+ # rather than on the class so GET keeps taking no body. These two are the
762
+ # instances the first draft of the #3364 sweep could not see: the read is one
763
+ # frame down in ``_update``, and a handler-local search finds only a delegating
764
+ # one-liner — which is why that sweep now follows ``self._helper()`` calls.
765
+ @extend_schema(request=WorkspaceEmailSettingsSerializer)
766
+ def put(self, request: Request) -> Response:
767
+ return self._update(request, partial=False)
768
+
769
+ @extend_schema(request=WorkspaceEmailSettingsSerializer)
770
+ def patch(self, request: Request) -> Response:
771
+ return self._update(request, partial=True)
772
+
773
+ def _update(self, request: Request, *, partial: bool) -> Response:
774
+ obj = WorkspaceEmailSettings.load()
775
+ serializer = WorkspaceEmailSettingsSerializer(
776
+ obj, data=request.data, partial=partial, context={"request": request}
777
+ )
778
+ serializer.is_valid(raise_exception=True)
779
+ serializer.save()
780
+ obj.refresh_from_db()
781
+ return Response(_email_settings_payload(obj, can_edit=True))
782
+
783
+
784
+ class WorkspaceEmailTestView(IdempotencyMixin, APIView):
785
+ """``POST /api/v1/workspace/email-settings/send-test/`` — send a test email.
786
+
787
+ Sends a fixed test message to the **requesting operator's own address only**
788
+ (server-derived — never a recipient from the request body, which would make
789
+ this an authenticated open relay, security review M5) through the resolved
790
+ transport. Synchronous so the admin gets immediate pass/fail feedback; a
791
+ transport failure returns 502 with a generic message.
792
+ """
793
+
794
+ permission_classes = [IsAuthenticated, IsWorkspaceOperator]
795
+ throttle_classes = [ScopedRateThrottle]
796
+ throttle_scope = "email_settings_probe"
797
+
798
+ @extend_schema(
799
+ request=None,
800
+ responses={
801
+ 200: OpenApiResponse(
802
+ response=inline_serializer(
803
+ name="WorkspaceEmailTestResult",
804
+ fields={
805
+ "sent": serializers.BooleanField(),
806
+ "recipient": serializers.EmailField(),
807
+ },
808
+ ),
809
+ description="Test message sent to the requesting operator's own address.",
810
+ ),
811
+ 400: OpenApiResponse(
812
+ # NOT the standard ``{"detail"}`` refusal envelope: this endpoint
813
+ # answers with its own ``{"sent", "error"}`` pair on both the 400
814
+ # and the 502 so a client renders one banner either way. Declared
815
+ # as it is actually emitted rather than as the house shape, which
816
+ # is the whole point of checking each site (#3319).
817
+ response={
818
+ "type": "object",
819
+ "properties": {
820
+ "sent": {"type": "boolean", "enum": [False]},
821
+ "error": {"type": "string"},
822
+ },
823
+ },
824
+ description=(
825
+ "The requesting operator's account has no email address on file, "
826
+ "so there is no server-derived recipient to send to. The recipient "
827
+ "is never read from the request body."
828
+ ),
829
+ ),
830
+ 502: OpenApiResponse(
831
+ response={
832
+ "type": "object",
833
+ "properties": {
834
+ "sent": {"type": "boolean", "enum": [False]},
835
+ "error": {"type": "string"},
836
+ },
837
+ },
838
+ description=(
839
+ "The configured transport refused or failed. The underlying "
840
+ "exception is deliberately not surfaced — it can echo credentials."
841
+ ),
842
+ ),
843
+ },
844
+ )
845
+ def post(self, request: Request) -> Response:
846
+ from django.core.mail import EmailMessage
847
+
848
+ from .email_backend import (
849
+ resolve_email_connection,
850
+ resolve_from_email,
851
+ resolve_reply_to,
852
+ )
853
+
854
+ recipient = (getattr(request.user, "email", "") or "").strip()
855
+ if not recipient:
856
+ return Response(
857
+ {"sent": False, "error": "Your account has no email address on file."},
858
+ status=status.HTTP_400_BAD_REQUEST,
859
+ )
860
+
861
+ obj = WorkspaceEmailSettings.load()
862
+ try:
863
+ connection = resolve_email_connection(obj)
864
+ EmailMessage(
865
+ subject="TruePPM test email",
866
+ body=(
867
+ "This is a test message from your TruePPM workspace email "
868
+ "configuration. If you received it, outbound mail is working."
869
+ ),
870
+ from_email=resolve_from_email(obj),
871
+ to=[recipient],
872
+ reply_to=resolve_reply_to(obj) or None,
873
+ connection=connection,
874
+ ).send(fail_silently=False)
875
+ except Exception:
876
+ # Never surface the underlying transport exception (may echo creds).
877
+ logger.warning("send-test: transport failed for %s", obj.transport_mode)
878
+ return Response(
879
+ {
880
+ "sent": False,
881
+ "error": "Could not send the test email. Check the transport configuration.",
882
+ },
883
+ status=status.HTTP_502_BAD_GATEWAY,
884
+ )
885
+ return Response({"sent": True, "recipient": recipient})
886
+
887
+
888
+ class WorkspaceEmailHealthView(APIView):
889
+ """``GET /api/v1/workspace/email-settings/health/`` — SPF/DKIM/DMARC posture.
890
+
891
+ Live, bounded DNS TXT lookups on the persisted From-address domain (never a
892
+ domain from request input). Operator-gated and tightly throttled — the
893
+ lookups are an egress surface (ADR-0213 §4, security review M4/H3).
894
+ """
895
+
896
+ permission_classes = [IsAuthenticated, IsWorkspaceOperator]
897
+ throttle_classes = [ScopedRateThrottle]
898
+ throttle_scope = "email_settings_probe"
899
+
900
+ def get(self, request: Request) -> Response:
901
+ from .email_health import check_deliverability
902
+
903
+ obj = WorkspaceEmailSettings.load()
904
+ return Response(check_deliverability(obj.from_email, obj.dkim_selector))