trueppm-api 0.4.0b2__py3-none-any.whl

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