trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1752 @@
1
+ """DRF ViewSets for the resources app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from decimal import Decimal
7
+ from typing import Any, cast
8
+
9
+ from django.db import models, transaction
10
+ from django.db.models import DateField, ProtectedError, QuerySet
11
+ from django.db.models.functions import Coalesce
12
+ from drf_spectacular.types import OpenApiTypes
13
+ from drf_spectacular.utils import (
14
+ OpenApiParameter,
15
+ OpenApiResponse,
16
+ extend_schema,
17
+ extend_schema_view,
18
+ )
19
+ from rest_framework import filters, status, viewsets
20
+ from rest_framework.decorators import action
21
+ from rest_framework.exceptions import PermissionDenied, ValidationError
22
+ from rest_framework.permissions import BasePermission, IsAuthenticated
23
+ from rest_framework.request import Request
24
+ from rest_framework.response import Response
25
+ from rest_framework.serializers import BaseSerializer
26
+ from rest_framework.throttling import UserRateThrottle
27
+
28
+ from trueppm_api.apps.access.models import ProjectMembership, Role
29
+ from trueppm_api.apps.access.permissions import (
30
+ CanAssignResource,
31
+ IsOrgAdmin,
32
+ IsOrgScheduler,
33
+ IsProjectMember,
34
+ IsProjectNotArchived,
35
+ ProjectScopedViewSet,
36
+ _membership_role,
37
+ assert_project_not_archived,
38
+ )
39
+ from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
40
+ from trueppm_api.apps.projects.models import Project, Task, TaskActivityEventType
41
+ from trueppm_api.apps.projects.utilization import (
42
+ Allocation,
43
+ peak_concurrent_units,
44
+ resolve_working_calendar,
45
+ )
46
+ from trueppm_api.apps.resources.capacity import resource_effective_units
47
+ from trueppm_api.apps.resources.models import (
48
+ Proficiency,
49
+ ProjectResource,
50
+ Resource,
51
+ ResourceSkill,
52
+ Skill,
53
+ TaskResource,
54
+ TaskSkillRequirement,
55
+ )
56
+ from trueppm_api.apps.resources.serializers import (
57
+ ProjectResourceSerializer,
58
+ ResourceAssignmentSerializer,
59
+ ResourceSerializer,
60
+ ResourceSkillSerializer,
61
+ SkillSerializer,
62
+ TaskResourceSerializer,
63
+ TaskSkillRequirementSerializer,
64
+ )
65
+ from trueppm_api.apps.resources.services import (
66
+ ensure_project_resource,
67
+ record_assignment_event,
68
+ task_is_summary,
69
+ )
70
+ from trueppm_api.apps.scheduling.services import enqueue_recalculate as _enqueue_recalculate
71
+ from trueppm_api.apps.workspace.permissions import IsWorkspaceAdminStrict, is_workspace_admin
72
+ from trueppm_api.core.protect_conflict import protected_error_response
73
+
74
+ # Shared 404 detail — matches DRF's default NotFound detail so a missing or
75
+ # soft-deleted lookup reads identically across the resource actions below.
76
+ _NOT_FOUND_DETAIL = "Not found."
77
+
78
+ #: Refusal shipped when a skill is still tagged on a resource or required by a task.
79
+ #: Both FKs into ``Skill`` are ``on_delete=PROTECT`` (``ResourceSkill.skill``,
80
+ #: ``TaskSkillRequirement.skill``) so deleting a catalog entry cannot silently strip
81
+ #: the skill from every resource that carries it or every task that requires it.
82
+ #: Django reports the refusal as ``ProtectedError``, which is not an ``APIException``
83
+ #: and escaped as an unhandled 500 until #2364.
84
+ _SKILL_IN_USE_DETAIL = "This skill is still in use and cannot be deleted."
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Skill catalog
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def _skill_reference_describer(user: Any) -> Callable[[models.Model], dict[str, str]]:
92
+ """Build a describer that names a skill's blockers without leaking task names.
93
+
94
+ ``ResourceSkill`` and ``TaskSkillRequirement`` are join tables the user never
95
+ sees and which have no ``name`` of their own; echoing them back would produce a
96
+ list of opaque UUIDs. What the caller can act on is the resource to retag or the
97
+ task whose requirement to drop.
98
+
99
+ **Task names are membership-scoped, and this endpoint's gate is not.**
100
+ ``IsOrgScheduler`` only proves the caller holds SCHEDULER+ on *some* project, so
101
+ a naive describer would let anyone with one project turn repeated delete attempts
102
+ into an oracle for task names across every project in the install — and a task
103
+ name describes actual work, unlike a resource name. Tasks in projects the caller
104
+ is not a member of are therefore reported by type and id only, with the name
105
+ withheld; ``reference_count`` still reflects the true total, so the refusal stays
106
+ honest about how much is blocking even when it cannot say what.
107
+
108
+ Resource names are *not* filtered: the resource catalog is org-shared and already
109
+ readable by any authenticated user (see :class:`ResourceViewSet`), so naming one
110
+ here discloses nothing the caller could not list directly.
111
+ """
112
+ # project__is_deleted (#4577): a trashed project confers no read, so its names
113
+ # are withheld from the refusal like any other project the caller cannot see.
114
+ visible_project_ids = set(
115
+ ProjectMembership.objects.filter(
116
+ user=user, is_deleted=False, project__is_deleted=False
117
+ ).values_list("project_id", flat=True)
118
+ )
119
+
120
+ def describe(obj: models.Model) -> dict[str, str]:
121
+ if isinstance(obj, ResourceSkill):
122
+ return {"type": "resource", "id": str(obj.resource_id), "name": obj.resource.name}
123
+ if isinstance(obj, TaskSkillRequirement):
124
+ entry = {"type": "task", "id": str(obj.task_id)}
125
+ if obj.task.project_id in visible_project_ids:
126
+ entry["name"] = obj.task.name
127
+ return entry
128
+ return {"type": obj._meta.model_name or "", "id": str(obj.pk), "name": str(obj)}
129
+
130
+ return describe
131
+
132
+
133
+ @extend_schema_view(
134
+ create=extend_schema(
135
+ summary="Create a skill, or return the existing one",
136
+ description=(
137
+ "Adds a skill to the org-level catalog.\n\n"
138
+ "Names are de-duplicated case-insensitively on `normalized_name`, so "
139
+ "posting a name that already exists is not an error: the existing row is "
140
+ "returned with `200` instead of `201`. Callers that need to know whether "
141
+ "they added the skill must read the status code — the body is identical "
142
+ "either way."
143
+ ),
144
+ responses={
145
+ 200: OpenApiResponse(
146
+ response=SkillSerializer,
147
+ description=(
148
+ "A skill with this normalized name already existed; it is returned unchanged."
149
+ ),
150
+ ),
151
+ 201: OpenApiResponse(
152
+ response=SkillSerializer,
153
+ description="Skill created.",
154
+ ),
155
+ },
156
+ ),
157
+ destroy=extend_schema(
158
+ summary="Delete a skill",
159
+ description=(
160
+ "Deletes a skill from the org-level catalog.\n\n"
161
+ "A skill that is still tagged on a resource or required by a task cannot "
162
+ "be deleted — removing it would silently strip the tag from every resource "
163
+ "that carries it and drop the requirement from every task that needs it. "
164
+ "That is refused with `409`, and the body names the resources and tasks "
165
+ "still using it so the caller can detach them first."
166
+ ),
167
+ responses={
168
+ 204: OpenApiResponse(description="Skill deleted."),
169
+ 409: OpenApiResponse(
170
+ response=OpenApiTypes.OBJECT,
171
+ description=(
172
+ "Skill is still tagged on a resource or required by a task. Body "
173
+ "carries `reference_count` and a `references` sample naming the "
174
+ "resources and tasks to detach first."
175
+ ),
176
+ ),
177
+ },
178
+ ),
179
+ )
180
+ class SkillViewSet(IdempotencyMixin, viewsets.ModelViewSet[Skill]):
181
+ """CRUD for the org-level skill catalog.
182
+
183
+ Any authenticated user may read. Writes require ``SCHEDULER+`` on at least
184
+ one project (#254 — IsProjectMember alone would not gate writes here
185
+ because the route is not project-nested). Skill creation normalises the
186
+ name and returns the existing row if the normalised name already exists
187
+ (de-dup by normalized_name unique constraint).
188
+ """
189
+
190
+ queryset = Skill.objects.filter(is_deleted=False).order_by("name")
191
+ serializer_class = SkillSerializer
192
+ filter_backends = [filters.SearchFilter, filters.OrderingFilter]
193
+ search_fields = ["name", "category"]
194
+ ordering_fields = ["name"]
195
+
196
+ def get_permissions(self) -> list[BasePermission]:
197
+ """Read open to any authenticated user; writes require SCHEDULER+ on any project."""
198
+ if self.request.method in ("GET", "HEAD", "OPTIONS"):
199
+ return [IsAuthenticated()]
200
+ return [IsAuthenticated(), IsOrgScheduler()]
201
+
202
+ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
203
+ """Delete the skill, or 409 when a resource or task still uses it.
204
+
205
+ Overrides ``destroy`` rather than ``perform_destroy`` because the refusal body
206
+ carries an integer ``reference_count``, and DRF coerces every value in an
207
+ ``APIException`` detail to ``ErrorDetail`` (a ``str`` subclass) — raising would
208
+ ship the count as ``"3"``. Returning is safe: ``ProtectedError`` is raised
209
+ while the collector walks the FKs, *before* any DELETE is issued, so there is
210
+ no partial state for ``ATOMIC_REQUESTS`` to roll back.
211
+ """
212
+ instance = self.get_object()
213
+ try:
214
+ instance.delete()
215
+ except ProtectedError as exc:
216
+ return protected_error_response(
217
+ exc.protected_objects,
218
+ detail=_SKILL_IN_USE_DETAIL,
219
+ code="skill_in_use",
220
+ describe=_skill_reference_describer(request.user),
221
+ )
222
+ return Response(status=status.HTTP_204_NO_CONTENT)
223
+
224
+ def create(self, request: Request, *args: object, **kwargs: object) -> Response:
225
+ """Create the skill (201), or return the existing row on a de-dup hit (200).
226
+
227
+ The status comes from the ``get_or_create`` flag the serializer records,
228
+ not from an inspection of the saved row. Nothing on a saved ``Skill``
229
+ distinguishes a fresh insert from a de-dup hit, so any re-derived flag is
230
+ a guess: this used to probe ``server_version=0``, which
231
+ ``VersionedModel.save`` never leaves behind (it stamps 1 on insert), so
232
+ the endpoint answered 200 for every create (#3573).
233
+ """
234
+ # ``get_serializer`` is typed as ``BaseSerializer[Skill]``; this viewset
235
+ # never swaps ``serializer_class``, so the concrete type is exact.
236
+ serializer = cast("SkillSerializer", self.get_serializer(data=request.data))
237
+ serializer.is_valid(raise_exception=True)
238
+ skill = serializer.save()
239
+ http_status = status.HTTP_201_CREATED if serializer.created else status.HTTP_200_OK
240
+ return Response(
241
+ self.get_serializer(skill).data,
242
+ status=http_status,
243
+ headers=self.get_success_headers(serializer.data),
244
+ )
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Resource skills
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ @extend_schema_view(
253
+ list=extend_schema(
254
+ parameters=[
255
+ OpenApiParameter(
256
+ name="resource",
257
+ type=OpenApiTypes.UUID,
258
+ location=OpenApiParameter.QUERY,
259
+ required=False,
260
+ description="Filter to skill tags for this resource.",
261
+ ),
262
+ ],
263
+ ),
264
+ )
265
+ class ResourceSkillViewSet(IdempotencyMixin, viewsets.ModelViewSet[ResourceSkill]):
266
+ """CRUD for skill tags on resources.
267
+
268
+ Read: any authenticated user (resource catalog is org-shared).
269
+ Write: SCHEDULER+ on at least one active project (``IsOrgScheduler``). The
270
+ route is not project-nested so ``IsProjectMember`` alone would not gate writes.
271
+ The docstring here claimed an ADMIN+ floor via ``IsOrgAdmin``; the code has
272
+ returned ``IsOrgScheduler`` since #254. Corrected to the code, not the reverse
273
+ — skill tagging is curation, which is what the SCHEDULER floor is for.
274
+ """
275
+
276
+ serializer_class = ResourceSkillSerializer
277
+ filter_backends = [filters.OrderingFilter]
278
+ queryset = ResourceSkill.objects.active().select_related("skill").filter(is_deleted=False)
279
+
280
+ def get_permissions(self) -> list[BasePermission]:
281
+ if self.request.method in ("GET", "HEAD", "OPTIONS"):
282
+ return [IsAuthenticated()]
283
+ return [IsAuthenticated(), IsOrgScheduler()]
284
+
285
+ def get_queryset(self) -> QuerySet[ResourceSkill]:
286
+ # ``.active()`` (#3572): the resource row itself is admin-only once
287
+ # deactivated (``?include_deleted=true`` on the catalog), so listing its
288
+ # skill tags to every authenticated user leaked the tail of a record the
289
+ # catalog had already withdrawn. Admins reach a deactivated resource's
290
+ # skills through the catalog's expanded ``skills`` instead.
291
+ qs = ResourceSkill.objects.active().select_related("skill").filter(is_deleted=False)
292
+ resource_id = self.request.query_params.get("resource")
293
+ if resource_id:
294
+ qs = qs.filter(resource_id=resource_id)
295
+ return qs
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Project roster
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ @extend_schema_view(
304
+ list=extend_schema(
305
+ parameters=[
306
+ OpenApiParameter(
307
+ name="project",
308
+ type=OpenApiTypes.UUID,
309
+ location=OpenApiParameter.QUERY,
310
+ required=False,
311
+ description="Filter the roster to this project.",
312
+ ),
313
+ ],
314
+ ),
315
+ create=extend_schema(
316
+ responses={
317
+ 201: ProjectResourceSerializer,
318
+ 403: OpenApiResponse(
319
+ description=(
320
+ "Caller lacks the Resource Manager role on the target project, or that "
321
+ "project is archived (#3414). The project is named only in the request "
322
+ "body here, so the refusal comes from the view rather than a permission "
323
+ "class — the status and body are the same either way."
324
+ )
325
+ ),
326
+ },
327
+ ),
328
+ destroy=extend_schema(
329
+ parameters=[
330
+ OpenApiParameter(
331
+ name="force",
332
+ type=OpenApiTypes.BOOL,
333
+ location=OpenApiParameter.QUERY,
334
+ required=False,
335
+ description=(
336
+ "When 'true', cascade-delete the resource's TaskResource rows on "
337
+ "this project and trigger a CPM recalculation. Without it, a 409 "
338
+ "is returned if the resource has live task assignments."
339
+ ),
340
+ ),
341
+ ],
342
+ responses={
343
+ 200: OpenApiResponse(
344
+ response=OpenApiTypes.OBJECT,
345
+ description=(
346
+ "Resource removed from the roster. Body carries "
347
+ "`cascaded_assignment_count` — the number of TaskResource rows "
348
+ "severed, `0` when the resource had no live assignments."
349
+ ),
350
+ ),
351
+ 409: OpenApiResponse(
352
+ response=OpenApiTypes.OBJECT,
353
+ description=(
354
+ "The resource still has live task assignments on this project and "
355
+ "`force=true` was not passed. Body carries `code: "
356
+ '"roster_has_assignments"`, `affected_tasks`, `task_names`, and '
357
+ "`assignment_count`. Distinct from the task-restructure "
358
+ "`has_assignments` warning documented on the errors page — this is "
359
+ "the roster-removal refusal."
360
+ ),
361
+ ),
362
+ },
363
+ ),
364
+ )
365
+ class ProjectResourceViewSet(ProjectScopedViewSet, viewsets.ModelViewSet[ProjectResource]):
366
+ """CRUD for a project's resource roster.
367
+
368
+ Read: any project member (VIEWER+).
369
+ Write: SCHEDULER+ on the project.
370
+ Delete: SCHEDULER+; with ?force=true cascades to TaskResource rows and
371
+ triggers CPM recalculation (ADR-0027). Without force=true returns 409 if
372
+ live task assignments exist.
373
+ """
374
+
375
+ permission_classes = [IsAuthenticated, IsProjectMember, CanAssignResource, IsProjectNotArchived]
376
+ resolves_scope_in_body = {
377
+ "create": (
378
+ "Flat route: the project arrives in the body, so `CanAssignResource."
379
+ "has_permission` cannot scope the write and `has_object_permission` does "
380
+ "not run on a create. `perform_create` resolves the body's project and "
381
+ "applies the same Scheduler+ floor before saving (#3767)."
382
+ )
383
+ }
384
+ serializer_class = ProjectResourceSerializer
385
+ filter_backends = [filters.OrderingFilter]
386
+ queryset = (
387
+ ProjectResource.objects.active()
388
+ .select_related("resource", "resource__calendar")
389
+ .prefetch_related("resource__skills__skill")
390
+ )
391
+
392
+ def get_queryset(self) -> QuerySet[ProjectResource]:
393
+ user_pk = self.request.user.pk
394
+ assert user_pk is not None
395
+ member_project_ids = ProjectMembership.objects.filter(
396
+ user_id=user_pk, is_deleted=False, project__is_deleted=False
397
+ ).values_list("project_id", flat=True)
398
+ # ``.active()`` (#3572) is ``is_deleted=False`` on the roster row AND on the
399
+ # resource behind it. The roster row is the surface an off-boarding is
400
+ # measured on: a deactivated person left the catalog but kept a full
401
+ # ``resource_detail`` on every project's Team tab.
402
+ qs = (
403
+ ProjectResource.objects.active()
404
+ .select_related("resource", "resource__calendar")
405
+ .prefetch_related("resource__skills__skill")
406
+ .filter(project_id__in=member_project_ids)
407
+ .order_by("resource__name")
408
+ )
409
+ project_id = self.request.query_params.get("project")
410
+ if project_id:
411
+ qs = qs.filter(project_id=project_id)
412
+ return qs
413
+
414
+ def perform_create(self, serializer: BaseSerializer[ProjectResource]) -> None:
415
+ """Verify SCHEDULER+ role and a live project before adding to roster.
416
+
417
+ The archived check is here, not left to ``IsProjectNotArchived`` on the class,
418
+ because this route is top-level (``POST /api/v1/project-resources/``) and names
419
+ the project only in the request BODY (#3414). ``has_permission`` finds no
420
+ ``project_pk`` kwarg and stands down, and DRF never calls
421
+ ``has_object_permission`` on a create — there is no object yet. The declared
422
+ permission class therefore reads as gating in review and enforces nothing on
423
+ exactly the request that creates a row, which is how roster adds (and the CPM
424
+ recalculation they enqueue) reached archived projects.
425
+ """
426
+ project = serializer.validated_data.get("project")
427
+ if project:
428
+ role = _membership_role(self.request, str(project.pk))
429
+ if role is None or role < Role.SCHEDULER:
430
+ raise PermissionDenied(
431
+ "You need at least Resource Manager role to manage the roster."
432
+ )
433
+ assert_project_not_archived(project.pk)
434
+ instance = serializer.save()
435
+
436
+ # Mirror the destroy() path: a roster add is invisible to connected
437
+ # clients until a refetch without this broadcast.
438
+ project_id = str(instance.project_id)
439
+ resource_id = str(instance.resource_id)
440
+
441
+ def _on_commit() -> None:
442
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
443
+
444
+ broadcast_board_event(
445
+ project_id,
446
+ "roster_changed",
447
+ {"resource_id": resource_id},
448
+ )
449
+
450
+ transaction.on_commit(_on_commit)
451
+
452
+ def perform_update(self, serializer: BaseSerializer[ProjectResource]) -> None:
453
+ """Persist an allocation/notes edit and broadcast the roster change.
454
+
455
+ The SCHEDULER+ floor for detail mutations is enforced declaratively by
456
+ ``CanAssignResource.has_object_permission``; this hook only adds the
457
+ missing real-time signal (#1359) so peers see an allocation or note
458
+ change without a manual refetch — mirroring perform_create/destroy.
459
+ """
460
+ instance = serializer.save()
461
+
462
+ project_id = str(instance.project_id)
463
+ resource_id = str(instance.resource_id)
464
+
465
+ def _on_commit() -> None:
466
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
467
+
468
+ broadcast_board_event(
469
+ project_id,
470
+ "roster_changed",
471
+ {"resource_id": resource_id},
472
+ )
473
+
474
+ transaction.on_commit(_on_commit)
475
+
476
+ def destroy(self, request: Request, *args: object, **kwargs: object) -> Response:
477
+ """Remove a resource from the roster.
478
+
479
+ If the resource has live TaskResource rows on this project and
480
+ force=true is not passed, returns 409 with code "roster_has_assignments".
481
+ With force=true, cascades the deletion and triggers CPM recalculation for
482
+ affected tasks. The code is distinct from the "has_assignments" 2xx
483
+ warning that task-restructure endpoints (indent/outdent/reparent/group)
484
+ return — that is an advisory on a write that succeeded; this is a hard
485
+ refusal of the roster removal itself.
486
+ """
487
+ instance: ProjectResource = self.get_object()
488
+ project_id = str(instance.project_id)
489
+ resource_id = instance.resource_id
490
+
491
+ live_assignments = list(
492
+ TaskResource.objects.filter(
493
+ resource_id=resource_id,
494
+ task__project_id=project_id,
495
+ task__is_deleted=False,
496
+ ).select_related("task")
497
+ )
498
+
499
+ if live_assignments and request.query_params.get("force") != "true":
500
+ task_names = [a.task.name for a in live_assignments[:5]]
501
+ return Response(
502
+ {
503
+ "code": "roster_has_assignments",
504
+ "detail": (
505
+ f"{instance.resource.name} is assigned to "
506
+ f"{len(live_assignments)} task(s) in this project. "
507
+ f"Pass ?force=true to remove and unassign."
508
+ ),
509
+ "affected_tasks": [
510
+ {"id": str(a.task_id), "name": a.task.name} for a in live_assignments[:10]
511
+ ],
512
+ "task_names": task_names,
513
+ "assignment_count": len(live_assignments),
514
+ },
515
+ status=status.HTTP_409_CONFLICT,
516
+ )
517
+
518
+ affected_task_ids = [str(a.task_id) for a in live_assignments]
519
+
520
+ # Cascade: delete TaskResource rows, then the ProjectResource row.
521
+ # Wrapped in atomic() so a mid-sequence failure cannot leave orphaned
522
+ # TaskResource rows without a corresponding ProjectResource (🔴-8).
523
+ with transaction.atomic():
524
+ TaskResource.objects.filter(
525
+ resource_id=resource_id,
526
+ task__project_id=project_id,
527
+ ).delete()
528
+ instance.delete()
529
+
530
+ # The roster shrank regardless of whether the removed resource had any
531
+ # live assignments — a zero-assignment removal is the most common case
532
+ # (#1359). Broadcast roster_changed unconditionally so connected clients
533
+ # refetch the roster; only enqueue a CPM recalc when assignments were
534
+ # actually severed (no schedule input changed otherwise).
535
+ def _on_commit() -> None:
536
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
537
+
538
+ if affected_task_ids:
539
+ _enqueue_recalculate(project_id)
540
+ broadcast_board_event(
541
+ project_id,
542
+ "roster_changed",
543
+ {"resource_id": str(resource_id)},
544
+ )
545
+
546
+ transaction.on_commit(_on_commit)
547
+
548
+ return Response(
549
+ {
550
+ "detail": "Resource removed from project.",
551
+ "cascaded_assignment_count": len(live_assignments),
552
+ },
553
+ status=status.HTTP_200_OK,
554
+ )
555
+
556
+
557
+ # ---------------------------------------------------------------------------
558
+ # Task skill requirements
559
+ # ---------------------------------------------------------------------------
560
+
561
+
562
+ @extend_schema_view(
563
+ list=extend_schema(
564
+ parameters=[
565
+ OpenApiParameter(
566
+ name="task",
567
+ type=OpenApiTypes.UUID,
568
+ location=OpenApiParameter.QUERY,
569
+ required=False,
570
+ description="Filter to skill requirements for this task.",
571
+ ),
572
+ ],
573
+ ),
574
+ create=extend_schema(
575
+ responses={
576
+ 201: TaskSkillRequirementSerializer,
577
+ 403: OpenApiResponse(
578
+ description=(
579
+ "Caller lacks the Resource Manager role on the target task's project, "
580
+ "or that project is archived (#3570). The project is reached only "
581
+ "through the body's task here, so the refusal comes from the view "
582
+ "rather than a permission class — the status and body are the same "
583
+ "either way."
584
+ )
585
+ ),
586
+ },
587
+ ),
588
+ )
589
+ class TaskSkillRequirementViewSet(IdempotencyMixin, viewsets.ModelViewSet[TaskSkillRequirement]):
590
+ """CRUD for skill requirements on tasks.
591
+
592
+ Read: authenticated users scoped to their member projects.
593
+ Write: SCHEDULER+ **on the target task's own project**, and the target task's
594
+ project must not be archived — both enforced per-write by
595
+ :meth:`_require_scheduler_on_task`.
596
+
597
+ This surface is project-scoped, not org-global: every row belongs to exactly
598
+ one task, which belongs to exactly one project. It carried ``IsOrgScheduler``
599
+ as a class gate, which was strictly redundant — anyone passing the real
600
+ per-target check necessarily holds SCHEDULER+ on some project and so passed
601
+ the org gate too. Redundant is not free: it kept a project-scoped endpoint
602
+ inside the blast radius of the self-grantable org derivation (#3569), and it
603
+ made the endpoint's true rule harder to read. Dropped in favour of the check
604
+ that was already doing the work. The archived-project floor (#3570) is the
605
+ same story: enforced inline against the *target* task's project, not via a
606
+ class-level ``IsProjectNotArchived`` that could only ever check the object
607
+ DRF already resolved.
608
+ """
609
+
610
+ serializer_class = TaskSkillRequirementSerializer
611
+ filter_backends = [filters.OrderingFilter]
612
+ queryset = TaskSkillRequirement.objects.select_related("skill", "task").filter(is_deleted=False)
613
+
614
+ def get_permissions(self) -> list[BasePermission]:
615
+ # Writes are authorized against the *target task's* project — both role
616
+ # and archived-state — in perform_create / perform_update /
617
+ # perform_destroy; see _require_scheduler_on_task. No class-level org or
618
+ # archived gate: either could only ever admit a superset of what that
619
+ # check admits.
620
+ return [IsAuthenticated()]
621
+
622
+ def _require_scheduler_on_task(self, task: Task) -> None:
623
+ # This is the *only* authorization on writes (#3569 removed the redundant
624
+ # IsOrgScheduler class gate above it). DRF never runs
625
+ # has_object_permission on create, so without a per-target check a
626
+ # SCHEDULER on project A could annotate a task in project B they've never
627
+ # joined (IDOR, #995). The membership-scoped get_queryset closes the
628
+ # non-member case on update/delete, but a low-role (Member/Viewer) member
629
+ # of the target project would still be a member — so the SCHEDULER floor
630
+ # is enforced on every write, against the *target task's* project.
631
+ role = _membership_role(self.request, str(task.project_id))
632
+ if role is None or role < Role.SCHEDULER:
633
+ # "Resource Manager" is the label for ``Role.SCHEDULER``; "Scheduler"
634
+ # is the code name and appears on no surface, so a refusal naming it
635
+ # sends the reader looking for a role that does not exist (#3503).
636
+ # Matches ``IsProjectScheduler.message``.
637
+ raise PermissionDenied(
638
+ "You need at least Resource Manager role on the target task's project."
639
+ )
640
+ # Archived is lifecycle state, not authority, so it is checked after the role
641
+ # floor and against the same target project. There is no class-level
642
+ # ``IsProjectNotArchived`` here (#3569 dropped the class-level org gate this
643
+ # method sits beside, for the same reason): this route is top-level, so on a
644
+ # create there is no object for ``has_object_permission`` to resolve, and on an
645
+ # update that repoints ``task`` the object DRF would check is the OLD row,
646
+ # whose project may be live while the destination is archived (#3570).
647
+ assert_project_not_archived(task.project_id)
648
+
649
+ def perform_create(self, serializer: BaseSerializer[TaskSkillRequirement]) -> None:
650
+ self._require_scheduler_on_task(serializer.validated_data["task"])
651
+ super().perform_create(serializer)
652
+
653
+ def perform_update(self, serializer: BaseSerializer[TaskSkillRequirement]) -> None:
654
+ # Guard both the current row's project and any repointed ``task`` so an
655
+ # update can neither mutate a row on a project the actor lacks SCHEDULER
656
+ # on, nor move a requirement onto a task in such a project.
657
+ instance = serializer.instance
658
+ if instance is not None:
659
+ self._require_scheduler_on_task(instance.task)
660
+ new_task = serializer.validated_data.get("task")
661
+ if new_task is not None:
662
+ self._require_scheduler_on_task(new_task)
663
+ super().perform_update(serializer)
664
+
665
+ def perform_destroy(self, instance: TaskSkillRequirement) -> None:
666
+ self._require_scheduler_on_task(instance.task)
667
+ super().perform_destroy(instance)
668
+
669
+ def get_queryset(self) -> QuerySet[TaskSkillRequirement]:
670
+ # Scope to tasks in projects where the requesting user is a member.
671
+ from typing import cast
672
+
673
+ from django.contrib.auth.models import User
674
+
675
+ user = cast(User, self.request.user)
676
+ member_project_ids = ProjectMembership.objects.filter(
677
+ user=user, is_deleted=False, project__is_deleted=False
678
+ ).values_list("project_id", flat=True)
679
+ # select_related("task") avoids N+1 queries in perform_update and
680
+ # perform_destroy, both of which call _require_scheduler_on_task(instance.task)
681
+ # and therefore load task.project_id (R1).
682
+ qs = TaskSkillRequirement.objects.select_related("skill", "task").filter(
683
+ is_deleted=False,
684
+ task__project_id__in=member_project_ids,
685
+ )
686
+ task_id = self.request.query_params.get("task")
687
+ if task_id:
688
+ qs = qs.filter(task_id=task_id)
689
+ return qs
690
+
691
+
692
+ # ---------------------------------------------------------------------------
693
+ # Resource with skill-fit annotation
694
+ # ---------------------------------------------------------------------------
695
+
696
+
697
+ class _WorkspaceAdminEmailSearchFilter(filters.SearchFilter):
698
+ """SearchFilter that only lets a workspace admin search the catalog by email (#892).
699
+
700
+ The catalog is readable by any authenticated user, and email is stripped from
701
+ payloads below workspace ADMIN (#891). But a static
702
+ ``search_fields = ["name", "email"]`` still let anyone else probe email existence
703
+ via ``?search=<email-substring>`` (a hit narrows the candidate set even though the
704
+ value is never echoed). This backend gates the searchable fields on the same check
705
+ the serializer uses: workspace admins search name + email; everyone else searches
706
+ name only.
707
+
708
+ Raised from the org-admin derivation to the stored workspace role in #3569 — the
709
+ org-admin check it used to call was reachable by any account that created a
710
+ throwaway project, which made the #891 harvest control decorative.
711
+ """
712
+
713
+ def get_search_fields(self, view: object, request: Request) -> list[str]:
714
+ if _request_is_workspace_admin(request):
715
+ return ["name", "email"]
716
+ return ["name"]
717
+
718
+
719
+ def _request_is_workspace_admin(request: Request) -> bool:
720
+ """Return True if the requesting user holds workspace ADMIN or above.
721
+
722
+ A request-shaped wrapper over
723
+ :func:`~trueppm_api.apps.workspace.permissions.is_workspace_admin`, which is the
724
+ single definition — do not re-derive the role test here. This one exists because
725
+ a ``SearchFilter`` backend has no permission class to hang the check on. Gates
726
+ email visibility in search (#892) and the ``?include_deleted=true`` deactivated
727
+ pool (#1374), matching :class:`IsWorkspaceAdminStrict` on the sibling actions.
728
+
729
+ This used to derive admin authority from holding ADMIN+ on any project, in
730
+ lockstep with ``IsOrgAdmin``. That derivation is self-grantable in two requests
731
+ (#3569): nothing gates ``POST /projects/`` and ``perform_create`` makes the caller
732
+ Owner. ``WorkspaceMembership`` is granted only by an existing workspace admin or
733
+ by SSO provisioning, so it is not reachable that way.
734
+ """
735
+ return is_workspace_admin(getattr(request, "user", None))
736
+
737
+
738
+ def _caller_is_project_member(request: Request, project_id: Any) -> bool:
739
+ """Return True when the caller holds an active membership on ``project_id``.
740
+
741
+ Gates the catalog list's project-scoped query parameters (#3571). The resource
742
+ catalog is readable by any authenticated user (ADR-0034), so a parameter that
743
+ reaches *through* that open read into one project's data — its roster, its
744
+ tasks' skill requirements — has to carry its own membership check; the
745
+ endpoint's permission class cannot supply one, because the endpoint is not
746
+ project-scoped.
747
+
748
+ A malformed id is deliberately *not* pre-validated here: letting it reach the
749
+ UUID column keeps the install-wide malformed-uuid contract
750
+ (``core.exception_handlers`` maps it to 400 for a query param), rather than
751
+ quietly downgrading a typo to an ignored parameter.
752
+ """
753
+ return _membership_role(request, project_id) is not None
754
+
755
+
756
+ def _caller_may_read_task(request: Request, task_id: Any) -> bool:
757
+ """Return True when the caller is a member of the project owning ``task_id``.
758
+
759
+ The ``?task=`` skill-fit annotation echoes a task's full requirement set back
760
+ through the open catalog read, so those rows need the same project scoping
761
+ :meth:`TaskSkillRequirementViewSet.get_queryset` already applies to them
762
+ (#3571). An unknown task id and a foreign one are equally un-annotated, so the
763
+ parameter cannot be used to confirm that a task id exists. A malformed id is
764
+ left to the install-wide malformed-uuid contract (400), as above.
765
+ """
766
+ project_id = Task.objects.filter(pk=task_id).values_list("project_id", flat=True).first()
767
+ if project_id is None:
768
+ return False
769
+ return _membership_role(request, project_id) is not None
770
+
771
+
772
+ class ResourceCatalogThrottle(UserRateThrottle):
773
+ """Per-user rate limit on the org-wide resource catalog (#891).
774
+
775
+ Mirrors the ``user_search`` throttle added for UserSearchView in #815: the
776
+ catalog is readable by any authenticated user, so even with email stripped a
777
+ single account could still page through it to enumerate the workforce. A
778
+ per-user 60/min cap bounds bulk scraping while staying well above any
779
+ interactive picker (AddToRosterCombobox) usage. The rate is set inline so no
780
+ settings entry is required.
781
+ """
782
+
783
+ scope = "resource_catalog"
784
+ rate = "60/min"
785
+
786
+
787
+ def _compute_skill_fit(
788
+ resource: Resource, requirements: list[TaskSkillRequirement]
789
+ ) -> tuple[str, list[dict[str, object]]]:
790
+ """Return (skill_fit, missing_skills) for a resource against task requirements.
791
+
792
+ skill_fit is 'exact' | 'partial' | 'missing'.
793
+ missing_skills is a list of dicts with skill_id, skill_name, required, actual.
794
+ """
795
+ if not requirements:
796
+ return "exact", []
797
+
798
+ resource_skills: dict[str, int] = {
799
+ str(rs.skill_id): rs.proficiency for rs in resource.skills.all()
800
+ }
801
+
802
+ matched = 0
803
+ missing = []
804
+ for req in requirements:
805
+ sid = str(req.skill_id)
806
+ actual = resource_skills.get(sid, 0)
807
+ if actual >= req.min_proficiency:
808
+ matched += 1
809
+ else:
810
+ missing.append(
811
+ {
812
+ "skill_id": sid,
813
+ "skill_name": req.skill.name,
814
+ "required": req.min_proficiency,
815
+ "required_label": req.get_min_proficiency_display(),
816
+ "actual": actual,
817
+ "actual_label": Proficiency(actual).label if actual else "not tagged",
818
+ }
819
+ )
820
+
821
+ if not missing:
822
+ return "exact", []
823
+ if matched > 0:
824
+ return "partial", missing
825
+ return "missing", missing
826
+
827
+
828
+ # Default fractional allocation assumed for a bare-assignee task with no
829
+ # TaskResource row (see the fallback in _check_overallocation below). Mirrors
830
+ # TaskResource.units' own model default — "assigned" without a tracked
831
+ # fraction is treated as full-time, the same assumption Task.assignee already
832
+ # encodes everywhere else it is read as a "who owns this" signal.
833
+ _BARE_ASSIGNEE_DEFAULT_UNITS = Decimal("1.0")
834
+
835
+
836
+ def _check_overallocation(resource: Resource, project_id: str) -> list[dict[str, str]]:
837
+ """Return a warnings list if the resource is overallocated on active tasks.
838
+
839
+ Overallocation is the **peak units the resource holds on any single working
840
+ day** across non-COMPLETE, committed tasks in the project, compared against the
841
+ resource's capacity **on this project** — the roster's ``units_override`` when
842
+ one is set, else ``resource.max_units`` (#3574). Comparing against the raw
843
+ default told a half-time person they were fine at 80% while the heat map one
844
+ click away read 160%. If the peak exceeds capacity a single warning entry is
845
+ returned so the caller can include it in the 201 response without blocking the
846
+ save (ADR-0028 — soft warning, not a hard error).
847
+
848
+ This was previously a project-lifetime ``Sum(units)`` with no date window at
849
+ all, so three 0.8-unit tasks that never share a calendar day reported as 240%
850
+ allocated — 18 of 41 real (resource, project) pairs on the seeded workspace
851
+ produced a warning with no calendar-day conflict anywhere (#3534). Every
852
+ sibling read — the heat map (``projects.utilization``), the board badge, the
853
+ weekly digest — already windows by day, so the write-time warning was the one
854
+ place in the product that did not ask whether the work overlaps. It now shares
855
+ the engine's own calendar resolution and ``peak_concurrent_units``, which means
856
+ "resource calendar wins" and non-working days are not conflicts here either.
857
+
858
+ A task whose only assignment signal is a bare ``Task.assignee`` (no
859
+ TaskResource row) previously contributed **zero** load here, silently
860
+ understating this resource's real allocation (#3047 — the read-side half
861
+ of the #2718/#2900 write/seed fixes). When ``resource.user`` links the
862
+ Resource to the User account ``Task.assignee`` points at, those tasks are
863
+ now folded in at ``_BARE_ASSIGNEE_DEFAULT_UNITS`` over their own span and
864
+ flagged with a separate ``assignment_not_unit_tracked`` warning, so the caller
865
+ knows the peak includes an estimate rather than a real ``TaskResource.units``
866
+ figure. Resources with no linked user account (teams, equipment, or legacy
867
+ rows predating the FK) cannot be correlated to ``Task.assignee`` and are
868
+ unaffected — same as before this fix.
869
+
870
+ Args:
871
+ resource: The Resource being assigned.
872
+ project_id: The project UUID to scope the utilisation peak.
873
+
874
+ Returns:
875
+ A list of warning dicts (overallocation and/or unit-tracking caveat),
876
+ or an empty list.
877
+ """
878
+ # Capacity check counts only committed delivery — BACKLOG and COMPLETE are
879
+ # excluded. BACKLOG via Task.committed (ADR-0057), COMPLETE via the
880
+ # historical exclude (units are no longer demanding capacity).
881
+ committed_tasks = Task.committed.filter(project_id=project_id).exclude(status="COMPLETE")
882
+
883
+ # Span, not remaining-work window: Coalesce(scheduled_start, early_start)
884
+ # .. early_finish, identical to the utilization engine (ADR-0752 / #2623).
885
+ # A task the CPM has never dated comes back as (None, None) and is folded in
886
+ # by peak_concurrent_units as an every-day baseline rather than dropped.
887
+ assignment_rows = list(
888
+ TaskResource.objects.filter(
889
+ resource=resource,
890
+ task_id__in=committed_tasks.values_list("pk", flat=True),
891
+ )
892
+ .annotate(
893
+ _span_start=Coalesce(
894
+ "task__scheduled_start", "task__early_start", output_field=DateField()
895
+ )
896
+ )
897
+ .values_list("task_id", "units", "_span_start", "task__early_finish")
898
+ )
899
+ allocations = [
900
+ Allocation(units=units, start=span_start, end=span_end)
901
+ for _task_id, units, span_start, span_end in assignment_rows
902
+ ]
903
+
904
+ warnings: list[dict[str, str]] = []
905
+
906
+ # Bare-assignee fallback: only resources linked to a user account can be
907
+ # correlated to Task.assignee at all.
908
+ bare_assignee_rows: list[tuple[object, object, object]] = []
909
+ if resource.user_id is not None:
910
+ unit_tracked_task_ids = {row[0] for row in assignment_rows}
911
+ bare_assignee_rows = list(
912
+ committed_tasks.filter(assignee_id=resource.user_id)
913
+ .exclude(pk__in=unit_tracked_task_ids)
914
+ .annotate(
915
+ _span_start=Coalesce("scheduled_start", "early_start", output_field=DateField())
916
+ )
917
+ .values_list("pk", "_span_start", "early_finish")
918
+ )
919
+
920
+ if bare_assignee_rows:
921
+ allocations.extend(
922
+ Allocation(
923
+ units=_BARE_ASSIGNEE_DEFAULT_UNITS,
924
+ start=span_start, # type: ignore[arg-type]
925
+ end=span_end, # type: ignore[arg-type]
926
+ )
927
+ for _pk, span_start, span_end in bare_assignee_rows
928
+ )
929
+ warnings.append(
930
+ {
931
+ "code": "assignment_not_unit_tracked",
932
+ "resource_id": str(resource.pk),
933
+ "resource_name": resource.name,
934
+ "detail": (
935
+ f"{resource.name} is assigned to {len(bare_assignee_rows)} "
936
+ "task(s) with no tracked allocation units; the capacity "
937
+ "figures below assume full-time allocation for those tasks."
938
+ ),
939
+ "task_ids": ",".join(str(pk) for pk, _s, _e in bare_assignee_rows),
940
+ }
941
+ )
942
+
943
+ project = (
944
+ Project.objects.select_related("calendar")
945
+ .prefetch_related("calendar__exceptions")
946
+ .filter(pk=project_id)
947
+ .first()
948
+ )
949
+ mask, exception_ranges = resolve_working_calendar(resource, project)
950
+ peak, peak_day = peak_concurrent_units(allocations, mask, exception_ranges)
951
+
952
+ capacity = resource_effective_units(project_id, resource)
953
+
954
+ if peak > capacity:
955
+ # Name the day when there is one. A peak carried entirely by undated
956
+ # tasks has no day to point at, only a floor that applies to every day.
957
+ when = f"on {peak_day.isoformat()}" if peak_day is not None else "on their busiest day"
958
+ warnings.append(
959
+ {
960
+ "code": "resource_overallocated",
961
+ "resource_id": str(resource.pk),
962
+ "resource_name": resource.name,
963
+ "detail": (
964
+ f"{resource.name} is allocated {peak:.0%} {when} (capacity: {capacity:.0%})."
965
+ ),
966
+ }
967
+ )
968
+ return warnings
969
+
970
+
971
+ @extend_schema_view(
972
+ list=extend_schema(
973
+ parameters=[
974
+ OpenApiParameter(
975
+ name="exclude_project",
976
+ type=OpenApiTypes.UUID,
977
+ location=OpenApiParameter.QUERY,
978
+ required=False,
979
+ description=(
980
+ "Exclude resources already in this project's roster. Only "
981
+ "honoured for members of that project; ignored otherwise."
982
+ ),
983
+ ),
984
+ OpenApiParameter(
985
+ name="task",
986
+ type=OpenApiTypes.UUID,
987
+ location=OpenApiParameter.QUERY,
988
+ required=False,
989
+ description=(
990
+ "Annotate each resource with skill_fit (exact/partial/missing) "
991
+ "against this task's skill requirements and group results "
992
+ "accordingly. Only honoured for members of the task's project; "
993
+ "ignored otherwise."
994
+ ),
995
+ ),
996
+ OpenApiParameter(
997
+ name="include_deleted",
998
+ type=OpenApiTypes.BOOL,
999
+ location=OpenApiParameter.QUERY,
1000
+ required=False,
1001
+ description=(
1002
+ "When 'true', include soft-deleted (deactivated) resources. Only "
1003
+ "honoured for callers holding workspace Admin or above; silently ignored "
1004
+ "for everyone else."
1005
+ ),
1006
+ ),
1007
+ ],
1008
+ ),
1009
+ )
1010
+ class ResourceViewSet(IdempotencyMixin, viewsets.ModelViewSet[Resource]):
1011
+ """CRUD for the org-level resource catalog (issue #155).
1012
+
1013
+ Permission model (ADR-0034):
1014
+ Read (GET/HEAD/OPTIONS): any authenticated user — supports self-view
1015
+ for team members and the AddToRosterCombobox picker.
1016
+ Write (POST/PATCH/PUT): IsOrgAdmin — any user with PM (ADMIN) or Owner
1017
+ role on at least one *active* project (#3569). ``email`` is an exception:
1018
+ ``ResourceSerializer.validate`` rejects it below the stored workspace ADMIN
1019
+ role (#3625) — this view-level gate alone was self-grantable and left
1020
+ writing the field wide open even after #3569 raised reading it.
1021
+ DELETE, ``restore``, ``assignments``, and ``?include_deleted=true``:
1022
+ IsWorkspaceAdminStrict (stored workspace ADMIN role). Raised in #3569 —
1023
+ these reach across every project in the install, and the org-admin
1024
+ derivation they used to sit behind is self-grantable by creating a
1025
+ throwaway project.
1026
+
1027
+ DELETE is a soft-delete ("deactivate"): it sets is_deleted=True, soft-deletes
1028
+ the resource's live ProjectResource roster row in every project, and enqueues a
1029
+ schedule recalculation for every project that has open TaskResource rows for the
1030
+ deactivated resource. TaskResource assignment rows are deliberately retained so
1031
+ assignment history survives an off-boarding; every roster, capacity, and skill
1032
+ read filters the deactivated resource out instead. The resource record is never
1033
+ hard-deleted, and POST /resources/{id}/restore/ reverses both halves — including
1034
+ the roster rows, but not a membership that was already removed by hand.
1035
+
1036
+ Query params:
1037
+ ?search= — filter by name/email (DRF SearchFilter); email is
1038
+ searchable only for org admins (#892)
1039
+ ?exclude_project= — exclude resources already in a project's roster;
1040
+ only honoured for members of that project (#3571)
1041
+ ?task= — annotate with skill_fit against the task's skill
1042
+ requirements; groups results into exact/partial/missing.
1043
+ Only honoured for members of the task's project (#3571)
1044
+ ?include_deleted=true — include soft-deleted (deactivated) resources;
1045
+ only honoured for workspace Admin or above
1046
+ ?ordering= — order by name (DRF OrderingFilter)
1047
+
1048
+ Every parameter that names a project- or task-scoped id is gated on the
1049
+ caller's membership of that project. The catalog read itself is open, so a
1050
+ parameter is the only place a project boundary can be crossed here; an
1051
+ unauthorized id is treated as absent rather than refused, so no parameter
1052
+ doubles as an existence oracle.
1053
+ """
1054
+
1055
+ queryset = (
1056
+ Resource.objects.select_related("calendar")
1057
+ .prefetch_related("skills__skill")
1058
+ .order_by("name")
1059
+ )
1060
+ serializer_class = ResourceSerializer
1061
+ # Email search is gated on the stored workspace ADMIN role via the custom backend
1062
+ # (#892, raised from org-admin in #3569): everyone else searches by name only, so
1063
+ # they cannot probe email existence with ?search=.
1064
+ filter_backends = [_WorkspaceAdminEmailSearchFilter, filters.OrderingFilter]
1065
+ search_fields = ["name", "email"] # workspace admins; backend narrows to ["name"]
1066
+ ordering_fields = ["name"]
1067
+ # Per-user cap on the harvest-prone read path (#891, mirrors #815).
1068
+ throttle_classes = [ResourceCatalogThrottle]
1069
+
1070
+ #: Actions that reach past the catalog's own row data — into other projects'
1071
+ #: task names, or into the deactivated pool — and therefore take the **stored**
1072
+ #: workspace ADMIN role rather than the self-grantable org-admin derivation
1073
+ #: (#3569).
1074
+ #:
1075
+ #: * ``assignments`` — task and project names for one person across every
1076
+ #: project in the install (ADR-0499). Exfiltration.
1077
+ #: * ``destroy`` — soft-deletes a shared resource and fans a CPM recalc plus a
1078
+ #: ``roster_changed`` broadcast to every project holding an assignment,
1079
+ #: including projects the caller cannot see. Destruction.
1080
+ #: * ``restore`` — the inverse of ``destroy``, kept on the same floor so the
1081
+ #: deactivation lifecycle has one principal. Splitting it would leave an
1082
+ #: admin able to reactivate rows they can neither list
1083
+ #: (``?include_deleted=true`` is on the same floor) nor deactivate.
1084
+ _WORKSPACE_ADMIN_ACTIONS: frozenset[str] = frozenset({"assignments", "destroy", "restore"})
1085
+
1086
+ def get_permissions(self) -> list[BasePermission]:
1087
+ """Split read vs write permissions.
1088
+
1089
+ Safe HTTP methods open to any authenticated user; ordinary catalog writes
1090
+ (create, update) restricted to org admins (PM or Owner role on any *active*
1091
+ project). The deactivation lifecycle and the cross-project ``assignments``
1092
+ view require workspace ADMIN — see :attr:`_WORKSPACE_ADMIN_ACTIONS`.
1093
+
1094
+ ``IsWorkspaceAdminStrict`` rather than ``IsWorkspaceOperator``: the defect in
1095
+ #3569 is that org authority was read out of a *self-grantable* project role,
1096
+ and the fix is a **stored** principal, not necessarily the install superuser.
1097
+ ``WorkspaceMembership`` is granted only by an existing workspace admin or by
1098
+ SSO provisioning — creating a project grants none — so ADMIN is unreachable
1099
+ by the exploit path while staying an in-app role a workspace owner can hand
1100
+ out. Superusers keep access through ``workspace_role_for_user``'s implicit
1101
+ OWNER bootstrap. ``IsWorkspaceAdminStrict`` (#1724) gates ADMIN on reads as
1102
+ well as writes, which is what ``assignments`` needs.
1103
+ """
1104
+ # `assignments` is a GET that must NOT inherit the base catalog read's open
1105
+ # IsAuthenticated gate: it carries task/project names across project
1106
+ # boundaries, so an open gate is a cross-project IDOR. It sits in the
1107
+ # workspace-admin set rather than being special-cased here.
1108
+ if getattr(self, "action", None) in self._WORKSPACE_ADMIN_ACTIONS:
1109
+ return [IsAuthenticated(), IsWorkspaceAdminStrict()]
1110
+ if self.request.method in ("GET", "HEAD", "OPTIONS"):
1111
+ return [IsAuthenticated()]
1112
+ return [IsAuthenticated(), IsOrgAdmin()]
1113
+
1114
+ def get_queryset(self) -> QuerySet[Resource]:
1115
+ qs = (
1116
+ Resource.objects.select_related("calendar")
1117
+ .prefetch_related("skills__skill")
1118
+ .order_by("name")
1119
+ )
1120
+
1121
+ # Deactivated resources are hidden by default. A workspace admin may opt in
1122
+ # via ?include_deleted=true to manage the deactivated pool. The param is
1123
+ # honored only at that floor (#1374, raised from org-admin in #3569):
1124
+ # anyone else passing it is silently ignored so soft-deleted resource
1125
+ # records stay hidden — defense-in-depth for the deactivated-pool contract
1126
+ # the docstring already claimed, and it keeps the pool's three verbs
1127
+ # (list/delete/restore) on one principal.
1128
+ include_deleted = self.request.query_params.get(
1129
+ "include_deleted", ""
1130
+ ).lower() == "true" and _request_is_workspace_admin(self.request)
1131
+ if not include_deleted:
1132
+ qs = qs.filter(is_deleted=False)
1133
+
1134
+ # ?exclude_project= reaches through the open catalog read into one
1135
+ # project's roster: diffing the filtered list against the bare one names
1136
+ # every resource on that roster. Honour it only for members of that
1137
+ # project (#3571). A non-member's parameter is ignored rather than
1138
+ # refused, which is exactly the response they get by omitting it, so the
1139
+ # parameter confirms nothing about the project id either — the same
1140
+ # treatment ?include_deleted= gets for non-admins above.
1141
+ exclude_project = self.request.query_params.get("exclude_project")
1142
+ if exclude_project and _caller_is_project_member(self.request, exclude_project):
1143
+ already_in = ProjectResource.objects.filter(
1144
+ project_id=exclude_project, is_deleted=False
1145
+ ).values_list("resource_id", flat=True)
1146
+ qs = qs.exclude(pk__in=already_in)
1147
+
1148
+ return qs
1149
+
1150
+ def perform_destroy(self, instance: Resource) -> None:
1151
+ """Soft-delete: hide from the catalog, drop off every roster, recalc schedules.
1152
+
1153
+ Deactivation is **catalog-only on the assignment rows** (#3572). Hard delete is
1154
+ intentionally unavailable from this endpoint, and the ``TaskResource`` rows are
1155
+ deliberately left in place: historical task assignments and capacity data
1156
+ reference this resource and must remain intact for audit trails — an
1157
+ off-boarding is precisely the moment that record matters most.
1158
+
1159
+ What deactivation *does* end is the person's presence in the team:
1160
+
1161
+ 1. Every live ``ProjectResource`` roster row is soft-deleted and stamped
1162
+ ``deactivated_with_resource`` so :meth:`restore` can put back exactly these
1163
+ and not a membership someone ended by hand.
1164
+ 2. Every capacity, roster, and skill read filters the resource out through
1165
+ ``ResourceScopedManager.active()`` — the retained assignment rows stop
1166
+ contributing load and stop contributing capacity to the denominator.
1167
+
1168
+ The CPM recalculation fan-out below stays coherent under (2): it is not
1169
+ undoing the assignments (they still hold their tasks and their units), it is
1170
+ re-running the schedule for projects whose *reported* team just changed, so the
1171
+ heat map, the allocation timeline, and the Overview utilization card agree with
1172
+ the roster on the very next read rather than on the next unrelated write.
1173
+ """
1174
+ # Wrap the soft-delete save, the roster cascade, and the per-project
1175
+ # recalculation fan-out in a single atomic block so a crash partway through
1176
+ # cannot leave the resource deactivated without the roster cascade or the CPM
1177
+ # recalcs (R3). The _enqueue_recalculate calls use the transactional outbox
1178
+ # pattern (ADR-0027), so they are durable against broker failures.
1179
+ with transaction.atomic():
1180
+ instance.is_deleted = True
1181
+ instance.server_version = (instance.server_version or 0) + 1
1182
+ instance.deleted_version = instance.server_version
1183
+ instance.save(update_fields=["is_deleted", "server_version", "deleted_version"])
1184
+
1185
+ # Roster cascade. Only rows that are live *right now* are stamped, so any
1186
+ # roster row soft-deleted by some other path keeps
1187
+ # deactivated_with_resource=False and restore leaves it alone — see the
1188
+ # field's own comment for why that matters. Bulk ``update()`` rather than per-row
1189
+ # ``soft_delete()``: ProjectResource is outside the sync union (nothing
1190
+ # reads its sync_seq), so there is no tombstone to publish and no reason
1191
+ # to spend a query per roster row on an off-boarding.
1192
+ #
1193
+ # The F() version bump replicates what VersionedModel.soft_delete would
1194
+ # have recorded, following cascade_project_children_soft_delete's
1195
+ # precedent: within one UPDATE every F() reads the pre-update column, so
1196
+ # deleted_version lands equal to the new server_version. It is not
1197
+ # decorative — server_version is the X-Base-Version optimistic-lock token
1198
+ # ProjectResourceSerializer publishes, so skipping it would let a client
1199
+ # holding a pre-deactivation copy pass the lock check on a row that had
1200
+ # since been deactivated and restored.
1201
+ #
1202
+ # Deliberately NOT excluding archived projects: deactivation is an
1203
+ # org-wide off-boarding and must not be left partially applied because
1204
+ # one project happens to be archived. The roster row of an archived
1205
+ # project is a historical record either way, and restore's symmetric
1206
+ # filter keeps the two halves reversible.
1207
+ cascade_rows = ProjectResource.objects.filter(resource_id=instance.pk, is_deleted=False)
1208
+ # Snapshot the project ids before the UPDATE clears the predicate that
1209
+ # selects them, so the broadcast set is exactly the rows this call
1210
+ # cascaded — not every row a previous, never-restored deactivation left
1211
+ # stamped.
1212
+ cascaded_project_ids = list(cascade_rows.values_list("project_id", flat=True))
1213
+ cascade_rows.update(
1214
+ is_deleted=True,
1215
+ deactivated_with_resource=True,
1216
+ server_version=models.F("server_version") + 1,
1217
+ deleted_version=models.F("server_version") + 1,
1218
+ )
1219
+
1220
+ # Fan out a schedule recalculation to every project with open
1221
+ # task assignments for this resource. Uses the transactional outbox
1222
+ # pattern (ADR-0027) so a broker-down event doesn't lose the request.
1223
+ affected_project_ids = list(
1224
+ TaskResource.objects.filter(resource_id=instance.pk)
1225
+ .values_list("task__project_id", flat=True)
1226
+ .distinct()
1227
+ )
1228
+ for project_id in affected_project_ids:
1229
+ _enqueue_recalculate(str(project_id))
1230
+
1231
+ # The roster cascade above reaches projects the assignment fan-out does not:
1232
+ # a resource can sit on a roster carrying no tasks at all, which is exactly
1233
+ # the person an off-boarding is most likely to leave behind. So the broadcast
1234
+ # set is the union of both, not just the projects that carried assignments.
1235
+ #
1236
+ # Fan a roster_changed broadcast out to every affected project so peers
1237
+ # viewing the roster see the deactivation live, not on the next poll
1238
+ # (#1359). Deferred to commit and snapshotted to plain strings; mirrors
1239
+ # the per-project recalc fan-out above.
1240
+ resource_id = str(instance.pk)
1241
+ broadcast_project_ids = sorted(
1242
+ {str(p) for p in affected_project_ids} | {str(p) for p in cascaded_project_ids}
1243
+ )
1244
+
1245
+ def _on_commit() -> None:
1246
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1247
+
1248
+ for project_id in broadcast_project_ids:
1249
+ broadcast_board_event(
1250
+ project_id,
1251
+ "roster_changed",
1252
+ {"resource_id": resource_id},
1253
+ )
1254
+
1255
+ transaction.on_commit(_on_commit)
1256
+
1257
+ # Reactivation is carried by the URL alone — the body is never read. Without
1258
+ # ``request=None`` the schema fell back to serializer_class and published a
1259
+ # *required* ResourceRequest body (the full write shape) on this no-body
1260
+ # endpoint, so a generated client demanded a whole resource payload to
1261
+ # un-deactivate one (#2840). Sibling restore actions on Task/Project already
1262
+ # publish no body; this one was the outlier.
1263
+ @extend_schema(
1264
+ summary="Restore a deactivated resource",
1265
+ request=None,
1266
+ responses={
1267
+ 200: ResourceSerializer,
1268
+ 400: OpenApiResponse(description="Resource is not deactivated."),
1269
+ 404: OpenApiResponse(description="Resource not found."),
1270
+ },
1271
+ )
1272
+ @action(detail=True, methods=["post"], url_path="restore")
1273
+ def restore(self, request: Request, pk: str | None = None) -> Response:
1274
+ """Restore a soft-deleted resource back to active status.
1275
+
1276
+ Requires IsWorkspaceAdminStrict (checked in get_permissions since this is a
1277
+ write action; raised from IsOrgAdmin in #3569 so the whole deactivation
1278
+ lifecycle sits on one principal). Fetches from the unfiltered queryset so
1279
+ soft-deleted records are reachable; the standard get_object() path
1280
+ excludes them.
1281
+
1282
+ Reverses **both** halves of :meth:`perform_destroy` (#3572). The catalog flag
1283
+ alone is not enough: deactivation also soft-deleted the resource's roster
1284
+ rows, and a one-way cascade would leave a reactivated person visible in the
1285
+ catalog but silently absent from every team they were on — a second bug in
1286
+ the shape of the first.
1287
+
1288
+ Only rows stamped ``deactivated_with_resource`` come back — restore states
1289
+ what it reverses rather than inferring it from ``is_deleted``. Roster removal
1290
+ through the API is a hard delete, so the cascade is currently the only
1291
+ product path that leaves a soft-deleted roster row; the stamp is what keeps
1292
+ that an assertion rather than an assumption, so a row soft-deleted by an
1293
+ import, a management command, or a data repair is never silently adopted
1294
+ into somebody's reactivation. See ``ProjectResource.deactivated_with_resource``.
1295
+ """
1296
+ if pk is None:
1297
+ return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
1298
+ try:
1299
+ resource = Resource.objects.get(pk=pk)
1300
+ except Resource.DoesNotExist:
1301
+ return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
1302
+ if not resource.is_deleted:
1303
+ return Response(
1304
+ {"detail": "Resource is not deactivated."},
1305
+ status=status.HTTP_400_BAD_REQUEST,
1306
+ )
1307
+
1308
+ # Atomic so the catalog flag and the roster rows cannot disagree: a crash
1309
+ # between them would leave the resource selectable but off every roster,
1310
+ # which is the exact asymmetry this action exists to prevent.
1311
+ with transaction.atomic():
1312
+ resource.is_deleted = False
1313
+ resource.deleted_version = None
1314
+ resource.server_version = (resource.server_version or 0) + 1
1315
+ resource.save(update_fields=["is_deleted", "deleted_version", "server_version"])
1316
+
1317
+ # Read the project ids before the update clears the stamp.
1318
+ restored_project_ids = list(
1319
+ ProjectResource.objects.filter(
1320
+ resource_id=resource.pk, deactivated_with_resource=True
1321
+ ).values_list("project_id", flat=True)
1322
+ )
1323
+ # Symmetric to the cascade: same F() version bump, so a client holding
1324
+ # a pre-deactivation copy of a restored roster row cannot pass the
1325
+ # X-Base-Version optimistic-lock check on it.
1326
+ ProjectResource.objects.filter(
1327
+ resource_id=resource.pk, deactivated_with_resource=True
1328
+ ).update(
1329
+ is_deleted=False,
1330
+ deleted_version=None,
1331
+ deactivated_with_resource=False,
1332
+ server_version=models.F("server_version") + 1,
1333
+ )
1334
+
1335
+ # Reactivation puts the resource back on every roster the deactivation took
1336
+ # it off, plus every project it still has assignments on — broadcast
1337
+ # roster_changed so those projects' clients refetch the roster live (#1359),
1338
+ # mirroring the soft-delete fan-out.
1339
+ affected_project_ids = sorted(
1340
+ {
1341
+ str(p)
1342
+ for p in TaskResource.objects.filter(resource_id=resource.pk)
1343
+ .values_list("task__project_id", flat=True)
1344
+ .distinct()
1345
+ }
1346
+ | {str(p) for p in restored_project_ids}
1347
+ )
1348
+ resource_id = str(resource.pk)
1349
+
1350
+ def _on_commit() -> None:
1351
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1352
+
1353
+ for project_id in affected_project_ids:
1354
+ broadcast_board_event(
1355
+ project_id,
1356
+ "roster_changed",
1357
+ {"resource_id": resource_id},
1358
+ )
1359
+
1360
+ transaction.on_commit(_on_commit)
1361
+
1362
+ serializer = self.get_serializer(resource)
1363
+ return Response(serializer.data)
1364
+
1365
+ @extend_schema(
1366
+ responses=ResourceAssignmentSerializer(many=True),
1367
+ description=(
1368
+ "Cross-project task assignments for one resource — the org catalog's "
1369
+ "'what is this person working on' view (#2047). Requires the workspace "
1370
+ "Admin role because it carries task/project names across "
1371
+ "project boundaries. "
1372
+ "Soft-deleted tasks are excluded; a deactivated resource still returns "
1373
+ "its assignments. Ordered by project then task."
1374
+ ),
1375
+ )
1376
+ @action(detail=True, methods=["get"], url_path="assignments")
1377
+ def assignments(self, request: Request, pk: str | None = None) -> Response:
1378
+ """Return every task the resource is assigned to, across all projects.
1379
+
1380
+ Gated on ``IsWorkspaceAdminStrict`` in ``get_permissions`` (this GET must not
1381
+ inherit the base catalog read's open gate — see the note there). Unlike
1382
+ ``/task-resources/?resource=`` this is NOT scoped to the caller's member
1383
+ projects: it returns the full cross-project set.
1384
+
1385
+ ADR-0499 gated it on ``IsOrgAdmin`` and reasoned that "the RBAC gate is
1386
+ what makes that safe". It did not — that gate is self-grantable in two
1387
+ requests (#3569), so the complete picture was readable by any account. The
1388
+ stored workspace ADMIN role is not reachable that way. A resource manager who
1389
+ needs their own projects' view uses ``/task-resources/?resource=``, which is
1390
+ membership-scoped.
1391
+
1392
+ The resource is looked up from the unfiltered manager so a deactivated
1393
+ resource's detail panel still resolves (mirrors ``restore``). Soft-deleted
1394
+ tasks are filtered out; completed tasks are included and sectioned client
1395
+ side. ``select_related('task__project')`` keeps this a single query
1396
+ regardless of assignment count (no N+1).
1397
+ """
1398
+ if pk is None or not Resource.objects.filter(pk=pk).exists():
1399
+ return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
1400
+
1401
+ assignments_qs = (
1402
+ TaskResource.objects.filter(resource_id=pk, task__is_deleted=False)
1403
+ .select_related("task__project")
1404
+ .order_by("task__project__name", "task__name")
1405
+ )
1406
+
1407
+ # Use the paginator directly rather than self.paginate_queryset(): the
1408
+ # latter is typed to the viewset's Resource model, but this action returns
1409
+ # TaskResource rows. The set is intrinsically bounded (one person's
1410
+ # assignments) — pagination is a belt-and-suspenders guard.
1411
+ paginator = self.paginator
1412
+ if paginator is not None:
1413
+ page = paginator.paginate_queryset(assignments_qs, request, view=self)
1414
+ if page is not None:
1415
+ serializer = ResourceAssignmentSerializer(page, many=True)
1416
+ return paginator.get_paginated_response(serializer.data)
1417
+ serializer = ResourceAssignmentSerializer(assignments_qs, many=True)
1418
+ return Response(serializer.data)
1419
+
1420
+ def list(self, request: Request, *args: object, **kwargs: object) -> Response:
1421
+ """List resources, optionally annotated with skill_fit for a task."""
1422
+ task_id = request.query_params.get("task")
1423
+ requirements: list[TaskSkillRequirement] = []
1424
+ # missing_skills[] echoes skill_name/required/required_label for every
1425
+ # requirement on the task, so an unscoped ?task= published a foreign
1426
+ # project's requirement set through a read any authenticated user may
1427
+ # make. Annotate only for members of the task's project (#3571) — the
1428
+ # same scoping TaskSkillRequirementViewSet.get_queryset applies to these
1429
+ # rows. A non-member gets the plain, un-annotated catalog.
1430
+ if task_id and _caller_may_read_task(request, task_id):
1431
+ requirements = list(
1432
+ TaskSkillRequirement.objects.filter(
1433
+ task_id=task_id, is_deleted=False
1434
+ ).select_related("skill")
1435
+ )
1436
+
1437
+ queryset = self.filter_queryset(self.get_queryset())
1438
+ page = self.paginate_queryset(queryset)
1439
+ resources = page if page is not None else queryset
1440
+
1441
+ serializer = self.get_serializer(resources, many=True)
1442
+ data = serializer.data
1443
+
1444
+ if requirements:
1445
+ resource_map = {str(r.pk): r for r in resources}
1446
+ fit_order = {"exact": 0, "partial": 1, "missing": 2}
1447
+ annotated = []
1448
+ for item in data:
1449
+ resource = resource_map.get(item["id"])
1450
+ if resource is not None:
1451
+ fit, missing = _compute_skill_fit(resource, requirements)
1452
+ item = dict(item)
1453
+ item["skill_fit"] = fit
1454
+ item["missing_skills"] = missing
1455
+ annotated.append(item)
1456
+ annotated.sort(key=lambda x: fit_order.get(x.get("skill_fit", "missing"), 2))
1457
+ data = annotated
1458
+
1459
+ if page is not None:
1460
+ return self.get_paginated_response(data)
1461
+ return Response(data)
1462
+
1463
+
1464
+ # ---------------------------------------------------------------------------
1465
+ # Task-resource assignments (extended with skill_mismatch warning)
1466
+ # ---------------------------------------------------------------------------
1467
+
1468
+
1469
+ @extend_schema_view(
1470
+ list=extend_schema(
1471
+ parameters=[
1472
+ OpenApiParameter(
1473
+ name="task",
1474
+ type=OpenApiTypes.UUID,
1475
+ location=OpenApiParameter.QUERY,
1476
+ required=False,
1477
+ description="Filter to assignments for this task.",
1478
+ ),
1479
+ OpenApiParameter(
1480
+ name="resource",
1481
+ type=OpenApiTypes.UUID,
1482
+ location=OpenApiParameter.QUERY,
1483
+ required=False,
1484
+ description="Filter to assignments for this resource.",
1485
+ ),
1486
+ ],
1487
+ ),
1488
+ create=extend_schema(
1489
+ responses={
1490
+ 201: TaskResourceSerializer,
1491
+ 403: OpenApiResponse(
1492
+ description=(
1493
+ "Caller lacks the Resource Manager role on the target project, or that "
1494
+ "project is archived (#3414). The project is named only in the request "
1495
+ "body here, so the refusal comes from the view rather than a permission "
1496
+ "class — the status and body are the same either way."
1497
+ )
1498
+ ),
1499
+ },
1500
+ ),
1501
+ )
1502
+ class TaskResourceViewSet(ProjectScopedViewSet, viewsets.ModelViewSet[TaskResource]):
1503
+ """CRUD for task-resource assignments.
1504
+
1505
+ Permission model:
1506
+ - Read (GET/HEAD/OPTIONS): any project member (Viewer+) via IsProjectMember.
1507
+ - Write (POST/PATCH/DELETE): Resource Manager (2) or above via CanAssignResource.
1508
+ The create path additionally checks role in perform_create because has_object_permission
1509
+ is not called before the object exists.
1510
+
1511
+ IDOR protection:
1512
+ ProjectScopedViewSet does not recognise the task→project FK path on TaskResource,
1513
+ so get_queryset explicitly scopes to the user's member projects rather than relying
1514
+ on the mixin fallthrough.
1515
+ """
1516
+
1517
+ permission_classes = [IsAuthenticated, IsProjectMember, CanAssignResource, IsProjectNotArchived]
1518
+ resolves_scope_in_body = {
1519
+ "create": (
1520
+ "Flat route: the project is reached only through the body's `task`, so "
1521
+ "`CanAssignResource.has_permission` cannot scope the write and "
1522
+ "`has_object_permission` does not run on a create. `perform_create` "
1523
+ "resolves `task.project_id` and applies the Scheduler+ floor there (#3767)."
1524
+ )
1525
+ }
1526
+ serializer_class = TaskResourceSerializer
1527
+ filter_backends = [filters.OrderingFilter]
1528
+ queryset = TaskResource.objects.select_related("task__project", "resource")
1529
+
1530
+ def get_queryset(self) -> QuerySet[TaskResource]:
1531
+ # IsAuthenticated guarantees pk is set; assert narrows the type for mypy.
1532
+ user_pk = self.request.user.pk
1533
+ assert user_pk is not None
1534
+ member_project_ids = ProjectMembership.objects.filter(
1535
+ user_id=user_pk,
1536
+ is_deleted=False,
1537
+ project__is_deleted=False, # #4577: a trashed project confers no access
1538
+ ).values_list("project_id", flat=True)
1539
+ # select_related("task__project") avoids N+1 queries in perform_create
1540
+ # and perform_update, both of which call ensure_project_resource(obj.task.project, ...)
1541
+ # and therefore load the full Project object (R2).
1542
+ #
1543
+ # Deliberately NOT ``.active()`` (#3572), unlike its two siblings in this
1544
+ # file. This is the assignment list, not a capacity read: when somebody is
1545
+ # deactivated their assignment rows are kept precisely so a PM can still see
1546
+ # the orphaned work and reassign it. Hiding them here would make the tasks
1547
+ # look unowned with no way to find out who had them. The rows carry no load
1548
+ # anywhere else — every capacity surface filters them out.
1549
+ qs = (
1550
+ TaskResource.objects.select_related("task__project", "resource")
1551
+ .filter(task__project_id__in=member_project_ids)
1552
+ .order_by("task__project_id", "task_id", "resource__name")
1553
+ )
1554
+ task_id = self.request.query_params.get("task")
1555
+ if task_id:
1556
+ qs = qs.filter(task_id=task_id)
1557
+ resource_id = self.request.query_params.get("resource")
1558
+ if resource_id:
1559
+ qs = qs.filter(resource_id=resource_id)
1560
+ return qs
1561
+
1562
+ def create(self, request: Request, *args: object, **kwargs: object) -> Response:
1563
+ """Create a task-resource assignment and return any warnings.
1564
+
1565
+ Returns overallocation and skill_mismatch warnings in the 201 response.
1566
+ The assignment is always saved regardless of warnings — these are soft
1567
+ alerts, not hard blocks (ADR-0028, ADR-0033).
1568
+ """
1569
+ serializer = self.get_serializer(data=request.data)
1570
+ serializer.is_valid(raise_exception=True)
1571
+ self.perform_create(serializer)
1572
+ headers = self.get_success_headers(serializer.data)
1573
+ obj: TaskResource = serializer.instance # type: ignore[assignment]
1574
+ project_id = str(obj.task.project_id)
1575
+ # Fetch the resource (with skills prefetched) once and reuse it for both
1576
+ # the overallocation check and the skill-fit check, instead of lazy-loading
1577
+ # obj.resource and then re-fetching the same row with prefetch (#821).
1578
+ # select_related/prefetch the resource calendar too: _check_overallocation
1579
+ # resolves the resource's working days (ADR-0031 "resource calendar wins"),
1580
+ # which would otherwise be two lazy queries per POST.
1581
+ resource_with_skills = (
1582
+ Resource.objects.select_related("calendar")
1583
+ .prefetch_related("skills__skill", "calendar__exceptions")
1584
+ .get(pk=obj.resource_id)
1585
+ )
1586
+ warnings: list[dict[str, object]] = [
1587
+ dict(w) for w in _check_overallocation(resource_with_skills, project_id)
1588
+ ]
1589
+
1590
+ # Skill mismatch check — only when the task has requirements.
1591
+ requirements = list(
1592
+ TaskSkillRequirement.objects.filter(
1593
+ task_id=obj.task_id, is_deleted=False
1594
+ ).select_related("skill")
1595
+ )
1596
+ _fit, missing = _compute_skill_fit(resource_with_skills, requirements)
1597
+ if missing:
1598
+ missing_labels = ", ".join(
1599
+ f"{m['skill_name']} ({m['required_label']})" for m in missing[:3]
1600
+ )
1601
+ warnings.append(
1602
+ {
1603
+ "code": "skill_mismatch",
1604
+ "detail": f"Task requires: {missing_labels}.",
1605
+ "missing_skills": missing,
1606
+ }
1607
+ )
1608
+
1609
+ data = dict(serializer.data)
1610
+ data["warnings"] = warnings
1611
+ return Response(data, status=status.HTTP_201_CREATED, headers=headers)
1612
+
1613
+ def perform_create(self, serializer: BaseSerializer[TaskResource]) -> None:
1614
+ """Block assignment creation for summary tasks, then trigger CPM and broadcast.
1615
+
1616
+ Role check: Resource Manager (2) or above is required. has_object_permission is
1617
+ not called for create (no object exists yet), so the role is verified here against
1618
+ the task's project before the row is written.
1619
+
1620
+ Summary tasks roll up from children — direct resource assignments on
1621
+ them create ambiguous scheduling semantics (ADR-0024).
1622
+ """
1623
+ task = serializer.validated_data.get("task")
1624
+ if task:
1625
+ role = _membership_role(self.request, str(task.project_id))
1626
+ if role is None or role < Role.SCHEDULER:
1627
+ raise PermissionDenied(
1628
+ "You need at least Resource Manager role to assign resources."
1629
+ )
1630
+ # Same shape as the role check above, and for the same structural reason
1631
+ # (#3414): the route is top-level, the project is reached only through the
1632
+ # body's ``task``, and ``has_object_permission`` does not run on a create —
1633
+ # so the class-level ``IsProjectNotArchived`` cannot see this write.
1634
+ assert_project_not_archived(task.project_id)
1635
+ if task and task_is_summary(task):
1636
+ raise ValidationError({"task": "Cannot assign resources to a summary task."})
1637
+ obj = serializer.save()
1638
+ project_id = str(obj.task.project_id)
1639
+ task_id = str(obj.task.pk)
1640
+ assignment_id = str(obj.pk)
1641
+
1642
+ # Auto-roster: assigning a resource to a task implicitly adds them to
1643
+ # the project roster so they appear in Team → Roster / Heatmap.
1644
+ ensure_project_resource(obj.task.project, obj.resource)
1645
+
1646
+ # Task-activity audit row (ADR-0394, #1886): written synchronously in the
1647
+ # request transaction so it commits/rolls back with the assignment itself.
1648
+ record_assignment_event(
1649
+ task_id=obj.task_id,
1650
+ event_type=TaskActivityEventType.ASSIGNEE_ADDED,
1651
+ resource=obj.resource,
1652
+ actor=self.request.user,
1653
+ units_to=obj.units,
1654
+ )
1655
+
1656
+ def _on_commit() -> None:
1657
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1658
+
1659
+ _enqueue_recalculate(project_id)
1660
+ broadcast_board_event(
1661
+ project_id,
1662
+ "assignment_created",
1663
+ {"id": assignment_id, "task_id": task_id},
1664
+ )
1665
+
1666
+ transaction.on_commit(_on_commit)
1667
+
1668
+ def perform_update(self, serializer: BaseSerializer[TaskResource]) -> None:
1669
+ """Save the updated assignment and trigger CPM recalculation and broadcast."""
1670
+ # Capture pre-save state so the audit row can distinguish a resource re-point
1671
+ # (remove old + add new) from an allocation-only change (ADR-0394, #1886).
1672
+ pre = serializer.instance
1673
+ old_resource = pre.resource if pre is not None else None
1674
+ old_units = pre.units if pre is not None else None
1675
+
1676
+ obj = serializer.save()
1677
+ project_id = str(obj.task.project_id)
1678
+ task_id = str(obj.task.pk)
1679
+ assignment_id = str(obj.pk)
1680
+
1681
+ # Re-pointing an assignment to a different resource must roster the new
1682
+ # one — otherwise editing assignee leaves them invisible in Team views (#241).
1683
+ ensure_project_resource(obj.task.project, obj.resource)
1684
+
1685
+ # Task-activity audit (ADR-0394, #1886), synchronous in-transaction:
1686
+ # a resource swap is an unassign+reassign; a units-only edit is a re-allocation.
1687
+ if old_resource is not None and old_resource.pk != obj.resource_id:
1688
+ record_assignment_event(
1689
+ task_id=obj.task_id,
1690
+ event_type=TaskActivityEventType.ASSIGNEE_REMOVED,
1691
+ resource=old_resource,
1692
+ actor=self.request.user,
1693
+ units_to=old_units,
1694
+ )
1695
+ record_assignment_event(
1696
+ task_id=obj.task_id,
1697
+ event_type=TaskActivityEventType.ASSIGNEE_ADDED,
1698
+ resource=obj.resource,
1699
+ actor=self.request.user,
1700
+ units_to=obj.units,
1701
+ )
1702
+ elif old_units != obj.units:
1703
+ record_assignment_event(
1704
+ task_id=obj.task_id,
1705
+ event_type=TaskActivityEventType.ASSIGNEE_UNITS_CHANGED,
1706
+ resource=obj.resource,
1707
+ actor=self.request.user,
1708
+ units_from=old_units,
1709
+ units_to=obj.units,
1710
+ )
1711
+
1712
+ def _on_commit() -> None:
1713
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1714
+
1715
+ _enqueue_recalculate(project_id)
1716
+ broadcast_board_event(
1717
+ project_id,
1718
+ "assignment_updated",
1719
+ {"id": assignment_id, "task_id": task_id},
1720
+ )
1721
+
1722
+ transaction.on_commit(_on_commit)
1723
+
1724
+ def perform_destroy(self, instance: TaskResource) -> None:
1725
+ """Delete the assignment and trigger CPM recalculation and broadcast."""
1726
+ project_id = str(instance.task.project_id)
1727
+ task_id = str(instance.task.pk)
1728
+ assignment_id = str(instance.pk)
1729
+
1730
+ # Task-activity audit row (ADR-0394, #1886) before the hard delete, while the
1731
+ # resource/units are still readable; the task FK survives (only the assignment
1732
+ # row is removed). Synchronous in-transaction, like the risk-link precedent.
1733
+ record_assignment_event(
1734
+ task_id=instance.task_id,
1735
+ event_type=TaskActivityEventType.ASSIGNEE_REMOVED,
1736
+ resource=instance.resource,
1737
+ actor=self.request.user,
1738
+ units_to=instance.units,
1739
+ )
1740
+ instance.delete()
1741
+
1742
+ def _on_commit() -> None:
1743
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
1744
+
1745
+ _enqueue_recalculate(project_id)
1746
+ broadcast_board_event(
1747
+ project_id,
1748
+ "assignment_deleted",
1749
+ {"id": assignment_id, "task_id": task_id},
1750
+ )
1751
+
1752
+ transaction.on_commit(_on_commit)