trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1965 @@
1
+ """ViewSets for the access app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from django.contrib.auth import get_user_model
9
+ from django.db import IntegrityError, transaction
10
+ from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery
11
+ from django.db.models.functions import Coalesce
12
+ from django.utils import timezone
13
+ from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
14
+ from rest_framework import serializers as drf_serializers
15
+ from rest_framework import status, viewsets
16
+ from rest_framework.decorators import action
17
+ from rest_framework.exceptions import PermissionDenied
18
+ from rest_framework.permissions import BasePermission, IsAuthenticated
19
+ from rest_framework.request import Request
20
+ from rest_framework.response import Response
21
+ from rest_framework.serializers import BaseSerializer
22
+ from rest_framework.throttling import BaseThrottle, ScopedRateThrottle, UserRateThrottle
23
+ from rest_framework.views import APIView
24
+
25
+ from trueppm_api.apps.access.models import (
26
+ ExternalStakeholder,
27
+ ProgramMembership,
28
+ ProgramUserDefinedMentionGroup,
29
+ ProjectMembership,
30
+ Role,
31
+ UserDefinedMentionGroup,
32
+ )
33
+ from trueppm_api.apps.access.permissions import (
34
+ IsProgramAdmin,
35
+ IsProgramMember,
36
+ IsProgramNotClosed,
37
+ IsProgramOwner,
38
+ IsProjectMember,
39
+ IsProjectNotArchived,
40
+ IsProjectOwner,
41
+ McpReadableViewMixin,
42
+ McpScope,
43
+ _membership_role,
44
+ _program_membership_role,
45
+ assert_project_not_archived,
46
+ )
47
+ from trueppm_api.apps.access.serializers import (
48
+ ExternalStakeholderSerializer,
49
+ MeSerializer,
50
+ ProgramMembershipReadSerializer,
51
+ ProgramMembershipUpdateSerializer,
52
+ ProgramMembershipWriteSerializer,
53
+ ProgramUserDefinedMentionGroupReadSerializer,
54
+ ProgramUserDefinedMentionGroupWriteSerializer,
55
+ ProjectMembershipReadSerializer,
56
+ ProjectMembershipUpdateSerializer,
57
+ ProjectMembershipWriteSerializer,
58
+ UserDefinedMentionGroupReadSerializer,
59
+ UserDefinedMentionGroupWriteSerializer,
60
+ UserSearchResultSerializer,
61
+ )
62
+ from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
63
+ from trueppm_api.apps.projects.models import Program, Project
64
+ from trueppm_api.apps.workspace.permissions import IsWorkspaceMember
65
+ from trueppm_api.core.openapi import (
66
+ ownership_refusal_403,
67
+ state_refusal_400,
68
+ suppress_list_pagination,
69
+ )
70
+ from trueppm_api.core.request_body import object_body
71
+
72
+ _PK = str | uuid.UUID
73
+
74
+ _PERMISSION_DENIED_DETAIL = "You do not have permission to perform this action."
75
+ _ROLE_NOT_BELOW_OWN_ERROR = "You cannot assign a role equal to or higher than your own."
76
+ _PROGRAM_NOT_FOUND_DETAIL = "Program not found."
77
+ _NOT_PROJECT_MEMBER_DETAIL = "You are not a member of this project."
78
+ _NOT_PROGRAM_MEMBER_DETAIL = "You are not a member of this program."
79
+
80
+ # #4578: a mention-group member-add must not tell a caller whether `user` names a
81
+ # real account. One message covers "no such id", "that id is inactive", and "that
82
+ # id is real but unreachable (not on this project / not on any project in this
83
+ # program)" — splitting any of those three into a distinct string turns the
84
+ # endpoint into a user-id existence oracle for any Scheduler+ (project) / Admin+
85
+ # (program) caller.
86
+ _MEMBER_USER_NOT_REACHABLE_PROJECT_DETAIL = "User not found or not a member of this project."
87
+ _MEMBER_USER_NOT_REACHABLE_PROGRAM_DETAIL = (
88
+ "User not found or not a member of any project in this program."
89
+ )
90
+
91
+
92
+ def _revive_revoked_membership(
93
+ membership: ProjectMembership | ProgramMembership, *, new_role: int
94
+ ) -> None:
95
+ """Un-tombstone a revoked membership row in place, at ``new_role`` (#3410).
96
+
97
+ ``(project, user)`` / ``(program, user)`` uniqueness is declared
98
+ **unconditionally**, so a revoked (soft-deleted) row keeps occupying its slot.
99
+ Re-adding that member therefore has to reuse the row — an INSERT hits the
100
+ constraint. The group-cascade reconciler resurrects rather than inserts for the
101
+ same reason (``workspace.services._reconcile_pair``), and both paths depend on
102
+ at most one row existing per (scope, user): that reconciler keys its "does a
103
+ row already exist" lookup on it, so a second live row would make its decision
104
+ ambiguous.
105
+
106
+ Every field the write serializer accepts is stamped as it would be on a fresh
107
+ add; the server-owned identity survives — the primary key (which is what an
108
+ offline client holds) and ``joined_at``, because this is the same membership
109
+ resuming rather than a new one.
110
+
111
+ ``role_changed_at`` is stamped only when the role actually differs from the one
112
+ held at revocation, so re-adding someone at their old role does not fabricate a
113
+ role-change event. This is the rule ``partial_update`` uses and it is
114
+ deliberately **not** what ``_reconcile_pair`` does — that path stamps
115
+ unconditionally on resurrect. The difference is intentional: the reconciler
116
+ cannot see whether a human meant the role to change, whereas this endpoint was
117
+ handed one explicitly.
118
+
119
+ ``reinstated_at`` is stamped unconditionally with ``timezone.now()`` on every
120
+ revive (#3436) — unlike ``role_changed_at`` it does not compare against the
121
+ prior state, because a revive is itself the fact being recorded, regardless of
122
+ whether the role also changed. This is what closes the #3410 known limitation:
123
+ ``deleted_version`` is still cleared and ``joined_at`` still reports the
124
+ original join date, but ``reinstated_at`` now gives a caller — human or
125
+ machine — a durable server-side discriminator between "joined once, never
126
+ left" and "was away and came back", without needing ``HistoricalRecords`` or
127
+ an ``AuditEvent`` that this app does not write.
128
+
129
+ The save draws a fresh ``sync_seq`` (ADR-0686), which is what makes the row
130
+ re-materialize on the next delta pull: the sync endpoint splits rows into
131
+ 'updated' vs 'deleted' purely on the current ``is_deleted`` value, so a client
132
+ holding the tombstone sees an **update** to a row it already knows, never a
133
+ duplicate (ADR-0202).
134
+ """
135
+ membership.is_deleted = False
136
+ membership.deleted_version = None
137
+ membership.reinstated_at = timezone.now()
138
+ if new_role != membership.role:
139
+ membership.role = new_role
140
+ membership.role_changed_at = timezone.now()
141
+ # The row was just SELECTed, so the UPDATE path is known — skip the exists()
142
+ # probe, exactly as ``soft_delete`` and ``restore`` do (#1527).
143
+ membership.save(known_exists=True)
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Lock-acquisition order (#3438)
148
+ # ---------------------------------------------------------------------------
149
+ # `create` and `partial_update` on both membership viewsets, `_check_last_owner_
150
+ # guard` (both viewsets), and `workspace.services.reconcile_group_access` all
151
+ # take `SELECT ... FOR UPDATE` locks on ProjectMembership / ProgramMembership
152
+ # rows inside a transaction. Whenever a request needs more than one such row —
153
+ # `create` locks the actor's own row plus the target's existing row (if any);
154
+ # `partial_update` locks the actor's own row plus the row it patches — they are
155
+ # locked together, in a single statement, ordered by ascending `pk`.
156
+ #
157
+ # The rule is deliberately "ascending pk", not "actor row first" or "target row
158
+ # first": a role-based order deadlocks the moment two requests have their actor
159
+ # and target reversed on the same pair of rows (Owner A adds/patches Owner B
160
+ # while Owner B concurrently adds/patches Owner A) — each transaction would
161
+ # hold the row it locked first and block waiting on the other, forever.
162
+ # Ordering by an intrinsic, row-level key instead of a role means every
163
+ # transaction agrees on which row to lock first regardless of which side of
164
+ # the request it is on, so the two requests serialize instead of deadlocking.
165
+ # `_check_last_owner_guard` and `reconcile_group_access` each already take a
166
+ # single multi-row lock; `order_by("pk")` there keeps them inside the same
167
+ # convention rather than leaving their row order database-defined.
168
+ # ---------------------------------------------------------------------------
169
+
170
+
171
+ class MembershipGrantThrottle(UserRateThrottle):
172
+ """Per-user rate limit on granting project/program membership (#3645).
173
+
174
+ Naming another account on a write is the same directory-shaped exposure
175
+ ``user_search`` (#815) and ``ResourceCatalogThrottle`` (#891) guard against on
176
+ reads — a workspace Admin reaches every active account via the reachable-
177
+ target queryset (#3641). Neither membership viewset set a ``throttle_scope``,
178
+ so ``create`` inherited the default ``user`` scope (1000/min). Shared by both
179
+ ``ProjectMembershipViewSet`` and ``ProgramMembershipViewSet`` — one 60/min
180
+ budget for the account, not one per resource type. The rate is set inline
181
+ (mirroring ``ResourceCatalogThrottle``) so no settings entry is required.
182
+ """
183
+
184
+ scope = "membership_grant"
185
+ rate = "60/min"
186
+
187
+
188
+ class ProjectMembershipViewSet(IdempotencyMixin, viewsets.GenericViewSet[ProjectMembership]):
189
+ """Nested CRUD for project memberships.
190
+
191
+ URL: /api/v1/projects/{project_pk}/members/
192
+ /api/v1/projects/{project_pk}/members/{pk}/
193
+
194
+ Permission matrix:
195
+ list/retrieve — any project member (Viewer+)
196
+ create — Owner only (role == Role.OWNER)
197
+ partial_update — Owner only; caller may not assign role >= their own
198
+ destroy — Owner only for others; any member may self-remove
199
+ (last-Owner guard prevents stranding a project)
200
+ """
201
+
202
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
203
+ archived_write_exempt = {
204
+ "destroy": (
205
+ "self-removal must survive archiving; removing another member is still "
206
+ "refused, in the destroy body where `is_self` is known"
207
+ )
208
+ }
209
+
210
+ def get_permissions(self) -> list[BasePermission]:
211
+ """Express the Owner-only create/partial_update gate at the permission layer.
212
+
213
+ The view bodies already enforce ``Role.OWNER`` (and the assign-below-self
214
+ and last-Owner invariants), but a Viewer reached the body before being
215
+ rejected — the role gate was invisible to DRF-level audits and OpenAPI
216
+ security generation (#1351). Adding IsProjectOwner here is defense-in-depth,
217
+ not a behavior change: the in-body checks remain authoritative. ``destroy``
218
+ is deliberately excluded — any member may self-remove (the last-Owner guard
219
+ prevents stranding a project), so it must not require Owner.
220
+ """
221
+ perms: list[BasePermission] = [IsAuthenticated(), IsProjectMember()]
222
+ if self.action in ("create", "partial_update"):
223
+ perms.append(IsProjectOwner())
224
+ if self.action != "destroy":
225
+ # `destroy` is the one action that must survive archiving, and only for the
226
+ # self-removal case (#3414). `IsProjectNotArchived` fires in `has_permission`
227
+ # on this route — before the object is fetched and long before `is_self` is
228
+ # known — so leaving it on the chain would close the "any member may
229
+ # self-remove" path documented above. The archived refusal for removing
230
+ # SOMEONE ELSE is re-asserted in the `destroy` body, where `is_self` exists.
231
+ perms.append(IsProjectNotArchived())
232
+ return perms
233
+
234
+ def get_throttles(self) -> list[BaseThrottle]:
235
+ """Scope the directory-shaped-exposure throttle to ``create`` only (#3645).
236
+
237
+ ``partial_update``/``destroy`` name an account already on the roster —
238
+ the harvest concern ``MembershipGrantThrottle`` exists for is naming an
239
+ arbitrary reachable account on a grant, not re-touching a known row.
240
+ """
241
+ if self.action == "create":
242
+ return [MembershipGrantThrottle()]
243
+ return super().get_throttles()
244
+
245
+ def get_queryset(self) -> QuerySet[ProjectMembership]:
246
+ project_pk = self.kwargs["project_pk"]
247
+ # Annotate each row with the member's count of OTHER active projects (#598)
248
+ # via a correlated subquery — one extra query for the whole page, no N+1.
249
+ # Coalesce to 0 for members who are on no other active project.
250
+ other_active = (
251
+ ProjectMembership.objects.filter(
252
+ user_id=OuterRef("user_id"),
253
+ is_deleted=False,
254
+ project__is_deleted=False,
255
+ project__is_archived=False,
256
+ )
257
+ .exclude(project_id=project_pk)
258
+ .values("user_id")
259
+ .annotate(c=Count("project_id", distinct=True))
260
+ .values("c")
261
+ )
262
+ return (
263
+ ProjectMembership.objects.select_related("project", "user")
264
+ .filter(project_id=project_pk, is_deleted=False)
265
+ .annotate(
266
+ other_active_count=Coalesce(Subquery(other_active, output_field=IntegerField()), 0)
267
+ )
268
+ )
269
+
270
+ def _build_other_project_names_map(
271
+ self, request: Request, memberships: list[ProjectMembership], current_project_pk: _PK
272
+ ) -> dict[object, list[str]]:
273
+ """Map user_id -> names of their other active projects the REQUESTER owns (#598).
274
+
275
+ Visibility gate: a project name is revealed only for projects the requesting user
276
+ is OWNER of — never leak the name of a project the requester cannot already see.
277
+ Bounded query cost: one query for the requester's owned projects + one for the
278
+ members' memberships within that owned set (no per-row queries).
279
+ """
280
+ names_map: dict[object, list[str]] = {m.user_id: [] for m in memberships}
281
+ if not memberships:
282
+ return names_map
283
+ owned_ids = set(
284
+ ProjectMembership.objects.filter(
285
+ # IsAuthenticated guarantees a real User, but mypy still sees
286
+ # request.user as User | AnonymousUser for the lookup.
287
+ user=request.user, # type: ignore[misc]
288
+ role=Role.OWNER,
289
+ is_deleted=False,
290
+ project__is_deleted=False,
291
+ project__is_archived=False,
292
+ )
293
+ .exclude(project_id=current_project_pk)
294
+ .values_list("project_id", flat=True)
295
+ )
296
+ if not owned_ids:
297
+ return names_map
298
+ rows = (
299
+ ProjectMembership.objects.filter(
300
+ user_id__in=list(names_map.keys()),
301
+ is_deleted=False,
302
+ project_id__in=owned_ids,
303
+ # Self-contained active-project gate (defense in depth): owned_ids is
304
+ # already built from active projects, but pin it here too so a future
305
+ # change to owned_ids can't silently surface an archived/deleted name.
306
+ project__is_deleted=False,
307
+ project__is_archived=False,
308
+ )
309
+ .select_related("project")
310
+ .order_by("project__name")
311
+ )
312
+ for row in rows:
313
+ names_map[row.user_id].append(row.project.name)
314
+ return names_map
315
+
316
+ def get_serializer_class(self) -> type[BaseSerializer[ProjectMembership]]:
317
+ if self.action in ("partial_update", "update"):
318
+ # An update body carries role only — ``user`` is immutable (#3641).
319
+ return ProjectMembershipUpdateSerializer
320
+ if self.action == "create":
321
+ return ProjectMembershipWriteSerializer
322
+ return ProjectMembershipReadSerializer
323
+
324
+ def _get_project_or_404(self) -> Project:
325
+ try:
326
+ return Project.objects.get(pk=self.kwargs["project_pk"], is_deleted=False)
327
+ except Project.DoesNotExist as err:
328
+ from rest_framework.exceptions import NotFound
329
+
330
+ raise NotFound("Project not found.") from err
331
+
332
+ def _require_actor_role(self, request: Request, project_id: _PK, minimum: int) -> int:
333
+ """Return the actor's role, raising 403 if below minimum."""
334
+ role = _membership_role(request, project_id)
335
+ if role is None or role < minimum:
336
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
337
+ return role
338
+
339
+ def _check_last_owner_guard(self, project_id: _PK, exclude_pk: _PK | None = None) -> None:
340
+ """Raise **400** if removing/demoting would strand the project without an Owner."""
341
+ qs = ProjectMembership.objects.filter(
342
+ project_id=project_id, role=Role.OWNER, is_deleted=False
343
+ )
344
+ if exclude_pk:
345
+ qs = qs.exclude(pk=exclude_pk)
346
+ # select_for_update prevents concurrent removal of both owners simultaneously.
347
+ # order_by("pk"): see the module-level "Lock-acquisition order" comment.
348
+ if not qs.order_by("pk").select_for_update().exists():
349
+ raise drf_serializers.ValidationError(
350
+ {"detail": "Cannot remove or demote the last Owner of a project."}
351
+ )
352
+
353
+ # -----------------------------------------------------------------------
354
+ # Actions
355
+ # -----------------------------------------------------------------------
356
+
357
+ @extend_schema(responses={200: ProjectMembershipReadSerializer(many=True)})
358
+ # This handler returns a bare array, but ProjectMembershipViewSet inherits the
359
+ # default pagination class, so the auto-schema would otherwise declare a
360
+ # Paginated...List envelope the real body violates (#3649). Opt out so the
361
+ # schema matches the response.
362
+ @suppress_list_pagination
363
+ def list(self, request: Request, **kwargs: object) -> Response:
364
+ project = self._get_project_or_404()
365
+ # has_permission only checks authentication; enforce membership explicitly
366
+ # because DRF only calls has_object_permission on retrieve/update/destroy.
367
+ if _membership_role(request, project.pk) is None:
368
+ raise PermissionDenied("You must be a member of this project.")
369
+ qs = self.get_queryset()
370
+ # ?self=true: return only the requesting user's own membership row.
371
+ # Used by the frontend useCurrentUserRole() hook for tab-level RBAC.
372
+ if request.query_params.get("self") == "true":
373
+ user_pk = request.user.pk
374
+ assert user_pk is not None # IsAuthenticated ensures a real user
375
+ qs = qs.filter(user_id=user_pk)
376
+ memberships = list(qs)
377
+ names_map = self._build_other_project_names_map(request, memberships, project.pk)
378
+ serializer = ProjectMembershipReadSerializer(
379
+ memberships,
380
+ many=True,
381
+ context={"request": request, "other_project_names_map": names_map},
382
+ )
383
+ return Response(serializer.data)
384
+
385
+ def retrieve(self, request: Request, pk: object = None, **kwargs: object) -> Response:
386
+ project = self._get_project_or_404()
387
+ instance = self.get_object()
388
+ names_map = self._build_other_project_names_map(request, [instance], project.pk)
389
+ return Response(
390
+ ProjectMembershipReadSerializer(
391
+ instance,
392
+ context={"request": request, "other_project_names_map": names_map},
393
+ ).data
394
+ )
395
+
396
+ @extend_schema(
397
+ responses={
398
+ 201: ProjectMembershipReadSerializer,
399
+ 400: state_refusal_400(
400
+ "Refused on the request: the role is at or above the caller's own, or "
401
+ "``user`` names an account the caller cannot reach (#3641). The "
402
+ "unreachable-target message is identical to the one a nonexistent id "
403
+ "gets, so the field is not an existence oracle."
404
+ ),
405
+ 409: OpenApiResponse(
406
+ description=(
407
+ "``user`` already holds a live membership on this project — "
408
+ "re-adding a revoked member instead revives their existing row "
409
+ "(see the handler), so this is only reached when the named "
410
+ "account is already an active member."
411
+ )
412
+ ),
413
+ }
414
+ )
415
+ def create(self, request: Request, **kwargs: object) -> Response:
416
+ project = self._get_project_or_404()
417
+ self._require_actor_role(request, project.pk, Role.OWNER)
418
+
419
+ # The write serializer bounds ``user`` to the accounts this caller may name
420
+ # (reachable_membership_targets, #3641) — it needs the request to do that,
421
+ # and resolves nobody without it.
422
+ serializer = ProjectMembershipWriteSerializer(
423
+ data=request.data, context={"request": request}
424
+ )
425
+ serializer.is_valid(raise_exception=True)
426
+
427
+ # Role is optional on add (ADR-0363, #157): fall back to the project's
428
+ # configured default when the caller does not name one. The strictly-below-
429
+ # your-own guard below then applies to the resolved role exactly as it does
430
+ # to an explicit one — a default is never a way around it.
431
+ new_role = serializer.validated_data.get("role")
432
+ if new_role is None:
433
+ new_role = project.default_member_role
434
+
435
+ user = serializer.validated_data["user"]
436
+ # The (project, user) unique constraint is unconditional, so the row a
437
+ # revoked member left behind still owns the slot. Deciding on the *live*
438
+ # rows only let a re-add sail past the guard straight into the constraint,
439
+ # which surfaced as a 500 (#3410); the decision has to be made on the row
440
+ # whatever its is_deleted state, and the row is locked so a concurrent add
441
+ # cannot slip between the read and the write. Under ATOMIC_REQUESTS that
442
+ # lock is held to the request's commit, not to the end of this block — so
443
+ # the 409 on a live duplicate now also holds it briefly. Accepted: the
444
+ # contention is confined to one (project, user) pair.
445
+ # ``of=("self",)`` keeps the lock on the membership row — a bare
446
+ # select_for_update alongside select_related would lock the joined auth_user
447
+ # row too, on every add. select_related is what stops the read serializer's
448
+ # ``user_detail`` from lazy-loading the user the caller already named.
449
+ #
450
+ # The actor's own membership row is locked in the SAME statement (#3438):
451
+ # reading the actor's role ceiling via the request-cached, unlocked
452
+ # `_membership_role()` — as this endpoint did before — left a TOCTOU window
453
+ # where a concurrent demotion of the actor between the initial permission
454
+ # check and this write could let them grant at their pre-demotion ceiling.
455
+ # See the module-level "Lock-acquisition order" comment for why the two
456
+ # rows are locked together, ordered by pk, rather than "actor, then
457
+ # target".
458
+ actor_pk = request.user.pk
459
+ assert actor_pk is not None # IsAuthenticated ensures a real user
460
+ with transaction.atomic():
461
+ locked = {
462
+ m.user_id: m
463
+ for m in (
464
+ ProjectMembership.objects.select_for_update(of=("self",))
465
+ .select_related("user")
466
+ .filter(project=project, user_id__in={actor_pk, user.pk})
467
+ .order_by("pk")
468
+ )
469
+ }
470
+ actor_membership = locked.get(actor_pk)
471
+ if actor_membership is None or actor_membership.is_deleted:
472
+ raise PermissionDenied(_NOT_PROJECT_MEMBER_DETAIL)
473
+ actor_role = actor_membership.role
474
+ if actor_role < Role.OWNER:
475
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
476
+ # Caller may only assign roles strictly below their own — read from the
477
+ # row just locked, not the unlocked cache read this replaced.
478
+ if new_role >= actor_role:
479
+ raise drf_serializers.ValidationError({"role": _ROLE_NOT_BELOW_OWN_ERROR})
480
+
481
+ existing = locked.get(user.pk)
482
+ if existing is not None and not existing.is_deleted:
483
+ return Response(
484
+ {"detail": "User is already a member of this project."},
485
+ status=status.HTTP_409_CONFLICT,
486
+ )
487
+ if existing is not None:
488
+ # A direct re-add supersedes any group provenance the revoked row
489
+ # carried. Group reconciliation only revokes rows it owns
490
+ # (source_group IS NOT NULL) and never touches a direct grant, so
491
+ # leaving the FK set would let a later reconcile revoke a
492
+ # membership an Owner granted by hand.
493
+ existing.source_group = None
494
+ _revive_revoked_membership(existing, new_role=new_role)
495
+ instance = existing
496
+ else:
497
+ # Pass the resolved role explicitly — it may have come from the
498
+ # project default rather than the request payload, so it is not in
499
+ # validated_data.
500
+ try:
501
+ # Savepoint so a lost INSERT race (no row existed to lock, two
502
+ # requests both got here) leaves the outer transaction usable
503
+ # and answers 409 instead of the 500 this guard exists to avoid.
504
+ with transaction.atomic():
505
+ instance = serializer.save(project=project, role=new_role)
506
+ except IntegrityError:
507
+ # Narrow the 409 to the uniqueness race this branch exists for.
508
+ # If a (project, user) row is present now, another request won
509
+ # the INSERT. Anything else — an FK violation from a
510
+ # concurrently hard-deleted user, or a constraint added to this
511
+ # table later — must not be answered "already a member", so
512
+ # re-raise it rather than masking it as a benign conflict.
513
+ if not ProjectMembership.objects.filter(project=project, user=user).exists():
514
+ raise
515
+ return Response(
516
+ {"detail": "User is already a member of this project."},
517
+ status=status.HTTP_409_CONFLICT,
518
+ )
519
+
520
+ project_id = str(project.pk)
521
+ membership_id = str(instance.pk)
522
+ user_id = str(user.pk)
523
+
524
+ # Audit + notify only on the success path reached above (#3645). Written
525
+ # synchronously — record_audit_event's own contract — so it rolls back
526
+ # with the request if anything after this point fails; DRF's
527
+ # exception_handler calls set_rollback() on every APIException, so a
528
+ # refusal can never reach here to begin with.
529
+ from trueppm_api.apps.notifications.models import NotificationEventType
530
+ from trueppm_api.apps.notifications.services import create_event_notifications
531
+ from trueppm_api.apps.workspace.models import AuditEventType
532
+ from trueppm_api.apps.workspace.services import _actor_label, record_audit_event
533
+
534
+ record_audit_event(
535
+ event_type=AuditEventType.MEMBER_ADDED,
536
+ actor=request.user,
537
+ target_type="member",
538
+ target_id=instance.pk,
539
+ target_label=_actor_label(user),
540
+ metadata={"project_id": project_id, "role": Role(new_role).label, "source": "grant"},
541
+ )
542
+ actor_label = _actor_label(request.user)
543
+ role_label = Role(new_role).label
544
+ subject = f"You were added to {project.name}"
545
+ body = f"{actor_label} added you to {project.name} as {role_label}."
546
+ # Snapshot to a plain value before the closure, not `user.pk` inline — the
547
+ # closure must never hold a live ORM instance (security-review, #3645).
548
+ recipient_id = user.pk
549
+ transaction.on_commit(
550
+ lambda: create_event_notifications(
551
+ event_type=NotificationEventType.MEMBERSHIP_GRANTED,
552
+ recipient_ids=[recipient_id],
553
+ subject=subject,
554
+ body=body,
555
+ project_id=project_id,
556
+ )
557
+ )
558
+
559
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
560
+
561
+ transaction.on_commit(
562
+ lambda: broadcast_board_event(
563
+ project_id,
564
+ "member_added",
565
+ {"membership_id": membership_id, "user_id": user_id, "role": new_role},
566
+ )
567
+ )
568
+
569
+ return Response(
570
+ ProjectMembershipReadSerializer(instance).data, status=status.HTTP_201_CREATED
571
+ )
572
+
573
+ def _audit_and_notify_role_change(
574
+ self,
575
+ request: Request,
576
+ project: Project,
577
+ instance: ProjectMembership,
578
+ old_role: int,
579
+ new_role: int,
580
+ ) -> None:
581
+ """Audit a real role change and queue the target's notification on commit."""
582
+ project_id = str(project.pk)
583
+ from trueppm_api.apps.notifications.models import NotificationEventType
584
+ from trueppm_api.apps.notifications.services import create_event_notifications
585
+ from trueppm_api.apps.workspace.models import AuditEventType
586
+ from trueppm_api.apps.workspace.services import _actor_label, record_audit_event
587
+
588
+ record_audit_event(
589
+ event_type=AuditEventType.MEMBER_ROLE_CHANGED,
590
+ actor=request.user,
591
+ target_type="member",
592
+ target_id=instance.pk,
593
+ target_label=_actor_label(instance.user),
594
+ metadata={
595
+ "project_id": project_id,
596
+ "old_role": Role(old_role).label,
597
+ "new_role": Role(new_role).label,
598
+ },
599
+ )
600
+ actor_label = _actor_label(request.user)
601
+ old_role_label = Role(old_role).label
602
+ new_role_label = Role(new_role).label
603
+ target_user_id = instance.user_id
604
+ subject = f"Your role changed on {project.name}"
605
+ body = (
606
+ f"{actor_label} changed your role on {project.name} from "
607
+ f"{old_role_label} to {new_role_label}."
608
+ )
609
+ transaction.on_commit(
610
+ lambda: create_event_notifications(
611
+ event_type=NotificationEventType.MEMBERSHIP_ROLE_CHANGED,
612
+ recipient_ids=[target_user_id],
613
+ subject=subject,
614
+ body=body,
615
+ project_id=project_id,
616
+ )
617
+ )
618
+
619
+ @extend_schema(
620
+ responses={
621
+ 200: ProjectMembershipReadSerializer,
622
+ 400: state_refusal_400(
623
+ "Refused on the request: the role is at or above the caller's own, "
624
+ "demoting this member would strand the project without an Owner, or the "
625
+ "body carried ``user`` — a membership's account is fixed at creation "
626
+ "(#3641)."
627
+ ),
628
+ }
629
+ )
630
+ def partial_update(self, request: Request, pk: object = None, **kwargs: object) -> Response:
631
+ project = self._get_project_or_404()
632
+ instance = self.get_object()
633
+ old_role = instance.role # snapshot before serializer.save() mutates it in place
634
+
635
+ serializer = ProjectMembershipUpdateSerializer(
636
+ instance, data=request.data, partial=True, context={"request": request}
637
+ )
638
+ serializer.is_valid(raise_exception=True)
639
+
640
+ new_role = serializer.validated_data.get("role")
641
+
642
+ # M4 fix: lock the actor's own membership row with SELECT FOR UPDATE inside
643
+ # an atomic block to close the TOCTOU window where a concurrent demotion
644
+ # could allow the actor to assign a role >= their effective role at save
645
+ # time. Locked together with `instance` (the row being patched), in one
646
+ # statement ordered by ascending pk — see the module-level
647
+ # "Lock-acquisition order" comment for why "actor row, then instance" can
648
+ # deadlock and pk order cannot.
649
+ #
650
+ # When a role change is requested, every current Owner's row is folded
651
+ # into this SAME statement too (security-review, #3438): a role change
652
+ # might trip the last-Owner guard below, which takes its own
653
+ # select_for_update() on the project's other Owner rows. Taking that as
654
+ # a SECOND, separate statement after this one reopens exactly the
655
+ # deadlock this comment warns about — this transaction would hold
656
+ # {actor, instance} and then reach for the Owner set, while a
657
+ # concurrent transaction with three or more Owners racing could be
658
+ # holding one of those Owner rows and reaching for {actor, instance} in
659
+ # the opposite order. Locking the superset up front, in one
660
+ # ascending-pk pass, means `_check_last_owner_guard`'s own query below
661
+ # only ever re-locks rows this statement already holds — a no-op, not a
662
+ # wait. We don't yet know under lock whether `instance` is actually an
663
+ # Owner, so this widens whenever a role change is requested at all,
664
+ # not only when the pre-lock `instance.role` looks like one; the
665
+ # over-inclusion when it turns out not to be a demotion is harmless.
666
+ with transaction.atomic():
667
+ lock_filter = Q(pk=instance.pk) | Q(
668
+ project=project, user=request.user, is_deleted=False
669
+ )
670
+ if new_role is not None:
671
+ lock_filter |= Q(project=project, role=Role.OWNER, is_deleted=False)
672
+ locked_rows = list(
673
+ ProjectMembership.objects.select_for_update().filter(lock_filter).order_by("pk")
674
+ )
675
+ actor_membership = next(
676
+ (m for m in locked_rows if m.user_id == request.user.pk and not m.is_deleted),
677
+ None,
678
+ )
679
+ if actor_membership is None:
680
+ raise PermissionDenied(_NOT_PROJECT_MEMBER_DETAIL) from None
681
+
682
+ actor_role = actor_membership.role
683
+ if actor_role < Role.OWNER:
684
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
685
+
686
+ if new_role is not None:
687
+ # Cannot assign role >= actor's own.
688
+ if new_role >= actor_role:
689
+ raise drf_serializers.ValidationError({"role": _ROLE_NOT_BELOW_OWN_ERROR})
690
+ # Last-Owner guard: if demoting an Owner, ensure another Owner exists.
691
+ # Its own select_for_update() only re-locks rows already held above.
692
+ if instance.role == Role.OWNER and new_role < Role.OWNER:
693
+ self._check_last_owner_guard(project.pk, exclude_pk=instance.pk)
694
+
695
+ # Stamp role_changed_at only on an actual role change (#590) so a
696
+ # no-op PATCH that re-sends the same role does not falsely advance
697
+ # the per-project access-evidence timestamp.
698
+ if new_role is not None and new_role != instance.role:
699
+ serializer.save(role_changed_at=timezone.now())
700
+ else:
701
+ serializer.save()
702
+
703
+ project_id = str(project.pk)
704
+ membership_id = str(instance.pk)
705
+ user_id = str(instance.user_id)
706
+ role_val = instance.role
707
+
708
+ # Audit + notify only when the role actually changed (#3645) — a no-op
709
+ # PATCH (unchanged role) or a role_title-only PATCH must not fabricate a
710
+ # role-change event. Written synchronously, same rationale as `create`.
711
+ if new_role is not None and new_role != old_role:
712
+ self._audit_and_notify_role_change(request, project, instance, old_role, new_role)
713
+
714
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
715
+
716
+ transaction.on_commit(
717
+ lambda: broadcast_board_event(
718
+ project_id,
719
+ "member_role_changed",
720
+ {"membership_id": membership_id, "user_id": user_id, "role": role_val},
721
+ )
722
+ )
723
+
724
+ return Response(ProjectMembershipReadSerializer(instance).data)
725
+
726
+ @extend_schema(
727
+ responses={
728
+ 204: None,
729
+ 400: state_refusal_400(
730
+ "Refused on the roster's own state: removing this member would leave "
731
+ "the project with no Owner. Verified against the last-Owner guard in "
732
+ "``destroy`` (#3319)."
733
+ ),
734
+ 403: ownership_refusal_403(
735
+ "The caller is not a member, is below Owner and removing someone else, "
736
+ "or holds a role at or below the target's (#3365)."
737
+ ),
738
+ }
739
+ )
740
+ def destroy(self, request: Request, pk: object = None, **kwargs: object) -> Response:
741
+ project = self._get_project_or_404()
742
+ instance = self.get_object()
743
+
744
+ is_self = instance.user == request.user
745
+
746
+ if is_self:
747
+ # Any member may remove themselves; require at least Viewer membership.
748
+ actor_role = _membership_role(request, project.pk)
749
+ if actor_role is None:
750
+ raise PermissionDenied(_NOT_PROJECT_MEMBER_DETAIL)
751
+ else:
752
+ # Removing another member is an ordinary roster write, so it obeys the
753
+ # archived read-only contract (#3414). Checked here rather than by keeping
754
+ # `IsProjectNotArchived` on the chain because `has_permission` runs before
755
+ # `is_self` is known, and refusing there would also close self-removal —
756
+ # which must survive archiving the way share-link and token revocation do.
757
+ assert_project_not_archived(project.pk)
758
+ # Removing another member requires Owner.
759
+ actor_role = self._require_actor_role(request, project.pk, Role.OWNER)
760
+ # Owner may only remove members with a lower role than themselves.
761
+ if instance.role >= actor_role:
762
+ # 403, not 400: a peer's role is a fact about the *caller's*
763
+ # authority over the target, not about the request (#3365).
764
+ raise PermissionDenied(
765
+ "You can only remove members with a role lower than your own."
766
+ )
767
+
768
+ # Last-Owner guard — atomic with select_for_update, pk-ordered like every
769
+ # other multi-row lock on this table (see the module-level "Lock-
770
+ # acquisition order" comment). `destroy` never locks the actor's own row —
771
+ # unlike `create`/`partial_update` it does not assign a role, so there is
772
+ # no ceiling to protect from a concurrent demotion; the actor's removal
773
+ # *authority* (`actor_role` above) is read unlocked, same as before #3438,
774
+ # which is scoped to the role-ceiling TOCTOU on grants, not this one.
775
+ if instance.role == Role.OWNER:
776
+ with transaction.atomic():
777
+ self._check_last_owner_guard(project.pk, exclude_pk=instance.pk)
778
+ instance.soft_delete()
779
+ else:
780
+ instance.soft_delete()
781
+
782
+ # Audit the revocation (#3645) — written synchronously after the
783
+ # soft_delete() above succeeds, same rationale as create/partial_update.
784
+ # No notification: the issue scopes notify to "added or role-changed",
785
+ # not removed.
786
+ from trueppm_api.apps.workspace.models import AuditEventType
787
+ from trueppm_api.apps.workspace.services import _actor_label, record_audit_event
788
+
789
+ record_audit_event(
790
+ event_type=AuditEventType.MEMBER_REMOVED,
791
+ actor=request.user,
792
+ target_type="member",
793
+ target_id=instance.pk,
794
+ target_label=_actor_label(instance.user),
795
+ metadata={
796
+ "project_id": str(project.pk),
797
+ "role": Role(instance.role).label,
798
+ "self_removal": is_self,
799
+ },
800
+ )
801
+
802
+ project_id = str(project.pk)
803
+ membership_id = str(instance.pk)
804
+ user_id = str(instance.user_id)
805
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
806
+
807
+ transaction.on_commit(
808
+ lambda: broadcast_board_event(
809
+ project_id, "member_removed", {"membership_id": membership_id, "user_id": user_id}
810
+ )
811
+ )
812
+
813
+ return Response(status=status.HTTP_204_NO_CONTENT)
814
+
815
+
816
+ class UserDefinedMentionGroupViewSet(
817
+ IdempotencyMixin, viewsets.GenericViewSet[UserDefinedMentionGroup]
818
+ ):
819
+ """Nested CRUD for user-defined @mention groups (ADR-0212, #515).
820
+
821
+ URL: ``/api/v1/projects/{project_pk}/mention-groups/``
822
+ ``/api/v1/projects/{project_pk}/mention-groups/{pk}/``
823
+ ``…/{pk}/add-member/`` ``…/{pk}/remove-member/``
824
+ ``…/{pk}/mute/`` ``…/{pk}/unmute/``
825
+
826
+ Permission matrix (ADR-0212 §3):
827
+ list / retrieve — any project member (Viewer+)
828
+ create / update / destroy — Project Admin+ (group lifecycle is a PM act)
829
+ add-member / remove-member — Project Scheduler+ (roster curation)
830
+ mute / unmute — any member (their own subscription only)
831
+ """
832
+
833
+ permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
834
+
835
+ def get_serializer_class(self) -> type[BaseSerializer[UserDefinedMentionGroup]]:
836
+ if self.action in ("create", "partial_update", "update"):
837
+ return UserDefinedMentionGroupWriteSerializer
838
+ return UserDefinedMentionGroupReadSerializer
839
+
840
+ def get_queryset(self) -> QuerySet[UserDefinedMentionGroup]:
841
+ project_pk = self.kwargs["project_pk"]
842
+ return (
843
+ UserDefinedMentionGroup.objects.filter(project_id=project_pk, is_deleted=False)
844
+ .prefetch_related("members", "muted_by")
845
+ .order_by("name")
846
+ )
847
+
848
+ def get_serializer_context(self) -> dict[str, Any]:
849
+ ctx = dict(super().get_serializer_context())
850
+ ctx["project_id"] = self.kwargs["project_pk"]
851
+ return ctx
852
+
853
+ def _get_project_or_404(self) -> Project:
854
+ try:
855
+ return Project.objects.get(pk=self.kwargs["project_pk"], is_deleted=False)
856
+ except Project.DoesNotExist as err:
857
+ from rest_framework.exceptions import NotFound
858
+
859
+ raise NotFound("Project not found.") from err
860
+
861
+ def _require_actor_role(self, request: Request, project_id: _PK, minimum: int) -> int:
862
+ role = _membership_role(request, project_id)
863
+ if role is None or role < minimum:
864
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
865
+ return role
866
+
867
+ def _broadcast(self, project_id: str, group_id: str, change: str) -> None:
868
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
869
+
870
+ # Deferred to on_commit (ADR-0083) so a rolled-back write never notifies
871
+ # open Members tabs; the group is also a VersionedModel, so clients that
872
+ # miss the transient event reconcile via the sync delta.
873
+ transaction.on_commit(
874
+ lambda: broadcast_board_event(
875
+ project_id,
876
+ "mention_group_changed",
877
+ {"group_id": group_id, "change": change},
878
+ )
879
+ )
880
+
881
+ def _read_response(self, instance: UserDefinedMentionGroup, *, code: int = 200) -> Response:
882
+ # Re-fetch through the prefetching queryset so member/mute counts on the
883
+ # response reflect the write without an N+1.
884
+ fresh = self.get_queryset().get(pk=instance.pk)
885
+ return Response(
886
+ UserDefinedMentionGroupReadSerializer(
887
+ fresh, context=self.get_serializer_context()
888
+ ).data,
889
+ status=code,
890
+ )
891
+
892
+ # -- lifecycle (Admin+) --------------------------------------------------
893
+
894
+ @extend_schema(responses={200: UserDefinedMentionGroupReadSerializer(many=True)})
895
+ # This handler returns a bare array, but UserDefinedMentionGroupViewSet inherits
896
+ # the default pagination class, so the auto-schema would otherwise declare a
897
+ # Paginated...List envelope the real body violates (#3649). Opt out so the
898
+ # schema matches the response.
899
+ @suppress_list_pagination
900
+ def list(self, request: Request, **kwargs: object) -> Response:
901
+ self._get_project_or_404()
902
+ serializer = UserDefinedMentionGroupReadSerializer(
903
+ self.get_queryset(), many=True, context=self.get_serializer_context()
904
+ )
905
+ return Response(serializer.data)
906
+
907
+ def retrieve(self, request: Request, pk: object = None, **kwargs: object) -> Response:
908
+ self._get_project_or_404()
909
+ return self._read_response(self.get_object())
910
+
911
+ def create(self, request: Request, **kwargs: object) -> Response:
912
+ project = self._get_project_or_404()
913
+ self._require_actor_role(request, project.pk, Role.ADMIN)
914
+ serializer = UserDefinedMentionGroupWriteSerializer(
915
+ data=request.data, context=self.get_serializer_context()
916
+ )
917
+ serializer.is_valid(raise_exception=True)
918
+ instance = serializer.save(project=project, created_by=request.user)
919
+ self._broadcast(str(project.pk), str(instance.pk), "created")
920
+ return self._read_response(instance, code=status.HTTP_201_CREATED)
921
+
922
+ def partial_update(self, request: Request, pk: object = None, **kwargs: object) -> Response:
923
+ project = self._get_project_or_404()
924
+ self._require_actor_role(request, project.pk, Role.ADMIN)
925
+ instance = self.get_object()
926
+ serializer = UserDefinedMentionGroupWriteSerializer(
927
+ instance, data=request.data, partial=True, context=self.get_serializer_context()
928
+ )
929
+ serializer.is_valid(raise_exception=True)
930
+ instance = serializer.save()
931
+ self._broadcast(str(project.pk), str(instance.pk), "updated")
932
+ return self._read_response(instance)
933
+
934
+ def destroy(self, request: Request, pk: object = None, **kwargs: object) -> Response:
935
+ project = self._get_project_or_404()
936
+ # Defense in depth. `IsProjectNotArchived` used to bypass the check for ANY
937
+ # action named "destroy" — the exemption was matched by action name, for
938
+ # ProjectViewSet's own delete — and this nested viewset names its delete
939
+ # "destroy" too, so the invariant had to be re-asserted here or it was lost.
940
+ # #3414 scoped the bypass to ProjectViewSet, so the permission class now covers
941
+ # this route on its own; the check below is retained per ADR-0184's additive
942
+ # doctrine rather than deleted as redundant.
943
+ if project.is_archived:
944
+ raise PermissionDenied(
945
+ "This project is archived and cannot be modified. Unarchive it first."
946
+ )
947
+ self._require_actor_role(request, project.pk, Role.ADMIN)
948
+ instance = self.get_object()
949
+ instance.soft_delete()
950
+ self._broadcast(str(project.pk), str(instance.pk), "deleted")
951
+ return Response(status=status.HTTP_204_NO_CONTENT)
952
+
953
+ # -- membership (Scheduler+) --------------------------------------------
954
+
955
+ def _member_user_or_400(self, project_id: _PK) -> Any:
956
+ """Resolve request.data['user'] to a User that is an active project member.
957
+
958
+ Unknown id, inactive account, and a real-but-not-on-this-project id all
959
+ raise the identical refusal (#4578) — see the module-level constant's
960
+ comment for why they must not be distinguished.
961
+ """
962
+ user_id = object_body(self.request).get("user")
963
+ if not user_id:
964
+ raise drf_serializers.ValidationError({"user": "This field is required."})
965
+ User = get_user_model()
966
+ try:
967
+ user = User.objects.get(pk=user_id, is_active=True)
968
+ except (User.DoesNotExist, ValueError, TypeError) as err:
969
+ raise drf_serializers.ValidationError(
970
+ {"user": _MEMBER_USER_NOT_REACHABLE_PROJECT_DETAIL}
971
+ ) from err
972
+ # A mention group may only contain current project members — a group
973
+ # member who is not on the project would be filtered out at resolution
974
+ # anyway, so reject the add up front.
975
+ if not ProjectMembership.objects.filter(
976
+ project_id=project_id, user=user, is_deleted=False
977
+ ).exists():
978
+ raise drf_serializers.ValidationError(
979
+ {"user": _MEMBER_USER_NOT_REACHABLE_PROJECT_DETAIL}
980
+ )
981
+ return user
982
+
983
+ def _mutate_membership(self, request: Request, *, add: bool) -> Response:
984
+ project = self._get_project_or_404()
985
+ self._require_actor_role(request, project.pk, Role.SCHEDULER)
986
+ instance = self.get_object()
987
+ user = self._member_user_or_400(project.pk)
988
+ if add:
989
+ instance.members.add(user)
990
+ else:
991
+ instance.members.remove(user)
992
+ # Bump server_version so the membership change flows through the sync
993
+ # delta (the M2M write alone does not touch the parent row).
994
+ instance.save(update_fields=["server_version"])
995
+ self._broadcast(
996
+ str(project.pk), str(instance.pk), "member_added" if add else "member_removed"
997
+ )
998
+ return self._read_response(instance)
999
+
1000
+ # Explicit request= / responses= on all four roster actions (#2840). Without
1001
+ # them drf-spectacular falls back to get_serializer_class(), which returns the
1002
+ # *read* serializer for a custom action — a read serializer has no writable
1003
+ # fields, so the published operation carried NO requestBody at all even though
1004
+ # ``user`` is required. A generated SDK method took no arguments, leaving the
1005
+ # caller no path that could ever send the body the endpoint 400s without.
1006
+ @extend_schema(
1007
+ summary="Add a project member to this @mention group",
1008
+ request=inline_serializer(
1009
+ name="MentionGroupAddMemberRequest",
1010
+ fields={"user": drf_serializers.IntegerField()},
1011
+ ),
1012
+ responses={200: UserDefinedMentionGroupReadSerializer},
1013
+ )
1014
+ @action(detail=True, methods=["post"], url_path="add-member")
1015
+ def add_member(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1016
+ return self._mutate_membership(request, add=True)
1017
+
1018
+ @extend_schema(
1019
+ summary="Remove a member from this @mention group",
1020
+ request=inline_serializer(
1021
+ name="MentionGroupRemoveMemberRequest",
1022
+ fields={"user": drf_serializers.IntegerField()},
1023
+ ),
1024
+ responses={200: UserDefinedMentionGroupReadSerializer},
1025
+ )
1026
+ @action(detail=True, methods=["post"], url_path="remove-member")
1027
+ def remove_member(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1028
+ return self._mutate_membership(request, add=False)
1029
+
1030
+ # -- mute / unmute (any member, self only) ------------------------------
1031
+
1032
+ def _mutate_mute(self, request: Request, *, mute: bool) -> Response:
1033
+ project = self._get_project_or_404()
1034
+ # Any project member may mute/unmute a group for THEMSELVES only.
1035
+ self._require_actor_role(request, project.pk, Role.VIEWER)
1036
+ instance = self.get_object()
1037
+ if mute:
1038
+ instance.muted_by.add(request.user) # type: ignore[arg-type]
1039
+ else:
1040
+ instance.muted_by.remove(request.user) # type: ignore[arg-type]
1041
+ return self._read_response(instance)
1042
+
1043
+ # ``request=None`` is asserted, not inferred: mute/unmute act on the caller's
1044
+ # own subscription and read nothing from the body. The pre-#2840 schema
1045
+ # happened to publish no requestBody here (the read-serializer fallback has no
1046
+ # writable fields), but that was an accident of the fallback, not a statement —
1047
+ # adding one writable field to the read serializer would have silently
1048
+ # published a body these endpoints ignore.
1049
+ @extend_schema(
1050
+ summary="Mute this @mention group for yourself",
1051
+ request=None,
1052
+ responses={200: UserDefinedMentionGroupReadSerializer},
1053
+ )
1054
+ @action(detail=True, methods=["post"], url_path="mute")
1055
+ def mute(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1056
+ return self._mutate_mute(request, mute=True)
1057
+
1058
+ @extend_schema(
1059
+ summary="Unmute this @mention group for yourself",
1060
+ request=None,
1061
+ responses={200: UserDefinedMentionGroupReadSerializer},
1062
+ )
1063
+ @action(detail=True, methods=["post"], url_path="unmute")
1064
+ def unmute(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1065
+ return self._mutate_mute(request, mute=False)
1066
+
1067
+
1068
+ class ProgramUserDefinedMentionGroupViewSet(
1069
+ IdempotencyMixin, viewsets.GenericViewSet[ProgramUserDefinedMentionGroup]
1070
+ ):
1071
+ """Nested CRUD for program-scoped user-defined @mention groups (ADR-0248, #516).
1072
+
1073
+ URL: ``/api/v1/programs/{program_pk}/mention-groups/``
1074
+ ``/api/v1/programs/{program_pk}/mention-groups/{pk}/``
1075
+ ``…/{pk}/add-member/`` ``…/{pk}/remove-member/``
1076
+ ``…/{pk}/mute/`` ``…/{pk}/unmute/``
1077
+
1078
+ Permission matrix (ADR-0248 §3) — tiers in program vocabulary, ordinals in
1079
+ parentheses because "Program Admin" is ``Role.OWNER`` (400) here, not
1080
+ ``Role.ADMIN`` (#3503):
1081
+ list / retrieve — any program member (Viewer+, 1)
1082
+ create / update / destroy — Program Admin (400) (group lifecycle is an
1083
+ owner act)
1084
+ add-member / remove-member — Program Manager+ (300) (roster curation)
1085
+ mute / unmute — any member (their own subscription only)
1086
+ """
1087
+
1088
+ permission_classes = [IsAuthenticated, IsProgramMember, IsProgramNotClosed]
1089
+
1090
+ def get_permissions(self) -> list[BasePermission]:
1091
+ """Surface the Owner-only lifecycle gate at the permission layer (#1351).
1092
+
1093
+ The action bodies already enforce ``Role.OWNER`` via ``_require_actor_role``,
1094
+ but that in-body check is invisible to DRF-level audits and OpenAPI security
1095
+ generation. Adding ``IsProgramOwner`` for the lifecycle actions is
1096
+ defense-in-depth over the authoritative in-body checks — mirroring the
1097
+ sibling membership viewsets. Membership (add/remove) and mute are Admin+ /
1098
+ any-member, so they keep the base classes only.
1099
+ """
1100
+ perms: list[BasePermission] = [
1101
+ IsAuthenticated(),
1102
+ IsProgramMember(),
1103
+ IsProgramNotClosed(),
1104
+ ]
1105
+ if self.action in ("create", "partial_update", "destroy"):
1106
+ perms.append(IsProgramOwner())
1107
+ return perms
1108
+
1109
+ def get_serializer_class(
1110
+ self,
1111
+ ) -> type[BaseSerializer[ProgramUserDefinedMentionGroup]]:
1112
+ if self.action in ("create", "partial_update", "update"):
1113
+ return ProgramUserDefinedMentionGroupWriteSerializer
1114
+ return ProgramUserDefinedMentionGroupReadSerializer
1115
+
1116
+ def get_queryset(self) -> QuerySet[ProgramUserDefinedMentionGroup]:
1117
+ program_pk = self.kwargs["program_pk"]
1118
+ return (
1119
+ ProgramUserDefinedMentionGroup.objects.filter(program_id=program_pk, is_deleted=False)
1120
+ .prefetch_related("members", "muted_by")
1121
+ .order_by("name")
1122
+ )
1123
+
1124
+ def get_serializer_context(self) -> dict[str, Any]:
1125
+ ctx = dict(super().get_serializer_context())
1126
+ ctx["program_id"] = self.kwargs["program_pk"]
1127
+ return ctx
1128
+
1129
+ def _get_program_or_404(self) -> Program:
1130
+ try:
1131
+ return Program.objects.get(pk=self.kwargs["program_pk"], is_deleted=False)
1132
+ except Program.DoesNotExist as err:
1133
+ from rest_framework.exceptions import NotFound
1134
+
1135
+ raise NotFound(_PROGRAM_NOT_FOUND_DETAIL) from err
1136
+
1137
+ def _require_actor_role(self, request: Request, program_id: _PK, minimum: int) -> int:
1138
+ role = _program_membership_role(request, program_id)
1139
+ if role is None or role < minimum:
1140
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
1141
+ return role
1142
+
1143
+ def _broadcast(self, program_id: str, group_id: str, change: str) -> None:
1144
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1145
+
1146
+ # A program isn't board-scoped, so fan the refresh out to every live
1147
+ # project in the program — any open Members tab in the program reconciles.
1148
+ # Deferred to on_commit (ADR-0083) so a rolled-back write never notifies;
1149
+ # the group is also a VersionedModel, so a missed transient event is
1150
+ # reconciled via the sync delta.
1151
+ project_ids = list(
1152
+ Project.objects.filter(program_id=program_id, is_deleted=False).values_list(
1153
+ "id", flat=True
1154
+ )
1155
+ )
1156
+
1157
+ def _emit() -> None:
1158
+ for project_id in project_ids:
1159
+ broadcast_board_event(
1160
+ str(project_id),
1161
+ "mention_group_changed",
1162
+ {
1163
+ "group_id": group_id,
1164
+ "change": change,
1165
+ "scope": "program",
1166
+ # The web consumer invalidates the program-scoped query key
1167
+ # (['program-mention-groups', program_id]) off this field —
1168
+ # the event rides project channels but targets the program cache.
1169
+ "program_id": program_id,
1170
+ },
1171
+ )
1172
+
1173
+ transaction.on_commit(_emit)
1174
+
1175
+ def _read_response(
1176
+ self, instance: ProgramUserDefinedMentionGroup, *, code: int = 200
1177
+ ) -> Response:
1178
+ # Re-fetch through the prefetching queryset so member/mute counts on the
1179
+ # response reflect the write without an N+1.
1180
+ fresh = self.get_queryset().get(pk=instance.pk)
1181
+ return Response(
1182
+ ProgramUserDefinedMentionGroupReadSerializer(
1183
+ fresh, context=self.get_serializer_context()
1184
+ ).data,
1185
+ status=code,
1186
+ )
1187
+
1188
+ # -- lifecycle (Owner) ---------------------------------------------------
1189
+
1190
+ @extend_schema(responses={200: ProgramUserDefinedMentionGroupReadSerializer(many=True)})
1191
+ # This handler returns a bare array, but ProgramUserDefinedMentionGroupViewSet
1192
+ # inherits the default pagination class, so the auto-schema would otherwise
1193
+ # declare a Paginated...List envelope the real body violates (#3649). Opt out
1194
+ # so the schema matches the response.
1195
+ @suppress_list_pagination
1196
+ def list(self, request: Request, **kwargs: object) -> Response:
1197
+ self._get_program_or_404()
1198
+ serializer = ProgramUserDefinedMentionGroupReadSerializer(
1199
+ self.get_queryset(), many=True, context=self.get_serializer_context()
1200
+ )
1201
+ return Response(serializer.data)
1202
+
1203
+ def retrieve(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1204
+ self._get_program_or_404()
1205
+ return self._read_response(self.get_object())
1206
+
1207
+ def create(self, request: Request, **kwargs: object) -> Response:
1208
+ program = self._get_program_or_404()
1209
+ self._require_actor_role(request, program.pk, Role.OWNER)
1210
+ serializer = ProgramUserDefinedMentionGroupWriteSerializer(
1211
+ data=request.data, context=self.get_serializer_context()
1212
+ )
1213
+ serializer.is_valid(raise_exception=True)
1214
+ instance = serializer.save(program=program, created_by=request.user)
1215
+ self._broadcast(str(program.pk), str(instance.pk), "created")
1216
+ return self._read_response(instance, code=status.HTTP_201_CREATED)
1217
+
1218
+ def partial_update(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1219
+ program = self._get_program_or_404()
1220
+ self._require_actor_role(request, program.pk, Role.OWNER)
1221
+ instance = self.get_object()
1222
+ serializer = ProgramUserDefinedMentionGroupWriteSerializer(
1223
+ instance, data=request.data, partial=True, context=self.get_serializer_context()
1224
+ )
1225
+ serializer.is_valid(raise_exception=True)
1226
+ instance = serializer.save()
1227
+ self._broadcast(str(program.pk), str(instance.pk), "updated")
1228
+ return self._read_response(instance)
1229
+
1230
+ def destroy(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1231
+ program = self._get_program_or_404()
1232
+ self._require_actor_role(request, program.pk, Role.OWNER)
1233
+ instance = self.get_object()
1234
+ instance.soft_delete()
1235
+ self._broadcast(str(program.pk), str(instance.pk), "deleted")
1236
+ return Response(status=status.HTTP_204_NO_CONTENT)
1237
+
1238
+ # -- membership (Admin+) -------------------------------------------------
1239
+
1240
+ def _member_user_or_400(self, program_id: _PK) -> Any:
1241
+ """Resolve request.data['user'] to a User who is a member of the program.
1242
+
1243
+ A program group may only contain users who hold a live ``ProjectMembership``
1244
+ on *some* project in the program (the ADR-0248 §2 union) — a user with no
1245
+ membership anywhere in the program would be filtered out at resolution
1246
+ anyway, so reject the add up front.
1247
+
1248
+ Unknown id, inactive account, and a real-but-unreachable id (a user who
1249
+ exists but is on no project in this program) all raise the identical
1250
+ refusal (#4578) — see the module-level constant's comment for why they
1251
+ must not be distinguished. Any Admin+ caller of ``add-member`` could
1252
+ otherwise use a split message as a user-id existence oracle.
1253
+ """
1254
+ user_id = object_body(self.request).get("user")
1255
+ if not user_id:
1256
+ raise drf_serializers.ValidationError({"user": "This field is required."})
1257
+ User = get_user_model()
1258
+ try:
1259
+ user = User.objects.get(pk=user_id, is_active=True)
1260
+ except (User.DoesNotExist, ValueError, TypeError) as err:
1261
+ raise drf_serializers.ValidationError(
1262
+ {"user": _MEMBER_USER_NOT_REACHABLE_PROGRAM_DETAIL}
1263
+ ) from err
1264
+ if not ProjectMembership.objects.filter(
1265
+ project__program_id=program_id,
1266
+ project__is_deleted=False,
1267
+ user=user,
1268
+ is_deleted=False,
1269
+ ).exists():
1270
+ raise drf_serializers.ValidationError(
1271
+ {"user": _MEMBER_USER_NOT_REACHABLE_PROGRAM_DETAIL}
1272
+ )
1273
+ return user
1274
+
1275
+ def _mutate_membership(self, request: Request, *, add: bool) -> Response:
1276
+ program = self._get_program_or_404()
1277
+ self._require_actor_role(request, program.pk, Role.ADMIN)
1278
+ instance = self.get_object()
1279
+ user = self._member_user_or_400(program.pk)
1280
+ if add:
1281
+ instance.members.add(user)
1282
+ else:
1283
+ instance.members.remove(user)
1284
+ # Bump server_version so the membership change flows through the sync
1285
+ # delta (the M2M write alone does not touch the parent row).
1286
+ instance.save(update_fields=["server_version"])
1287
+ self._broadcast(
1288
+ str(program.pk), str(instance.pk), "member_added" if add else "member_removed"
1289
+ )
1290
+ return self._read_response(instance)
1291
+
1292
+ # Same #2840 fallback trap as the project-scoped mirror above: the read
1293
+ # serializer has no writable fields, so the published operation declared no
1294
+ # requestBody while ``user`` is required.
1295
+ @extend_schema(
1296
+ summary="Add a program member to this @mention group",
1297
+ request=inline_serializer(
1298
+ name="ProgramMentionGroupAddMemberRequest",
1299
+ fields={"user": drf_serializers.IntegerField()},
1300
+ ),
1301
+ responses={200: ProgramUserDefinedMentionGroupReadSerializer},
1302
+ )
1303
+ @action(detail=True, methods=["post"], url_path="add-member")
1304
+ def add_member(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1305
+ return self._mutate_membership(request, add=True)
1306
+
1307
+ @extend_schema(
1308
+ summary="Remove a member from this @mention group",
1309
+ request=inline_serializer(
1310
+ name="ProgramMentionGroupRemoveMemberRequest",
1311
+ fields={"user": drf_serializers.IntegerField()},
1312
+ ),
1313
+ responses={200: ProgramUserDefinedMentionGroupReadSerializer},
1314
+ )
1315
+ @action(detail=True, methods=["post"], url_path="remove-member")
1316
+ def remove_member(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1317
+ return self._mutate_membership(request, add=False)
1318
+
1319
+ # -- mute / unmute (any member, self only) ------------------------------
1320
+
1321
+ def _mutate_mute(self, request: Request, *, mute: bool) -> Response:
1322
+ program = self._get_program_or_404()
1323
+ # Any program member may mute/unmute a group for THEMSELVES only.
1324
+ self._require_actor_role(request, program.pk, Role.VIEWER)
1325
+ instance = self.get_object()
1326
+ if mute:
1327
+ instance.muted_by.add(request.user) # type: ignore[arg-type]
1328
+ else:
1329
+ instance.muted_by.remove(request.user) # type: ignore[arg-type]
1330
+ return self._read_response(instance)
1331
+
1332
+ @extend_schema(
1333
+ summary="Mute this @mention group for yourself",
1334
+ request=None,
1335
+ responses={200: ProgramUserDefinedMentionGroupReadSerializer},
1336
+ )
1337
+ @action(detail=True, methods=["post"], url_path="mute")
1338
+ def mute(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1339
+ return self._mutate_mute(request, mute=True)
1340
+
1341
+ @extend_schema(
1342
+ summary="Unmute this @mention group for yourself",
1343
+ request=None,
1344
+ responses={200: ProgramUserDefinedMentionGroupReadSerializer},
1345
+ )
1346
+ @action(detail=True, methods=["post"], url_path="unmute")
1347
+ def unmute(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1348
+ return self._mutate_mute(request, mute=False)
1349
+
1350
+
1351
+ class ExternalStakeholderViewSet(IdempotencyMixin, viewsets.ModelViewSet[ExternalStakeholder]):
1352
+ """Program-scoped CRUD for the external stakeholder registry (#1658, ADR-0264).
1353
+
1354
+ URL: ``/api/v1/programs/{program_pk}/external-stakeholders/``
1355
+ ``/api/v1/programs/{program_pk}/external-stakeholders/{pk}/``
1356
+
1357
+ A registry of non-account people (client sponsors, vendor contacts, external
1358
+ reviewers) who are included in the ``@program-stakeholders`` mention fan-out
1359
+ alongside the program's Viewer-role members. Delivery of email to these
1360
+ addresses is **deferred to #1675** — this surface manages the registry only.
1361
+
1362
+ Permission matrix: program **Admin+** (Owner/Admin) for every action, list
1363
+ included — managing who is externally pinged is an administrative act, so a
1364
+ Scheduler/Member/Viewer/non-member is denied. ``IsProgramAdmin`` is the
1365
+ existing program-management gate (Role.ADMIN threshold); reused verbatim rather
1366
+ than reinvented. ``IsProgramNotClosed`` blocks writes to a closed program while
1367
+ still allowing reads (GET passes it).
1368
+ """
1369
+
1370
+ permission_classes = [IsAuthenticated, IsProgramAdmin, IsProgramNotClosed]
1371
+ serializer_class = ExternalStakeholderSerializer
1372
+ lookup_field = "pk"
1373
+ # A program's stakeholder list is small and read whole by the settings UI; a
1374
+ # plain array (no pagination envelope) matches the sibling program-group hook.
1375
+ pagination_class = None
1376
+
1377
+ def get_queryset(self) -> QuerySet[ExternalStakeholder]:
1378
+ # Scope to the URL's program AND live rows only — never trust a body-supplied
1379
+ # program id (IDOR-safe). The queryset is the sole authority on which program
1380
+ # a stakeholder belongs to, for both list and detail (update/destroy) routes.
1381
+ program_pk = self.kwargs["program_pk"]
1382
+ return (
1383
+ ExternalStakeholder.objects.filter(program_id=program_pk, is_deleted=False)
1384
+ .select_related("created_by")
1385
+ .order_by("name", "email")
1386
+ )
1387
+
1388
+ def get_serializer_context(self) -> dict[str, Any]:
1389
+ ctx = dict(super().get_serializer_context())
1390
+ # The serializer's per-program email-uniqueness check reads this.
1391
+ ctx["program_id"] = self.kwargs["program_pk"]
1392
+ return ctx
1393
+
1394
+ def _get_program_or_404(self) -> Program:
1395
+ try:
1396
+ return Program.objects.get(pk=self.kwargs["program_pk"], is_deleted=False)
1397
+ except Program.DoesNotExist as err:
1398
+ from rest_framework.exceptions import NotFound
1399
+
1400
+ raise NotFound(_PROGRAM_NOT_FOUND_DETAIL) from err
1401
+
1402
+ def perform_create(self, serializer: BaseSerializer[ExternalStakeholder]) -> None:
1403
+ program = self._get_program_or_404()
1404
+ # Stamp program from the URL and created_by from the caller — both are
1405
+ # server-controlled, never client-supplied.
1406
+ serializer.save(program=program, created_by=self.request.user)
1407
+
1408
+ def perform_destroy(self, instance: ExternalStakeholder) -> None:
1409
+ # Soft-delete: flip the flag so the email frees up for re-add and the
1410
+ # partial-unique constraint stops binding this row.
1411
+ instance.is_deleted = True
1412
+ instance.save(update_fields=["is_deleted", "updated_at"])
1413
+
1414
+
1415
+ class UserSearchView(APIView):
1416
+ """GET /api/v1/users/search/?q=<term> — workspace user typeahead for member invite.
1417
+
1418
+ Returns up to 10 active users matching username or email (case-insensitive).
1419
+ The email is matched but never returned (#815, ADR-0061 amended): the previous
1420
+ serializer echoed every matched user's email, so a single authenticated account
1421
+ could paginate the typeahead to harvest the whole workspace's email list. The
1422
+ substantive fix is dropping ``email`` from the payload + a per-user throttle.
1423
+ Defense in depth:
1424
+
1425
+ - ``IsWorkspaceMember`` — a deactivated membership (or, once explicit
1426
+ memberships/multi-workspace land, a non-member) is denied. In a single-
1427
+ workspace OSS deploy every active account is an implicit member, so this is
1428
+ the semantically-correct gate rather than an added restriction today; and
1429
+ - a per-user 60/min throttle (``user_search`` scope) bounds bulk scraping.
1430
+
1431
+ Returns an empty list when q is fewer than 2 characters.
1432
+ """
1433
+
1434
+ permission_classes = [IsWorkspaceMember]
1435
+ throttle_classes = [ScopedRateThrottle]
1436
+ throttle_scope = "user_search"
1437
+
1438
+ @extend_schema(responses={200: UserSearchResultSerializer(many=True)})
1439
+ def get(self, request: Request) -> Response:
1440
+ q = (request.query_params.get("q") or "").strip()
1441
+ if len(q) < 2:
1442
+ return Response([])
1443
+ User = get_user_model()
1444
+ qs = User.objects.filter(
1445
+ Q(username__icontains=q) | Q(email__icontains=q),
1446
+ is_active=True,
1447
+ ).order_by("username")[:10]
1448
+ return Response(UserSearchResultSerializer(qs, many=True).data)
1449
+
1450
+
1451
+ class MeView(McpReadableViewMixin, APIView):
1452
+ """GET /api/v1/auth/me/ — current user identity.
1453
+
1454
+ Returns display name and initials derived from auth.User fields.
1455
+ No project context — role is project-scoped and available separately.
1456
+ """
1457
+
1458
+ # ADR-0678 (#2482): identity echo — no project-scoped rows to withhold.
1459
+ mcp_scope = McpScope.NO_PROJECT_DATA
1460
+
1461
+ permission_classes = [IsAuthenticated]
1462
+
1463
+ @extend_schema(responses={200: MeSerializer})
1464
+ def get(self, request: Request) -> Response:
1465
+ # context={"request": request} so MeSerializer.get_token can read
1466
+ # request.auth — the caller's own token, absent for a session/JWT caller (#2938).
1467
+ return Response(MeSerializer(request.user, context={"request": request}).data)
1468
+
1469
+
1470
+ class ProgramMembershipViewSet(IdempotencyMixin, viewsets.GenericViewSet[ProgramMembership]):
1471
+ """Nested CRUD for program memberships (ADR-0070).
1472
+
1473
+ URL: ``/api/v1/programs/{program_pk}/members/``
1474
+ ``/api/v1/programs/{program_pk}/members/{pk}/``
1475
+
1476
+ Permission matrix mirrors :class:`ProjectMembershipViewSet`:
1477
+ list/retrieve — any program member (Viewer+)
1478
+ create — Owner only; cannot assign role >= caller's own
1479
+ partial_update — Owner only; cannot assign role >= caller's own; last-Owner guard
1480
+ destroy — Owner only for others; self-remove allowed; last-Owner guard
1481
+
1482
+ Why membership is Owner-only while program *configuration* is Admin+
1483
+ (:class:`IsProgramAdmin` on rename/methodology/calendar/health/visibility/
1484
+ sharing/rollup/risk): the asymmetry is intentional (#2017 product decision).
1485
+ Deciding *who* belongs to a program and at *what* role is a sovereignty
1486
+ (ownership) decision — it grants or revokes another person's access — which
1487
+ is categorically distinct from reconfiguring a program whose membership is
1488
+ already scoped. Admins may tune an already-scoped program; only Owners may
1489
+ change its roster. Do not "fix" this by lowering the gate to Admin+.
1490
+ """
1491
+
1492
+ permission_classes = [IsAuthenticated, IsProgramMember, IsProgramNotClosed]
1493
+
1494
+ def get_permissions(self) -> list[BasePermission]:
1495
+ """Express the create/update role floors at the permission layer (#1351).
1496
+
1497
+ ``create`` is Owner-only, so IsProgramOwner is added as defense-in-depth
1498
+ over the in-body ``_require_actor_role(OWNER)`` check.
1499
+
1500
+ ``partial_update`` carries **Admin**, not Owner: a ``role_title``-only PATCH
1501
+ (benign descriptive metadata, #565) is permitted at Admin+, and the body
1502
+ escalates to the Owner gate only when ``role`` changes. Gating the whole
1503
+ action on Owner here would regress that Admin metadata branch. Admin is the
1504
+ floor the body already enforces, and stating it here is what keeps a
1505
+ below-Admin caller's refusal a **403 about their authority** rather than a
1506
+ 400 from whichever field validator happened to run first — the convention
1507
+ ``destroy`` states at the bottom of this class (#3365). It matters since
1508
+ #3641: ``user`` is now refused by the serializer, which runs before the
1509
+ in-body role check.
1510
+
1511
+ ``destroy`` allows self-remove, so it is excluded from both the role floor
1512
+ and ``IsProgramNotClosed``: leaving a closed program must keep working, the way
1513
+ self-removal survives archiving on the project twin. ``IsProgramNotClosed``
1514
+ fires in ``has_permission``, before ``is_self`` is known, so the closed refusal
1515
+ for removing SOMEONE ELSE is re-asserted in the ``destroy`` body.
1516
+ """
1517
+ perms: list[BasePermission] = [IsAuthenticated(), IsProgramMember()]
1518
+ if self.action != "destroy":
1519
+ perms.append(IsProgramNotClosed())
1520
+ if self.action == "create":
1521
+ perms.append(IsProgramOwner())
1522
+ elif self.action in ("partial_update", "update"):
1523
+ perms.append(IsProgramAdmin())
1524
+ return perms
1525
+
1526
+ def get_throttles(self) -> list[BaseThrottle]:
1527
+ """Scope the directory-shaped-exposure throttle to ``create`` only (#3645).
1528
+
1529
+ See the project twin (``ProjectMembershipViewSet.get_throttles``) — one
1530
+ shared ``membership_grant`` budget across both membership surfaces.
1531
+ """
1532
+ if self.action == "create":
1533
+ return [MembershipGrantThrottle()]
1534
+ return super().get_throttles()
1535
+
1536
+ def get_queryset(self) -> QuerySet[ProgramMembership]:
1537
+ program_pk = self.kwargs["program_pk"]
1538
+ # ``live()`` rather than an inline floor (#3458). This module is allowlisted in
1539
+ # scripts/check-membership-live-floor.py — the revive path below must see revoked
1540
+ # rows — so the gate cannot see this read. Routing it through the shared helper is
1541
+ # the only protection the roster read gets.
1542
+ return (
1543
+ ProgramMembership.live().select_related("program", "user").filter(program_id=program_pk)
1544
+ )
1545
+
1546
+ def get_serializer_class(self) -> type[BaseSerializer[ProgramMembership]]:
1547
+ if self.action in ("partial_update", "update"):
1548
+ # An update body carries role/role_title only — ``user`` is immutable (#3641).
1549
+ return ProgramMembershipUpdateSerializer
1550
+ if self.action == "create":
1551
+ return ProgramMembershipWriteSerializer
1552
+ return ProgramMembershipReadSerializer
1553
+
1554
+ def _get_program_or_404(self) -> Program:
1555
+ try:
1556
+ return Program.objects.get(pk=self.kwargs["program_pk"], is_deleted=False)
1557
+ except Program.DoesNotExist as err:
1558
+ from rest_framework.exceptions import NotFound
1559
+
1560
+ raise NotFound(_PROGRAM_NOT_FOUND_DETAIL) from err
1561
+
1562
+ def _require_actor_role(self, request: Request, program_id: _PK, minimum: int) -> int:
1563
+ """Return the actor's program role, raising 403 if below minimum."""
1564
+ role = _program_membership_role(request, program_id)
1565
+ if role is None or role < minimum:
1566
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
1567
+ return role
1568
+
1569
+ def _check_last_owner_guard(self, program_id: _PK, exclude_pk: _PK | None = None) -> None:
1570
+ """Raise **400** if removing/demoting would leave the program with zero Owners."""
1571
+ qs = ProgramMembership.live().filter(program_id=program_id, role=Role.OWNER)
1572
+ if exclude_pk:
1573
+ qs = qs.exclude(pk=exclude_pk)
1574
+ # order_by("pk"): see the module-level "Lock-acquisition order" comment.
1575
+ if not qs.order_by("pk").select_for_update().exists():
1576
+ raise drf_serializers.ValidationError(
1577
+ {"detail": "Cannot remove or demote the last Owner of a program."}
1578
+ )
1579
+
1580
+ def _authorize_program_role_change(
1581
+ self,
1582
+ locked_rows: list[ProgramMembership],
1583
+ request: Request,
1584
+ required_role: Role,
1585
+ new_role: Role | None,
1586
+ instance: ProgramMembership,
1587
+ program: Program,
1588
+ ) -> None:
1589
+ """Raise unless the actor may make this change; run the last-owner guard if needed.
1590
+
1591
+ `locked_rows` must come from the SAME SELECT FOR UPDATE statement
1592
+ `partial_update` issues — see its lock-acquisition-order comment for why
1593
+ this cannot re-query.
1594
+ """
1595
+ actor_membership = next(
1596
+ (m for m in locked_rows if m.user_id == request.user.pk and not m.is_deleted),
1597
+ None,
1598
+ )
1599
+ if actor_membership is None:
1600
+ raise PermissionDenied(_NOT_PROGRAM_MEMBER_DETAIL) from None
1601
+
1602
+ actor_role = actor_membership.role
1603
+ if actor_role < required_role:
1604
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
1605
+
1606
+ if new_role is not None:
1607
+ if new_role >= actor_role:
1608
+ raise drf_serializers.ValidationError({"role": _ROLE_NOT_BELOW_OWN_ERROR})
1609
+ if instance.role == Role.OWNER and new_role < Role.OWNER:
1610
+ self._check_last_owner_guard(program.pk, exclude_pk=instance.pk)
1611
+
1612
+ # -----------------------------------------------------------------------
1613
+ # Actions
1614
+ # -----------------------------------------------------------------------
1615
+
1616
+ @extend_schema(responses={200: ProgramMembershipReadSerializer(many=True)})
1617
+ # This handler returns a bare array, but ProgramMembershipViewSet inherits the
1618
+ # default pagination class, so the auto-schema would otherwise declare a
1619
+ # Paginated...List envelope the real body violates (#3649). Opt out so the
1620
+ # schema matches the response.
1621
+ @suppress_list_pagination
1622
+ def list(self, request: Request, **kwargs: object) -> Response:
1623
+ program = self._get_program_or_404()
1624
+ # has_permission only checks authentication + membership-at-program-pk
1625
+ # for nested routes; the queryset filters by program_id, so an authenticated
1626
+ # non-member would see an empty list. Enforce explicitly to return 403.
1627
+ if _program_membership_role(request, program.pk) is None:
1628
+ raise PermissionDenied("You must be a member of this program.")
1629
+ qs = self.get_queryset()
1630
+ # ?self=true: only the caller's own membership row — used by the frontend
1631
+ # useCurrentProgramRole() hook for tab-level RBAC.
1632
+ if request.query_params.get("self") == "true":
1633
+ user_pk = request.user.pk
1634
+ assert user_pk is not None
1635
+ qs = qs.filter(user_id=user_pk)
1636
+ serializer = ProgramMembershipReadSerializer(qs, many=True)
1637
+ return Response(serializer.data)
1638
+
1639
+ def retrieve(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1640
+ self._get_program_or_404()
1641
+ instance = self.get_object()
1642
+ return Response(ProgramMembershipReadSerializer(instance).data)
1643
+
1644
+ @extend_schema(
1645
+ responses={
1646
+ 201: ProgramMembershipReadSerializer,
1647
+ 400: state_refusal_400(
1648
+ "Refused on the request: the role is at or above the caller's own, or "
1649
+ "``user`` names an account the caller cannot reach (#3641) — see the "
1650
+ "project twin."
1651
+ ),
1652
+ 409: OpenApiResponse(
1653
+ description=(
1654
+ "``user`` already holds a live membership on this program — see "
1655
+ "the project twin."
1656
+ )
1657
+ ),
1658
+ }
1659
+ )
1660
+ def create(self, request: Request, **kwargs: object) -> Response:
1661
+ program = self._get_program_or_404()
1662
+ self._require_actor_role(request, program.pk, Role.OWNER)
1663
+
1664
+ # See the project twin: ``user`` is caller-scoped and needs the request.
1665
+ serializer = ProgramMembershipWriteSerializer(
1666
+ data=request.data, context={"request": request}
1667
+ )
1668
+ serializer.is_valid(raise_exception=True)
1669
+
1670
+ new_role = serializer.validated_data["role"]
1671
+ user = serializer.validated_data["user"]
1672
+ # Mirrors ProjectMembershipViewSet.create exactly — ProgramMembership
1673
+ # carries the same unconditional (program, user) constraint and therefore
1674
+ # carried the same 500 on re-adding a revoked member (#3410). See
1675
+ # ``_revive_revoked_membership`` for why the row is reused rather than
1676
+ # re-inserted, and what an offline client sees.
1677
+ #
1678
+ # The actor's own membership row is locked in the SAME statement (#3438),
1679
+ # together with the target's existing row (if any), ordered by pk — see
1680
+ # the module-level "Lock-acquisition order" comment. Reading the actor's
1681
+ # role ceiling via the request-cached, unlocked `_program_membership_role()`
1682
+ # — as this endpoint did before — left a TOCTOU window where a concurrent
1683
+ # demotion of the actor between the initial permission check and this
1684
+ # write could let them grant at their pre-demotion ceiling.
1685
+ actor_pk = request.user.pk
1686
+ assert actor_pk is not None # IsAuthenticated ensures a real user
1687
+ with transaction.atomic():
1688
+ locked = {
1689
+ m.user_id: m
1690
+ for m in (
1691
+ ProgramMembership.objects.select_for_update(of=("self",))
1692
+ .select_related("user")
1693
+ .filter(program=program, user_id__in={actor_pk, user.pk})
1694
+ .order_by("pk")
1695
+ )
1696
+ }
1697
+ actor_membership = locked.get(actor_pk)
1698
+ if actor_membership is None or actor_membership.is_deleted:
1699
+ raise PermissionDenied(_NOT_PROGRAM_MEMBER_DETAIL)
1700
+ actor_role = actor_membership.role
1701
+ if actor_role < Role.OWNER:
1702
+ raise PermissionDenied(_PERMISSION_DENIED_DETAIL)
1703
+ # Caller may only assign roles strictly below their own — read from the
1704
+ # row just locked, not the unlocked cache read this replaced.
1705
+ if new_role >= actor_role:
1706
+ raise drf_serializers.ValidationError({"role": _ROLE_NOT_BELOW_OWN_ERROR})
1707
+
1708
+ existing = locked.get(user.pk)
1709
+ if existing is not None and not existing.is_deleted:
1710
+ return Response(
1711
+ {"detail": "User is already a member of this program."},
1712
+ status=status.HTTP_409_CONFLICT,
1713
+ )
1714
+ if existing is not None:
1715
+ # Stamped as a fresh add would leave it: an omitted role_title
1716
+ # means "unset", never "inherit whatever the revoked row carried".
1717
+ existing.role_title = serializer.validated_data.get("role_title", "")
1718
+ _revive_revoked_membership(existing, new_role=new_role)
1719
+ instance = existing
1720
+ else:
1721
+ try:
1722
+ # Savepoint — see the project-side twin for why the INSERT
1723
+ # answers 409 rather than 500 when two adds race.
1724
+ with transaction.atomic():
1725
+ instance = serializer.save(program=program)
1726
+ except IntegrityError:
1727
+ # Narrowed to the uniqueness race — see the project-side twin.
1728
+ if not ProgramMembership.objects.filter(program=program, user=user).exists():
1729
+ raise
1730
+ return Response(
1731
+ {"detail": "User is already a member of this program."},
1732
+ status=status.HTTP_409_CONFLICT,
1733
+ )
1734
+
1735
+ # Audit + notify only on the success path reached above (#3645) — see the
1736
+ # project twin's create() for the full rationale. A program has no owning
1737
+ # project, so the notification's ``project_id`` is deliberately None: the
1738
+ # inbox row is still recipient-scoped and readable without one (only the
1739
+ # ADR-0663 scheduled digests were expected to omit it before this).
1740
+ from trueppm_api.apps.notifications.models import NotificationEventType
1741
+ from trueppm_api.apps.notifications.services import create_event_notifications
1742
+ from trueppm_api.apps.workspace.models import AuditEventType
1743
+ from trueppm_api.apps.workspace.services import _actor_label, record_audit_event
1744
+
1745
+ record_audit_event(
1746
+ event_type=AuditEventType.MEMBER_ADDED,
1747
+ actor=request.user,
1748
+ target_type="member",
1749
+ target_id=instance.pk,
1750
+ target_label=_actor_label(user),
1751
+ metadata={
1752
+ "program_id": str(program.pk),
1753
+ "role": Role(new_role).label,
1754
+ "source": "grant",
1755
+ },
1756
+ )
1757
+ actor_label = _actor_label(request.user)
1758
+ role_label = Role(new_role).label
1759
+ program_name = program.name
1760
+ subject = f"You were added to {program_name}"
1761
+ body = f"{actor_label} added you to {program_name} as {role_label}."
1762
+ recipient_id = user.pk
1763
+ transaction.on_commit(
1764
+ lambda: create_event_notifications(
1765
+ event_type=NotificationEventType.MEMBERSHIP_GRANTED,
1766
+ recipient_ids=[recipient_id],
1767
+ subject=subject,
1768
+ body=body,
1769
+ project_id=None,
1770
+ )
1771
+ )
1772
+
1773
+ return Response(
1774
+ ProgramMembershipReadSerializer(instance).data, status=status.HTTP_201_CREATED
1775
+ )
1776
+
1777
+ def _audit_and_notify_role_change(
1778
+ self,
1779
+ request: Request,
1780
+ program: Program,
1781
+ instance: ProgramMembership,
1782
+ old_role: int,
1783
+ new_role: int,
1784
+ ) -> None:
1785
+ """Audit a real role change and queue the target's notification on commit."""
1786
+ from trueppm_api.apps.notifications.models import NotificationEventType
1787
+ from trueppm_api.apps.notifications.services import create_event_notifications
1788
+ from trueppm_api.apps.workspace.models import AuditEventType
1789
+ from trueppm_api.apps.workspace.services import _actor_label, record_audit_event
1790
+
1791
+ record_audit_event(
1792
+ event_type=AuditEventType.MEMBER_ROLE_CHANGED,
1793
+ actor=request.user,
1794
+ target_type="member",
1795
+ target_id=instance.pk,
1796
+ target_label=_actor_label(instance.user),
1797
+ metadata={
1798
+ "program_id": str(program.pk),
1799
+ "old_role": Role(old_role).label,
1800
+ "new_role": Role(new_role).label,
1801
+ },
1802
+ )
1803
+ actor_label = _actor_label(request.user)
1804
+ old_role_label = Role(old_role).label
1805
+ new_role_label = Role(new_role).label
1806
+ program_name = program.name
1807
+ target_user_id = instance.user_id
1808
+ subject = f"Your role changed on {program_name}"
1809
+ body = (
1810
+ f"{actor_label} changed your role on {program_name} from "
1811
+ f"{old_role_label} to {new_role_label}."
1812
+ )
1813
+ transaction.on_commit(
1814
+ lambda: create_event_notifications(
1815
+ event_type=NotificationEventType.MEMBERSHIP_ROLE_CHANGED,
1816
+ recipient_ids=[target_user_id],
1817
+ subject=subject,
1818
+ body=body,
1819
+ project_id=None,
1820
+ )
1821
+ )
1822
+
1823
+ @extend_schema(
1824
+ responses={
1825
+ 200: ProgramMembershipReadSerializer,
1826
+ 400: state_refusal_400(
1827
+ "Refused on the request: the role is at or above the caller's own, "
1828
+ "demoting this member would strand the program without an Owner, or the "
1829
+ "body carried ``user`` — a membership's account is fixed at creation "
1830
+ "(#3641)."
1831
+ ),
1832
+ }
1833
+ )
1834
+ def partial_update(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1835
+ program = self._get_program_or_404()
1836
+ instance = self.get_object()
1837
+ old_role = instance.role # snapshot before serializer.save() mutates it in place
1838
+
1839
+ serializer = ProgramMembershipUpdateSerializer(
1840
+ instance, data=request.data, partial=True, context={"request": request}
1841
+ )
1842
+ serializer.is_valid(raise_exception=True)
1843
+ new_role = serializer.validated_data.get("role")
1844
+
1845
+ # Changing the access role stays Owner-only (the ADR-0070 matrix). The
1846
+ # freeform role_title (#565) is benign descriptive metadata — not enforced
1847
+ # anywhere — so a role_title-only PATCH is allowed at Admin+. A request that
1848
+ # also touches role is privileged and falls back to the Owner gate.
1849
+ #
1850
+ # Reassigning the *member identity* is no longer a privileged change here
1851
+ # because it is no longer a change at all: the serializer refuses ``user`` on
1852
+ # update at any role (#3641). Swapping the account behind a live row was a
1853
+ # second route to the same address harvest, and it rewrote who held access
1854
+ # while keeping the row's ``joined_at`` access evidence.
1855
+ required_role = Role.OWNER if new_role is not None else Role.ADMIN
1856
+
1857
+ # Lock the actor's membership row inside an atomic block to close the
1858
+ # TOCTOU window where a concurrent demotion could let the actor assign
1859
+ # a role >= their effective role at save time. Locked together with
1860
+ # `instance` (the row being patched), in one statement ordered by
1861
+ # ascending pk — see the module-level "Lock-acquisition order" comment
1862
+ # for why "actor row, then instance" can deadlock and pk order cannot.
1863
+ #
1864
+ # See the project twin for why every current Owner's row is folded into
1865
+ # this SAME statement whenever a role change is requested at all
1866
+ # (security-review, #3438): `_check_last_owner_guard`'s own lock below
1867
+ # would otherwise be a second, separately-ordered statement, reopening
1868
+ # the deadlock this comment exists to close.
1869
+ with transaction.atomic():
1870
+ lock_filter = Q(pk=instance.pk) | Q(
1871
+ program=program, user=request.user, is_deleted=False
1872
+ )
1873
+ if new_role is not None:
1874
+ lock_filter |= Q(program=program, role=Role.OWNER, is_deleted=False)
1875
+ locked_rows = list(
1876
+ ProgramMembership.objects.select_for_update().filter(lock_filter).order_by("pk")
1877
+ )
1878
+ self._authorize_program_role_change(
1879
+ locked_rows, request, required_role, new_role, instance, program
1880
+ )
1881
+
1882
+ # Stamp role_changed_at only on an actual role change (#878) so a
1883
+ # no-op PATCH that re-sends the same role does not falsely advance
1884
+ # the per-program access-evidence timestamp.
1885
+ if new_role is not None and new_role != instance.role:
1886
+ serializer.save(role_changed_at=timezone.now())
1887
+ else:
1888
+ serializer.save()
1889
+
1890
+ # Audit + notify only when the role actually changed (#3645) — a
1891
+ # role_title-only PATCH (Admin+ tier) must not fabricate a role-change
1892
+ # event. See the project twin's partial_update() for the full rationale.
1893
+ if new_role is not None and new_role != old_role:
1894
+ self._audit_and_notify_role_change(request, program, instance, old_role, new_role)
1895
+
1896
+ return Response(ProgramMembershipReadSerializer(instance).data)
1897
+
1898
+ @extend_schema(
1899
+ responses={
1900
+ 204: None,
1901
+ 400: state_refusal_400(
1902
+ "Refused on the roster's own state: removing this member would leave "
1903
+ "the program with no Owner. Verified against the last-Owner guard in "
1904
+ "``destroy`` (#3319)."
1905
+ ),
1906
+ 403: ownership_refusal_403(
1907
+ "The caller is not a member, is below Owner and removing someone else, "
1908
+ "or holds a role at or below the target's (#3365)."
1909
+ ),
1910
+ }
1911
+ )
1912
+ def destroy(self, request: Request, pk: object = None, **kwargs: object) -> Response:
1913
+ program = self._get_program_or_404()
1914
+ instance = self.get_object()
1915
+
1916
+ is_self = instance.user == request.user
1917
+
1918
+ if is_self:
1919
+ actor_role = _program_membership_role(request, program.pk)
1920
+ if actor_role is None:
1921
+ raise PermissionDenied(_NOT_PROGRAM_MEMBER_DETAIL)
1922
+ else:
1923
+ # Removing another member is a roster write, so it obeys the closed
1924
+ # read-only contract (#530); only self-removal survives closing. Checked
1925
+ # here because `get_permissions` drops `IsProgramNotClosed` for `destroy`
1926
+ # to keep the self-leave path open (#4014).
1927
+ if program.is_closed:
1928
+ raise PermissionDenied(IsProgramNotClosed.message)
1929
+ actor_role = self._require_actor_role(request, program.pk, Role.OWNER)
1930
+ if instance.role >= actor_role:
1931
+ # 403, not 400: a peer's role is a fact about the *caller's*
1932
+ # authority over the target, not about the request (#3365).
1933
+ raise PermissionDenied(
1934
+ "You can only remove members with a role lower than your own."
1935
+ )
1936
+
1937
+ # See the project twin: `destroy` never locks the actor's own row (no role
1938
+ # is assigned here), so `_check_last_owner_guard`'s pk-ordered lock is the
1939
+ # only one this path takes.
1940
+ if instance.role == Role.OWNER:
1941
+ with transaction.atomic():
1942
+ self._check_last_owner_guard(program.pk, exclude_pk=instance.pk)
1943
+ instance.soft_delete()
1944
+ else:
1945
+ instance.soft_delete()
1946
+
1947
+ # Audit the revocation (#3645) — see the project twin's destroy() for the
1948
+ # full rationale. No notification: scoped to "added or role-changed".
1949
+ from trueppm_api.apps.workspace.models import AuditEventType
1950
+ from trueppm_api.apps.workspace.services import _actor_label, record_audit_event
1951
+
1952
+ record_audit_event(
1953
+ event_type=AuditEventType.MEMBER_REMOVED,
1954
+ actor=request.user,
1955
+ target_type="member",
1956
+ target_id=instance.pk,
1957
+ target_label=_actor_label(instance.user),
1958
+ metadata={
1959
+ "program_id": str(program.pk),
1960
+ "role": Role(instance.role).label,
1961
+ "self_removal": is_self,
1962
+ },
1963
+ )
1964
+
1965
+ return Response(status=status.HTTP_204_NO_CONTENT)