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,1581 @@
1
+ """Workspace settings API (#517 General, #518 Members/Invites, #519 Groups).
2
+
3
+ See ADR-0087. RBAC: any active member reads; ADMIN+ writes (``IsWorkspaceAdmin``).
4
+ The invite-accept endpoint is the one public surface (``AllowAny``) — it
5
+ authenticates with a one-time token, not a session.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from typing import Any
12
+
13
+ from django.contrib.auth import get_user_model
14
+ from django.core.exceptions import ValidationError as DjangoValidationError
15
+ from django.db import transaction
16
+ from django.db.models import Count, Q, QuerySet
17
+ from django.http import FileResponse, Http404
18
+ from django.shortcuts import get_object_or_404
19
+ from django.utils import timezone
20
+ from django.utils.dateparse import parse_date, parse_datetime
21
+ from drf_spectacular.types import OpenApiTypes
22
+ from drf_spectacular.utils import (
23
+ OpenApiParameter,
24
+ OpenApiResponse,
25
+ extend_schema,
26
+ inline_serializer,
27
+ )
28
+ from rest_framework import serializers, status
29
+ from rest_framework.exceptions import PermissionDenied, ValidationError
30
+ from rest_framework.pagination import CursorPagination, PageNumberPagination
31
+ from rest_framework.parsers import FormParser, MultiPartParser
32
+ from rest_framework.permissions import AllowAny, IsAuthenticated
33
+ from rest_framework.request import Request
34
+ from rest_framework.response import Response
35
+ from rest_framework.throttling import AnonRateThrottle, ScopedRateThrottle
36
+ from rest_framework.views import APIView
37
+
38
+ from trueppm_api.apps.access.models import Role
39
+ from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
40
+ from trueppm_api.apps.workspace import services
41
+ from trueppm_api.apps.workspace.models import (
42
+ AuditEvent,
43
+ AuditEventType,
44
+ ExportJobStatus,
45
+ Group,
46
+ GroupMembership,
47
+ GroupProject,
48
+ InviteStatus,
49
+ MemberStatus,
50
+ Workspace,
51
+ WorkspaceExportJob,
52
+ WorkspaceInvite,
53
+ WorkspaceMembership,
54
+ WorkspaceRole,
55
+ )
56
+ from trueppm_api.apps.workspace.permissions import (
57
+ IsWorkspaceAdmin,
58
+ IsWorkspaceAdminStrict,
59
+ IsWorkspaceAuditViewer,
60
+ IsWorkspaceOwner,
61
+ _workspace_membership_role,
62
+ request_is_workspace_owner,
63
+ )
64
+ from trueppm_api.apps.workspace.serializers import (
65
+ AuditEventSerializer,
66
+ GroupMemberAddSerializer,
67
+ GroupProjectWriteSerializer,
68
+ GroupSerializer,
69
+ GroupWriteSerializer,
70
+ InviteAcceptSerializer,
71
+ TransferOwnershipSerializer,
72
+ WorkspaceExportJobSerializer,
73
+ WorkspaceInviteCreateSerializer,
74
+ WorkspaceInviteSerializer,
75
+ WorkspaceMemberSerializer,
76
+ WorkspaceMemberUpdateSerializer,
77
+ WorkspaceSettingsSerializer,
78
+ color_for,
79
+ display_name_for,
80
+ initials_for,
81
+ )
82
+ from trueppm_api.core.export_downloads import stream_export_job_or_error
83
+ from trueppm_api.core.openapi import state_refusal_400
84
+
85
+ # Typed-confirmation header for the destructive workspace delete (ADR-0174). Its
86
+ # value must equal the workspace name exactly — the web danger page sends it.
87
+ CONFIRM_WORKSPACE_HEADER = "X-Confirm-Workspace"
88
+
89
+ User = get_user_model()
90
+
91
+
92
+ class InviteAcceptThrottle(AnonRateThrottle):
93
+ """Defense-in-depth rate limit on the public invite-accept endpoint.
94
+
95
+ The token is 256-bit and SHA-256-hashed at rest, so brute force is already
96
+ infeasible; this caps it regardless and bounds account-provisioning abuse.
97
+ """
98
+
99
+ scope = "invite_accept"
100
+ rate = "20/min"
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Row builders (shared by list + detail responses)
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _initials(user: Any) -> str:
109
+ return initials_for(user.first_name, user.last_name, user.username)
110
+
111
+
112
+ def _build_member_rows(users: list[Any]) -> list[dict[str, Any]]:
113
+ user_ids = [u.id for u in users]
114
+ memberships = {
115
+ m.user_id: m
116
+ for m in WorkspaceMembership.objects.filter(user_id__in=user_ids, is_deleted=False)
117
+ }
118
+ group_map: dict[Any, list[str]] = defaultdict(list)
119
+ for gm in GroupMembership.objects.filter(
120
+ user_id__in=user_ids, is_deleted=False, group__is_deleted=False
121
+ ).select_related("group"):
122
+ group_map[gm.user_id].append(gm.group.name)
123
+
124
+ rows: list[dict[str, Any]] = []
125
+ for u in users:
126
+ membership = memberships.get(u.id)
127
+ if membership is not None:
128
+ role_val = membership.role
129
+ member_status = membership.status
130
+ avail_percent = membership.availability_percent
131
+ avail_from = membership.availability_effective_from
132
+ avail_to = membership.availability_effective_to
133
+ avail_notes = membership.availability_notes
134
+ else:
135
+ role_val = WorkspaceRole.OWNER if u.is_superuser else WorkspaceRole.MEMBER
136
+ member_status = MemberStatus.ACTIVE
137
+ # A user without a membership row is fully available by default —
138
+ # mirrors the lazy-creation default the PATCH handler applies (#542).
139
+ avail_percent = 100
140
+ avail_from = None
141
+ avail_to = None
142
+ avail_notes = ""
143
+ rows.append(
144
+ {
145
+ "id": str(u.id),
146
+ "name": display_name_for(u.first_name, u.last_name, u.username),
147
+ "initials": _initials(u),
148
+ "color": color_for(u.id),
149
+ "email": u.email,
150
+ "role": WorkspaceRole(role_val).label,
151
+ "role_value": role_val,
152
+ "groups": sorted(group_map.get(u.id, [])),
153
+ "project_count": getattr(u, "project_count", 0),
154
+ "last_active": u.last_login.isoformat() if u.last_login else None,
155
+ "status": member_status,
156
+ "availability_percent": avail_percent,
157
+ "availability_effective_from": avail_from.isoformat() if avail_from else None,
158
+ "availability_effective_to": avail_to.isoformat() if avail_to else None,
159
+ "availability_notes": avail_notes,
160
+ "sso": False,
161
+ "two_fa": False,
162
+ }
163
+ )
164
+ return rows
165
+
166
+
167
+ def _group_dict(
168
+ group: Group, members: list[Any], project_links: list[dict[str, Any]]
169
+ ) -> dict[str, Any]:
170
+ lead = group.lead
171
+ return {
172
+ "id": str(group.pk),
173
+ "name": group.name,
174
+ "description": group.description,
175
+ "lead": _initials(lead) if lead is not None else None,
176
+ "lead_user_id": str(lead.pk) if lead is not None else None,
177
+ "member_count": len(members),
178
+ "members": [
179
+ {
180
+ "id": str(gm.user_id),
181
+ "name": display_name_for(gm.user.first_name, gm.user.last_name, gm.user.username),
182
+ "initials": _initials(gm.user),
183
+ "color": color_for(gm.user_id),
184
+ }
185
+ for gm in members
186
+ ],
187
+ # #2253: project links carry id + conferred role (not just the name) so the
188
+ # management UI can render the granted role and revoke by project UUID.
189
+ "projects": project_links,
190
+ }
191
+
192
+
193
+ def _project_link(project_id: Any, name: str, role: int) -> dict[str, Any]:
194
+ """One group→project access grant, with the conferred role and its label (#2253)."""
195
+ return {"id": str(project_id), "name": name, "role": role, "role_label": Role(role).label}
196
+
197
+
198
+ def _build_group_dict(group: Group) -> dict[str, Any]:
199
+ """Single-group dict (used for create/patch/detail responses — 2 queries)."""
200
+ members = list(
201
+ GroupMembership.objects.filter(group=group, is_deleted=False).select_related("user")
202
+ )
203
+ project_links = [
204
+ _project_link(pid, name, role)
205
+ for pid, name, role in GroupProject.objects.filter(
206
+ group=group, project__is_deleted=False
207
+ ).values_list("project_id", "project__name", "role")
208
+ ]
209
+ return _group_dict(group, members, project_links)
210
+
211
+
212
+ def _build_group_dicts(groups: list[Group]) -> list[dict[str, Any]]:
213
+ """Bulk group dicts for the list endpoint — two queries total, no per-group N+1."""
214
+ group_ids = [g.pk for g in groups]
215
+ members_by_group: dict[Any, list[Any]] = defaultdict(list)
216
+ for gm in GroupMembership.objects.filter(
217
+ group_id__in=group_ids, is_deleted=False
218
+ ).select_related("user"):
219
+ members_by_group[gm.group_id].append(gm)
220
+ projects_by_group: dict[Any, list[dict[str, Any]]] = defaultdict(list)
221
+ for group_id, project_id, project_name, role in GroupProject.objects.filter(
222
+ group_id__in=group_ids, project__is_deleted=False
223
+ ).values_list("group_id", "project_id", "project__name", "role"):
224
+ projects_by_group[group_id].append(_project_link(project_id, project_name, role))
225
+ return [
226
+ _group_dict(g, members_by_group.get(g.pk, []), projects_by_group.get(g.pk, []))
227
+ for g in groups
228
+ ]
229
+
230
+
231
+ def _invite_dict(invite: WorkspaceInvite) -> dict[str, Any]:
232
+ """Shape one invite row for the admin list.
233
+
234
+ ``accepted_at`` / ``accepted_by`` are the outcome half (#2911): both were
235
+ written by the accept path and read by nothing, so the endpoint could show that
236
+ an invite existed but never that it had been taken up, or by whom. ``accepted_by``
237
+ is initials rather than an email or a name, matching ``invited_by`` — this list
238
+ is already the workspace's PII-heaviest admin surface and there is no reason for
239
+ the outcome column to widen it.
240
+ """
241
+ inviter = invite.invited_by
242
+ accepted_user = invite.accepted_user
243
+ return {
244
+ "id": str(invite.pk),
245
+ "email": invite.email,
246
+ "role": WorkspaceRole(invite.role).label,
247
+ "role_value": invite.role,
248
+ "status": invite.status,
249
+ "invited_by": _initials(inviter) if inviter is not None else None,
250
+ "created_at": invite.created_at,
251
+ "expires_at": invite.expires_at,
252
+ "accepted_at": invite.accepted_at,
253
+ "accepted_by": _initials(accepted_user) if accepted_user is not None else None,
254
+ }
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # #517 — General settings
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ class WorkspaceSettingsView(IdempotencyMixin, APIView):
263
+ """GET/PATCH /api/v1/workspace/ — singleton workspace config (#517).
264
+
265
+ Read for any active member; PATCH requires workspace ADMIN+.
266
+ """
267
+
268
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
269
+
270
+ @extend_schema(responses={200: WorkspaceSettingsSerializer})
271
+ def get(self, request: Request) -> Response:
272
+ workspace = Workspace.load()
273
+ return Response(WorkspaceSettingsSerializer(workspace).data)
274
+
275
+ @extend_schema(
276
+ request=WorkspaceSettingsSerializer, responses={200: WorkspaceSettingsSerializer}
277
+ )
278
+ def patch(self, request: Request) -> Response:
279
+ workspace = Workspace.load()
280
+ # The workspace calendar (or its override policy) is the root of the calendar
281
+ # inheritance chain (ADR-0441): changing either re-points every project that
282
+ # inherits up to the workspace, so capture the before-values to fan out a CPM
283
+ # recompute only when they actually change (a no-op PATCH must not recompute).
284
+ old_calendar_id = workspace.calendar_id
285
+ old_calendar_policy = workspace.calendar_override_policy
286
+ serializer = WorkspaceSettingsSerializer(workspace, data=request.data, partial=True)
287
+ serializer.is_valid(raise_exception=True)
288
+ serializer.save()
289
+ if (
290
+ workspace.calendar_id != old_calendar_id
291
+ or workspace.calendar_override_policy != old_calendar_policy
292
+ ):
293
+ from trueppm_api.apps.projects.views import _recalc_projects_for_workspace_calendar
294
+
295
+ _recalc_projects_for_workspace_calendar(actor=request.user)
296
+ # Audit the change (ADR-0157). Record which settings keys were touched —
297
+ # not their values, which may be large or sensitive (e.g. branding blobs).
298
+ services.record_audit_event(
299
+ event_type=AuditEventType.SETTINGS_CHANGED,
300
+ actor=request.user,
301
+ target_type="workspace",
302
+ target_label="Workspace settings",
303
+ metadata={"fields": sorted(serializer.validated_data.keys())},
304
+ )
305
+ return Response(serializer.data)
306
+
307
+ @extend_schema(
308
+ request=None,
309
+ responses={
310
+ 204: OpenApiResponse(description="Workspace purged and reset."),
311
+ 400: state_refusal_400(
312
+ f"The {CONFIRM_WORKSPACE_HEADER} header is absent or does not match "
313
+ "the workspace name exactly. Verified against the typed-confirmation "
314
+ "guard in ``delete`` (#3319)."
315
+ ),
316
+ },
317
+ description=(
318
+ "Permanently delete the workspace and all its data (factory reset). "
319
+ f"Owner only. Requires the {CONFIRM_WORKSPACE_HEADER} header set to the "
320
+ "exact workspace name."
321
+ ),
322
+ )
323
+ def delete(self, request: Request) -> Response:
324
+ """Hard-delete the workspace and all its data (ADR-0174, #641).
325
+
326
+ Owner-only (PATCH is ADMIN+, so the owner gate is enforced here rather than
327
+ via the class-level permission). A typed-confirmation header must match the
328
+ workspace name exactly, mirroring the inline typed-confirmation on the web
329
+ danger page. ``purge_workspace`` deletes every workspace-scoped row and the
330
+ singleton; ``Workspace.load()`` recreates a fresh default on next access.
331
+ """
332
+ if not request_is_workspace_owner(request):
333
+ raise PermissionDenied("Only the workspace Owner can delete the workspace.")
334
+
335
+ workspace = Workspace.load()
336
+ confirmation = request.headers.get(CONFIRM_WORKSPACE_HEADER, "")
337
+ if confirmation != workspace.name:
338
+ raise ValidationError(
339
+ {
340
+ "detail": (
341
+ f"Confirmation failed — set the {CONFIRM_WORKSPACE_HEADER} header to the "
342
+ "exact workspace name to delete it."
343
+ )
344
+ }
345
+ )
346
+
347
+ services.purge_workspace()
348
+ return Response(status=status.HTTP_204_NO_CONTENT)
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # #518 — Members
353
+ # ---------------------------------------------------------------------------
354
+
355
+
356
+ class WorkspaceMemberCursorPagination(CursorPagination):
357
+ """Depth-independent pagination for the workspace member list (#1317).
358
+
359
+ The roster grows with the org and was previously serialized in full —
360
+ ``User.objects.all()`` with no bound — which risks OOM / slow responses on a
361
+ large workspace. A username-keyed cursor is stable under concurrent member
362
+ creation and avoids the COUNT + deep-OFFSET cost a page-number scheme would
363
+ pay on an unbounded user table. Mirrors AuditEventCursorPagination.
364
+ """
365
+
366
+ ordering = "username"
367
+ page_size = 50
368
+ page_size_query_param = "page_size"
369
+ max_page_size = 200
370
+
371
+
372
+ class WorkspaceMemberListView(APIView):
373
+ """GET /api/v1/workspace/members/ — list members (#518).
374
+
375
+ Admins see every member; a non-admin member sees only their own row.
376
+ Cursor-paginated (#1317): each page materializes at most ``page_size`` rows,
377
+ so the member-row builder's batched queries stay bounded regardless of org
378
+ size.
379
+ """
380
+
381
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
382
+
383
+ def _annotated_users(self, qs: QuerySet[Any]) -> QuerySet[Any]:
384
+ annotated: QuerySet[Any] = qs.annotate(
385
+ project_count=Count(
386
+ "memberships",
387
+ filter=Q(memberships__is_deleted=False, memberships__project__is_deleted=False),
388
+ distinct=True,
389
+ )
390
+ )
391
+ return annotated
392
+
393
+ @extend_schema(
394
+ # This view manually cursor-paginates (it is an APIView, so drf-spectacular
395
+ # does not auto-wrap); declare the envelope explicitly so the schema matches
396
+ # the ``{next, previous, results}`` body (#2127).
397
+ responses={
398
+ 200: inline_serializer(
399
+ name="PaginatedWorkspaceMemberList",
400
+ fields={
401
+ "next": serializers.URLField(allow_null=True),
402
+ "previous": serializers.URLField(allow_null=True),
403
+ "results": WorkspaceMemberSerializer(many=True),
404
+ },
405
+ )
406
+ }
407
+ )
408
+ def get(self, request: Request) -> Response:
409
+ role = _workspace_membership_role(request)
410
+ if role is not None and role >= WorkspaceRole.ADMIN:
411
+ qs = self._annotated_users(User.objects.all())
412
+ else:
413
+ # Non-admin: only your own row.
414
+ user_pk = request.user.pk
415
+ assert user_pk is not None # IsAuthenticated guarantees a real user
416
+ qs = self._annotated_users(User.objects.filter(pk=user_pk))
417
+
418
+ # Paginate the user queryset first, then build rows only for the page —
419
+ # _build_member_rows fans out into membership/group lookups keyed on the
420
+ # page's user ids, so bounding the page bounds those queries too.
421
+ paginator = WorkspaceMemberCursorPagination()
422
+ page = paginator.paginate_queryset(qs, request, view=self)
423
+ rows = _build_member_rows(list(page) if page is not None else [])
424
+ data = WorkspaceMemberSerializer(rows, many=True).data
425
+ return paginator.get_paginated_response(data)
426
+
427
+
428
+ def _enforce_member_mutation_guard(
429
+ *,
430
+ membership: WorkspaceMembership,
431
+ actor_role: int | None,
432
+ actor_pk: Any,
433
+ target: Any,
434
+ new_role: Any,
435
+ new_status: Any,
436
+ ) -> None:
437
+ """Peer/higher-role guard for a member PATCH.
438
+
439
+ An actor may only change the role or the active status of a member whose
440
+ CURRENT role is strictly below their own (mirrors
441
+ access.views.ProjectMembershipViewSet.destroy). Without this an Admin could
442
+ deactivate a peer Admin, or — with two Owners — an Owner could be deactivated
443
+ by an Admin. REACTIVATION (status→ACTIVE) is gated too (#901): otherwise a
444
+ lower-role Admin could flip a deactivated Owner/peer-Admin back to ACTIVE,
445
+ restoring their login. Self-edits are exempt so an actor can still change
446
+ their own row (e.g. step down).
447
+ """
448
+ mutating = new_role is not None or new_status in (
449
+ MemberStatus.DEACTIVATED,
450
+ MemberStatus.ACTIVE,
451
+ )
452
+ if (
453
+ mutating
454
+ and target.pk != actor_pk
455
+ and actor_role is not None
456
+ and membership.role >= actor_role
457
+ ):
458
+ raise PermissionDenied("You can only modify members with a role lower than your own.")
459
+
460
+
461
+ def _apply_member_role_change(
462
+ *,
463
+ membership: WorkspaceMembership,
464
+ new_role: Any,
465
+ actor_role: int | None,
466
+ target: Any,
467
+ actor: Any = None,
468
+ ) -> int | None:
469
+ """Apply a role change with grant + strand guards; return the prior role if changed.
470
+
471
+ An actor may only grant a role STRICTLY BELOW their own (#1728). ``>`` let an
472
+ Admin mint a peer Admin (equal role); combined with the peer-guard (an actor
473
+ can't modify a member at or above their own role) that peer was then
474
+ unmanageable by either Admin. ``>=`` matches the project analog
475
+ (access.views.ProjectMembershipViewSet.create). Demoting an owner must not
476
+ strand the workspace.
477
+ """
478
+ if actor_role is not None and new_role >= actor_role:
479
+ raise PermissionDenied("You cannot assign a role equal to or higher than your own.")
480
+ if (
481
+ membership.role == WorkspaceRole.OWNER
482
+ and new_role < WorkspaceRole.OWNER
483
+ and services.would_strand_workspace(target.pk)
484
+ ):
485
+ raise ValidationError({"detail": "Cannot demote the last Owner of the workspace."})
486
+ if new_role == membership.role:
487
+ return None
488
+ role_changed_from = membership.role
489
+ membership.role = new_role
490
+ membership.role_changed_at = timezone.now()
491
+ if new_role < role_changed_from:
492
+ # A demoted sender can no longer vouch for invites at or above their new role
493
+ # (accept re-checks the ceiling anyway); revoke them now so they do not linger
494
+ # as unacceptable-but-resendable rows that block re-inviting the address
495
+ # (#4589). Below Admin they can vouch for nothing, so everything goes.
496
+ from trueppm_api.apps.access.services import revoke_pending_workspace_invites
497
+
498
+ revoke_pending_workspace_invites(
499
+ target,
500
+ actor=actor,
501
+ min_role=None if new_role < WorkspaceRole.ADMIN else new_role,
502
+ )
503
+ return role_changed_from
504
+
505
+
506
+ def _revoke_offboarded_credentials(target: Any, *, actor: Any = None) -> None:
507
+ """Cut off every long-lived credential of a member being off-boarded (#2832).
508
+
509
+ ``is_active = False`` alone terminates only the session/JWT path. A Personal
510
+ Access Token is a *separate*, long-lived bearer of the same authority, and its
511
+ ``expires_at`` is optional — a ``legacy:full`` PAT minted without one never
512
+ retires by itself. Off-boarding is the control an operator relies on to
513
+ terminate access, and the visible signal (the browser session dying) made it
514
+ look complete while ``curl -H "Authorization: Bearer <PAT>"`` kept returning
515
+ 200 at the member's full pre-departure permissions, indefinitely.
516
+
517
+ Revocation is **durable and one-way**: reactivating the member does not
518
+ un-revoke anything, and nothing else in the codebase clears ``revoked_at``.
519
+ That is deliberate. Tokens minted before a departure have been outside the
520
+ organization's control in the interim (a laptop, a CI secret store, a
521
+ personal script), so restoring them would restore credentials of unknown
522
+ provenance; a returning member mints new ones. This mirrors the password-reset
523
+ path (``core/password_reset.py``), which revokes for the same reason.
524
+
525
+ Scope: personal (``owner=target``) tokens and refresh tokens only, plus the
526
+ non-token durable grants ``target`` personally minted — their share links,
527
+ any pending workspace invite they sent (#4589), any git-automation webhook
528
+ secret they configured, and (#4461) any outgoing
529
+ webhook they registered **or have ever set the URL/secret on, or re-enabled
530
+ while disabled** (#2939; ``revoke_personal_durable_grants``; see
531
+ ``WebhookEditor``). For a webhook ``target`` only registered (and never
532
+ edited), this sweep is belt-and-suspenders: dispatch already stops
533
+ delivering to an off-boarded registrant because it re-checks live Admin+
534
+ membership on every event (#4325/#4330). It is **not** redundant for a
535
+ webhook ``target`` only edited or re-enabled, since created by someone
536
+ else — dispatch's membership re-check reads only ``created_by``, never the
537
+ editors set, so this sweep is the *only* control that reaches that case.
538
+ ``revoke_all_personal_access_tokens`` deliberately leaves project- and
539
+ program-scoped *tokens* alone — those are org assets minted *by* the
540
+ departing member for a team's integration, not their personal credentials,
541
+ and killing them would break a team's CI on an unrelated person's
542
+ off-boarding.
543
+
544
+ All three calls are plain DB updates and run **inside** the caller's
545
+ ``transaction.atomic()`` block, not under ``transaction.on_commit()``: they
546
+ must roll back with a failed deactivation, and there is no external
547
+ side effect that would justify deferring past commit. Deferring would open a
548
+ window in which the deactivation is committed but the credentials are still
549
+ live — precisely the state this function exists to prevent.
550
+
551
+ ``actor`` is the admin performing the off-boarding, recorded on the per-token
552
+ ``ApiTokenAuditEntry`` rows the revocation now writes (#2878). Off-boarding is one
553
+ of the two revocations an auditor actually asks about, and it was the one leaving
554
+ no trace at all.
555
+ """
556
+ from trueppm_api.apps.access.services import (
557
+ revoke_all_personal_access_tokens,
558
+ revoke_all_refresh_tokens,
559
+ revoke_personal_durable_grants,
560
+ )
561
+
562
+ revoke_all_refresh_tokens(target)
563
+ revoke_all_personal_access_tokens(target, actor=actor, reason="offboarding")
564
+ revoke_personal_durable_grants(target, actor=actor)
565
+
566
+
567
+ def _apply_member_status_change(
568
+ *, membership: WorkspaceMembership, new_status: Any, target: Any, actor: Any = None
569
+ ) -> Any:
570
+ """Apply a status change, guarding the last Owner and syncing the login flag.
571
+
572
+ Returns the **prior** status when it actually changed, else ``None`` — the caller
573
+ audits on that, so a PATCH that re-sends the current status writes no audit row
574
+ (#2867), matching how ``_apply_member_role_change`` gates ``MEMBER_ROLE_CHANGED``.
575
+ """
576
+ if (
577
+ new_status == MemberStatus.DEACTIVATED
578
+ and membership.role == WorkspaceRole.OWNER
579
+ and services.would_strand_workspace(target.pk)
580
+ ):
581
+ raise ValidationError({"detail": "Cannot deactivate the last Owner of the workspace."})
582
+ status_changed_from = membership.status if membership.status != new_status else None
583
+ membership.status = new_status
584
+ # Sync the Django account active flag so a deactivated member cannot
585
+ # authenticate, and reactivation restores login.
586
+ target.is_active = new_status != MemberStatus.DEACTIVATED
587
+ target.save(update_fields=["is_active"])
588
+ if new_status == MemberStatus.DEACTIVATED:
589
+ # Deactivation only — the reactivation branch deliberately has no inverse
590
+ # (see _revoke_offboarded_credentials): revocation is durable, and a
591
+ # returning member mints new tokens.
592
+ _revoke_offboarded_credentials(target, actor=actor)
593
+ return status_changed_from
594
+
595
+
596
+ def _apply_member_availability(*, membership: WorkspaceMembership, data: dict[str, Any]) -> None:
597
+ """Merge resource-availability baseline fields (#542) and validate the bounds.
598
+
599
+ Benign capacity metadata — it neither escalates a role nor gates login — so it
600
+ is deliberately NOT subject to the peer/higher-role guard: a resource manager
601
+ (Admin) declares availability for everyone, peers included. Presence of the key
602
+ (not its truthiness) drives the write so 0% and an explicit-null date clear are
603
+ honored, not skipped. The from<=to guard runs after the partial merge so a PATCH
604
+ that sets only one bound is still validated against the stored other.
605
+ """
606
+ if "availability_percent" in data:
607
+ membership.availability_percent = data["availability_percent"]
608
+ if "availability_effective_from" in data:
609
+ membership.availability_effective_from = data["availability_effective_from"]
610
+ if "availability_effective_to" in data:
611
+ membership.availability_effective_to = data["availability_effective_to"]
612
+ if "availability_notes" in data:
613
+ membership.availability_notes = data["availability_notes"]
614
+ if (
615
+ membership.availability_effective_from is not None
616
+ and membership.availability_effective_to is not None
617
+ and membership.availability_effective_from > membership.availability_effective_to
618
+ ):
619
+ raise ValidationError(
620
+ {"availability_effective_to": ("Must be on or after availability_effective_from.")}
621
+ )
622
+
623
+
624
+ class WorkspaceMemberDetailView(IdempotencyMixin, APIView):
625
+ """PATCH/DELETE /api/v1/workspace/members/{user_id}/ (#518). Admin only."""
626
+
627
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
628
+
629
+ def _get_user_or_404(self, user_id: str) -> Any:
630
+ return get_object_or_404(User, pk=user_id)
631
+
632
+ @extend_schema(
633
+ request=WorkspaceMemberUpdateSerializer, responses={200: WorkspaceMemberSerializer}
634
+ )
635
+ def patch(self, request: Request, user_id: str) -> Response:
636
+ target = self._get_user_or_404(user_id)
637
+ serializer = WorkspaceMemberUpdateSerializer(data=request.data)
638
+ serializer.is_valid(raise_exception=True)
639
+ data = serializer.validated_data
640
+
641
+ actor_role = _workspace_membership_role(request)
642
+ new_role = data.get("role")
643
+ new_status = data.get("status")
644
+
645
+ with transaction.atomic():
646
+ membership, _ = WorkspaceMembership.objects.select_for_update().get_or_create(
647
+ workspace=Workspace.load(),
648
+ user=target,
649
+ defaults={
650
+ "role": WorkspaceRole.OWNER if target.is_superuser else WorkspaceRole.MEMBER,
651
+ "status": MemberStatus.ACTIVE,
652
+ },
653
+ )
654
+
655
+ _enforce_member_mutation_guard(
656
+ membership=membership,
657
+ actor_role=actor_role,
658
+ actor_pk=request.user.pk,
659
+ target=target,
660
+ new_role=new_role,
661
+ new_status=new_status,
662
+ )
663
+
664
+ role_changed_from: int | None = None
665
+ if new_role is not None:
666
+ role_changed_from = _apply_member_role_change(
667
+ membership=membership,
668
+ new_role=new_role,
669
+ actor_role=actor_role,
670
+ target=target,
671
+ actor=request.user,
672
+ )
673
+
674
+ status_changed_from: Any = None
675
+ if new_status is not None:
676
+ status_changed_from = _apply_member_status_change(
677
+ membership=membership,
678
+ new_status=new_status,
679
+ target=target,
680
+ actor=request.user,
681
+ )
682
+
683
+ _apply_member_availability(membership=membership, data=data)
684
+
685
+ membership.save()
686
+
687
+ # Audit a real status change (#2867) inside the same transaction, so a
688
+ # rolled-back deactivation leaves no audit row. Recorded separately from
689
+ # the role change because a PATCH may carry either or both, and because
690
+ # this is the destructive one: since #2832 a deactivation revokes the
691
+ # target's refresh tokens and PATs durably, so a status-only PATCH is
692
+ # irreversible and previously recorded nothing at all — while DELETE,
693
+ # which does strictly less, recorded MEMBER_REMOVED. `credentials_revoked`
694
+ # is what a later support conversation needs: "account re-enabled,
695
+ # credentials NOT restored" is a real state and this is its evidence.
696
+ if status_changed_from is not None:
697
+ services.record_audit_event(
698
+ event_type=AuditEventType.MEMBER_STATUS_CHANGED,
699
+ actor=request.user,
700
+ target_type="member",
701
+ target_label=services._actor_label(target),
702
+ metadata={
703
+ "old_status": MemberStatus(status_changed_from).label,
704
+ "new_status": MemberStatus(membership.status).label,
705
+ "credentials_revoked": new_status == MemberStatus.DEACTIVATED,
706
+ },
707
+ )
708
+
709
+ # Audit a real role change (ADR-0157) inside the same transaction so
710
+ # it rolls back with a failed save. A status-only PATCH leaves
711
+ # role_changed_from None and records nothing here.
712
+ if role_changed_from is not None:
713
+ services.record_audit_event(
714
+ event_type=AuditEventType.MEMBER_ROLE_CHANGED,
715
+ actor=request.user,
716
+ target_type="member",
717
+ target_label=services._actor_label(target),
718
+ metadata={
719
+ "old_role": WorkspaceRole(role_changed_from).label,
720
+ "new_role": WorkspaceRole(membership.role).label,
721
+ },
722
+ )
723
+
724
+ users = list(WorkspaceMemberListView()._annotated_users(User.objects.filter(pk=target.pk)))
725
+ return Response(_build_member_rows(users)[0] if users else {})
726
+
727
+ @extend_schema(
728
+ responses={
729
+ 204: None,
730
+ 400: state_refusal_400(
731
+ "Removing this member would leave the workspace with no Owner. "
732
+ "Reachable only on an Owner target — a peer/higher-role removal is "
733
+ "refused earlier with 403. Verified against the strand guard in "
734
+ "``delete`` (#3319)."
735
+ ),
736
+ }
737
+ )
738
+ def delete(self, request: Request, user_id: str) -> Response:
739
+ target = self._get_user_or_404(user_id)
740
+ actor_role = _workspace_membership_role(request)
741
+ with transaction.atomic():
742
+ membership, _ = WorkspaceMembership.objects.select_for_update().get_or_create(
743
+ workspace=Workspace.load(),
744
+ user=target,
745
+ defaults={
746
+ # Materialize the implicit role so a deactivated superuser's
747
+ # row reflects their true OWNER tier (consistent with PATCH).
748
+ "role": WorkspaceRole.OWNER if target.is_superuser else WorkspaceRole.MEMBER,
749
+ "status": MemberStatus.ACTIVE,
750
+ },
751
+ )
752
+ # Peer/higher-role guard (mirrors PATCH and access.views): an actor may
753
+ # only deactivate a member whose role is strictly below their own, so an
754
+ # Admin cannot deactivate a peer Admin or an Owner. Self-removal is exempt.
755
+ # This runs BEFORE the strand check so a forbidden peer/owner removal
756
+ # returns 403, not a 400 about ownership.
757
+ if (
758
+ target.pk != request.user.pk
759
+ and actor_role is not None
760
+ and membership.role >= actor_role
761
+ ):
762
+ raise PermissionDenied(
763
+ "You can only remove members with a role lower than your own."
764
+ )
765
+ # Only an Owner removal can strand the workspace — gate the check on the
766
+ # target's role (mirrors PATCH) so deactivating a Member/Admin in an
767
+ # owner-less workspace is not spuriously blocked with a 400.
768
+ if membership.role == WorkspaceRole.OWNER and services.would_strand_workspace(
769
+ target.pk
770
+ ):
771
+ raise ValidationError({"detail": "Cannot remove the last Owner of the workspace."})
772
+ membership.status = MemberStatus.DEACTIVATED
773
+ membership.save()
774
+ target.is_active = False
775
+ target.save(update_fields=["is_active"])
776
+ # Removal is a deactivation here, so it must cut off the same long-lived
777
+ # credentials the PATCH deactivate path does (#2832) — otherwise "remove
778
+ # from workspace," the strongest off-boarding action the UI offers, would
779
+ # be the weaker of the two.
780
+ _revoke_offboarded_credentials(target, actor=request.user)
781
+
782
+ # Audit inside the same transaction so the row rolls back with a
783
+ # failed deactivation (ADR-0157). "Removed" here is a deactivation —
784
+ # the OSS workspace deactivates members rather than hard-deleting them.
785
+ services.record_audit_event(
786
+ event_type=AuditEventType.MEMBER_REMOVED,
787
+ actor=request.user,
788
+ target_type="member",
789
+ target_label=services._actor_label(target),
790
+ metadata={"role": WorkspaceRole(membership.role).label},
791
+ )
792
+ return Response(status=status.HTTP_204_NO_CONTENT)
793
+
794
+
795
+ class WorkspaceInviteListView(IdempotencyMixin, APIView):
796
+ """GET/POST /api/v1/workspace/invites/ (#518). Admin only."""
797
+
798
+ # #1724: pending invites expose PII (email / role / invited_by). IsWorkspaceAdmin
799
+ # would admit any implicit member on GET — gate reads at ADMIN too.
800
+ permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
801
+ # Standard page-number envelope so this bounded admin list returns the same
802
+ # {count,next,previous,results} shape as every other list endpoint — an
803
+ # integrator can't tell paginated from unpaginated when one list returns a
804
+ # bare array (#1355). Set as an attribute (not just instantiated below) so
805
+ # drf-spectacular documents the envelope in the OpenAPI schema too.
806
+ pagination_class = PageNumberPagination
807
+
808
+ @extend_schema(
809
+ parameters=[
810
+ OpenApiParameter(
811
+ name="status",
812
+ type=OpenApiTypes.STR,
813
+ location=OpenApiParameter.QUERY,
814
+ required=False,
815
+ enum=[*InviteStatus.values, "all"],
816
+ description=(
817
+ "Which invites to list. Defaults to `pending` — the working set the "
818
+ "Members page shows. Pass a terminal status (`accepted`, `revoked`, "
819
+ "`expired`, `failed`) or `all` to read the invite history, including "
820
+ "rows the default hides. Unknown value → 400."
821
+ ),
822
+ )
823
+ ],
824
+ responses={200: WorkspaceInviteSerializer(many=True)},
825
+ )
826
+ def get(self, request: Request) -> Response:
827
+ """List workspace invites, defaulting to the pending working set (#518, #2911).
828
+
829
+ ``?status=`` exists because the default was previously the *only* view: the
830
+ query was hard-filtered to PENDING, so accepting or revoking an invite erased
831
+ it from the one admin surface that showed it and nothing replaced it — an
832
+ admin could not answer "who was invited last month, and did they join?"
833
+ ``accepted_at`` and ``accepted_user`` were written by the accept path and
834
+ exposed by no endpoint at all.
835
+
836
+ The default stays ``pending`` rather than widening to everything: the Members
837
+ page's pending-invite section reads this endpoint, and a silent change to what
838
+ an unparameterized GET returns would put revoked and expired rows into a list
839
+ captioned "Pending invites". Reading the history is an explicit ask.
840
+
841
+ ``accepted_user`` joins ``invited_by`` in ``select_related`` because
842
+ ``_invite_dict`` renders initials for both, which needs the user row — without
843
+ it the history page costs one extra query per accepted invite.
844
+ """
845
+ requested = (request.query_params.get("status") or InviteStatus.PENDING).strip().lower()
846
+ if requested != "all" and requested not in InviteStatus.values:
847
+ raise ValidationError(
848
+ {"status": f"Must be one of {', '.join([*InviteStatus.values, 'all'])}."}
849
+ )
850
+
851
+ invites = WorkspaceInvite.objects.select_related("invited_by", "accepted_user")
852
+ if requested != "all":
853
+ invites = invites.filter(status=requested)
854
+ # Newest first, so the history reads like a log rather than in insertion order.
855
+ invites = invites.order_by("-created_at")
856
+ # Paginate the queryset first, then build dicts only for the page.
857
+ paginator = self.pagination_class()
858
+ page = paginator.paginate_queryset(invites, request, view=self)
859
+ rows = [_invite_dict(i) for i in (page if page is not None else invites)]
860
+ data = WorkspaceInviteSerializer(rows, many=True).data
861
+ return paginator.get_paginated_response(data)
862
+
863
+ @extend_schema(
864
+ request=WorkspaceInviteCreateSerializer, responses={201: WorkspaceInviteSerializer}
865
+ )
866
+ def post(self, request: Request) -> Response:
867
+ serializer = WorkspaceInviteCreateSerializer(data=request.data)
868
+ serializer.is_valid(raise_exception=True)
869
+ email = serializer.validated_data["email"]
870
+
871
+ # Actor-ceiling: an actor may only invite someone to a role STRICTLY
872
+ # BELOW their own — the same ``>=`` rule WorkspaceMemberDetailView.patch
873
+ # enforces on existing members (#1728), applied here so an invite can't
874
+ # be a back-door escalation. ``>`` would let an Admin invite a peer Admin
875
+ # (equal role); the accept path grants the invite's role verbatim, so the
876
+ # peer-Admin hole the PATCH gate closes would stay reachable via invites.
877
+ actor_role = _workspace_membership_role(request)
878
+ if actor_role is not None and serializer.validated_data["role"] >= actor_role:
879
+ raise PermissionDenied(
880
+ "You cannot invite someone to a role equal to or higher than your own."
881
+ )
882
+
883
+ if User.objects.filter(email__iexact=email, is_active=True).exists():
884
+ raise ValidationError({"email": "A member with this email already exists."})
885
+ if WorkspaceInvite.objects.filter(
886
+ email__iexact=email, status=InviteStatus.PENDING
887
+ ).exists():
888
+ raise ValidationError({"email": "An invite for this email is already pending."})
889
+
890
+ invite = services.create_invite(
891
+ workspace=Workspace.load(),
892
+ email=email,
893
+ role=serializer.validated_data["role"],
894
+ invited_by=request.user,
895
+ )
896
+ # #2911: an invite grants a role to an address nobody has verified yet, and
897
+ # the row it creates is no longer visible once accepted or revoked. Record
898
+ # the grant at the moment it is made, so "who offered this role, and which
899
+ # role" survives independently of the invite row's lifecycle.
900
+ services.record_audit_event(
901
+ event_type=AuditEventType.INVITE_SENT,
902
+ actor=request.user,
903
+ target_type="invite",
904
+ target_id=invite.pk,
905
+ target_label=invite.email,
906
+ metadata={"role": WorkspaceRole(invite.role).label},
907
+ )
908
+ return Response(_invite_dict(invite), status=status.HTTP_201_CREATED)
909
+
910
+
911
+ class WorkspaceInviteDetailView(IdempotencyMixin, APIView):
912
+ """DELETE /api/v1/workspace/invites/{id}/ — revoke a pending invite (#518)."""
913
+
914
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
915
+
916
+ def delete(self, request: Request, invite_id: str) -> Response:
917
+ invite = get_object_or_404(WorkspaceInvite, pk=invite_id)
918
+ if invite.status == InviteStatus.PENDING:
919
+ invite.status = InviteStatus.REVOKED
920
+ invite.email_pending = False
921
+ invite.email_token = ""
922
+ invite.save(update_fields=["status", "email_pending", "email_token"])
923
+ # Only on a real state change (#2911). A repeat DELETE of an already
924
+ # revoked or accepted invite is a no-op and must not write a second row
925
+ # claiming a revocation that did not occur — the guard above is what
926
+ # makes the audit count match the number of revocations.
927
+ services.record_audit_event(
928
+ event_type=AuditEventType.INVITE_REVOKED,
929
+ actor=request.user,
930
+ target_type="invite",
931
+ target_id=invite.pk,
932
+ target_label=invite.email,
933
+ metadata={
934
+ "role": WorkspaceRole(invite.role).label,
935
+ "invited_by": services._actor_label(invite.invited_by),
936
+ },
937
+ )
938
+ return Response(status=status.HTTP_204_NO_CONTENT)
939
+
940
+
941
+ class InviteAcceptView(IdempotencyMixin, APIView):
942
+ """POST /api/v1/workspace/invites/accept/ — accept an invite (public, token-auth).
943
+
944
+ No session required: the one-time token in the body is the credential. Errors
945
+ are generic to avoid token enumeration.
946
+ """
947
+
948
+ permission_classes = [AllowAny]
949
+ authentication_classes: list[Any] = []
950
+ throttle_classes = [InviteAcceptThrottle]
951
+
952
+ @extend_schema(request=InviteAcceptSerializer, responses={200: None})
953
+ def post(self, request: Request) -> Response:
954
+ serializer = InviteAcceptSerializer(data=request.data)
955
+ serializer.is_valid(raise_exception=True)
956
+ try:
957
+ user = services.accept_invite(
958
+ token=serializer.validated_data["token"],
959
+ username=serializer.validated_data.get("username", ""),
960
+ password=serializer.validated_data.get("password", ""),
961
+ )
962
+ except services.InviteError as exc:
963
+ # Return the 400 directly rather than raising: DRF's exception handler
964
+ # calls set_rollback(), which would revert the expired-status write
965
+ # accept_invite makes for an overdue token under ATOMIC_REQUESTS.
966
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
967
+ return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
968
+ return Response({"detail": "Invitation accepted.", "username": user.username})
969
+
970
+
971
+ class InviteResendThrottle(ScopedRateThrottle):
972
+ """Caps invite-resend frequency (ADR-0149) to bound email-bomb abuse.
973
+
974
+ Scoped per-admin via ``invite_resend`` (5/min). The bulk resend-all endpoint
975
+ is one request → one bucket hit, so it cannot be looped to exceed the cap.
976
+
977
+ ``ScopedRateThrottle`` normally reassigns ``self.scope = getattr(view,
978
+ self.scope_attr, None)`` on every ``allow_request`` and returns ``True`` when
979
+ that is falsy — so a ``scope`` class attribute declared here alone is silently
980
+ overwritten and the throttle becomes a no-op (#3598). Both views below
981
+ therefore also set ``throttle_scope`` (mirrors ``_SsoProviderWriteThrottle``,
982
+ #3552).
983
+ """
984
+
985
+ scope = "invite_resend"
986
+
987
+
988
+ class WorkspaceInviteResendView(IdempotencyMixin, APIView):
989
+ """POST /api/v1/workspace/invites/{id}/resend/ — re-queue one invite (ADR-0149).
990
+
991
+ Admin only. Re-issues a fresh token (the old email link stops working) and puts
992
+ the row back in the outbox for ``drain_invite_emails`` to pick up. Best-effort
993
+ dispatch → 202 ``{"queued": true}``. Only PENDING/FAILED invites are resendable;
994
+ accepted/revoked/expired return 409, as does an invite whose sender no longer holds a role
995
+ above the invited role or is no longer an active workspace Admin (#4589).
996
+ """
997
+
998
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
999
+ throttle_classes = [InviteResendThrottle]
1000
+ # ScopedRateThrottle reads the scope off the VIEW, not the throttle class — see
1001
+ # InviteResendThrottle's docstring (#3598).
1002
+ throttle_scope = "invite_resend"
1003
+
1004
+ @extend_schema(
1005
+ request=None,
1006
+ responses={
1007
+ 202: OpenApiResponse(description="Invite re-queued for sending."),
1008
+ 404: OpenApiResponse(description="Invite not found."),
1009
+ 409: OpenApiResponse(description="Invite is not in a resendable state."),
1010
+ },
1011
+ )
1012
+ def post(self, request: Request, invite_id: str) -> Response:
1013
+ if not WorkspaceInvite.objects.filter(pk=invite_id).exists():
1014
+ raise Http404("Invite not found.")
1015
+ try:
1016
+ invite = services.resend_invite(invite_id)
1017
+ except services.InviteSenderUnauthorizedError:
1018
+ return Response(
1019
+ {
1020
+ "detail": (
1021
+ "This invite cannot be resent because its sender no longer holds a "
1022
+ "role above the invited role (or is no longer an active "
1023
+ "workspace Admin). Revoke it and send a new invite instead."
1024
+ )
1025
+ },
1026
+ status=status.HTTP_409_CONFLICT,
1027
+ )
1028
+ if invite is None:
1029
+ # Exists (checked above) but not resendable → accepted/revoked/expired.
1030
+ return Response(
1031
+ {"detail": "This invite can no longer be resent."},
1032
+ status=status.HTTP_409_CONFLICT,
1033
+ )
1034
+ transaction.on_commit(services.drain_invite_emails_soon)
1035
+ return Response({"queued": True}, status=status.HTTP_202_ACCEPTED)
1036
+
1037
+
1038
+ class WorkspaceInviteResendAllView(IdempotencyMixin, APIView):
1039
+ """POST /api/v1/workspace/invites/resend-all/ — re-queue every pending invite.
1040
+
1041
+ Admin only. One transaction, one throttle bucket — emails everyone with a
1042
+ resendable invite at once. Returns 202 ``{"requeued": n}`` (n excludes invites
1043
+ already in flight).
1044
+ """
1045
+
1046
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
1047
+ throttle_classes = [InviteResendThrottle]
1048
+ # ScopedRateThrottle reads the scope off the VIEW, not the throttle class — see
1049
+ # InviteResendThrottle's docstring (#3598).
1050
+ throttle_scope = "invite_resend"
1051
+
1052
+ @extend_schema(
1053
+ request=None,
1054
+ responses={202: OpenApiResponse(description="Pending invites re-queued.")},
1055
+ )
1056
+ def post(self, request: Request) -> Response:
1057
+ count = services.resend_all_pending(Workspace.load())
1058
+ if count:
1059
+ transaction.on_commit(services.drain_invite_emails_soon)
1060
+ return Response({"requeued": count}, status=status.HTTP_202_ACCEPTED)
1061
+
1062
+
1063
+ # Magic-byte signatures for the raster logo allowlist (ADR-0149). We sniff the
1064
+ # leading bytes rather than trust the multipart Content-Type, and accept no SVG —
1065
+ # a publicly-served SVG is a stored-XSS vector.
1066
+ _LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2 MB
1067
+ _LOGO_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
1068
+ _LOGO_MIME_PNG = "image/png"
1069
+ _LOGO_MIME_WEBP = "image/webp"
1070
+
1071
+
1072
+ def _sniff_logo_mime(head: bytes) -> str | None:
1073
+ """Return the canonical MIME for a PNG/WebP byte head, or None if neither."""
1074
+ if head.startswith(_LOGO_PNG_MAGIC):
1075
+ return _LOGO_MIME_PNG
1076
+ # WebP: 'RIFF' <4-byte size> 'WEBP'.
1077
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
1078
+ return _LOGO_MIME_WEBP
1079
+ return None
1080
+
1081
+
1082
+ # drf-spectacular's ``auth`` is typed ``Sequence[str]`` but emits each entry
1083
+ # verbatim as a security requirement, so requirement objects are what is correct.
1084
+ class LogoUploadErrorSerializer(serializers.Serializer): # type: ignore[type-arg]
1085
+ """Body of a refused logo upload: the message keyed by the ``file`` field."""
1086
+
1087
+ file = serializers.CharField()
1088
+
1089
+
1090
+ _LOGO_WRITE_AUTH: Any = [
1091
+ {"personalApiTokenAuth": []},
1092
+ {"jwtAuth": []},
1093
+ {"cookieAuth": []},
1094
+ ]
1095
+
1096
+
1097
+ class WorkspaceLogoView(IdempotencyMixin, APIView):
1098
+ """Workspace logo: POST upload, DELETE clear (admin), GET serve (public).
1099
+
1100
+ GET is ``AllowAny`` — the logo is non-sensitive branding shown on public share
1101
+ pages, so serving it without auth avoids a JWT-in-``<img>`` problem. Write paths
1102
+ require workspace ADMIN+. Raster only (PNG/WebP); content type is decided by a
1103
+ magic-byte sniff, not the client-declared Content-Type (ADR-0149).
1104
+
1105
+ Carries ``IdempotencyMixin`` like every other unsafe workspace view: the mixin
1106
+ only intercepts unsafe methods bearing an Idempotency-Key, so the public GET
1107
+ surface is unaffected.
1108
+ """
1109
+
1110
+ parser_classes = [MultiPartParser, FormParser]
1111
+
1112
+ def get_permissions(self) -> list[Any]:
1113
+ if self.request.method == "GET":
1114
+ return [AllowAny()]
1115
+ return [IsAuthenticated(), IsWorkspaceAdmin()]
1116
+
1117
+ def get_authenticators(self) -> list[Any]:
1118
+ # Public GET must not 401 on an absent/invalid token; only the write paths
1119
+ # authenticate. Mirrors InviteAcceptView's anonymous surface.
1120
+ if getattr(self.request, "method", None) == "GET":
1121
+ return []
1122
+ return super().get_authenticators()
1123
+
1124
+ @extend_schema(
1125
+ responses={
1126
+ 200: OpenApiResponse(description="Logo image bytes."),
1127
+ 404: OpenApiResponse(description="No logo set."),
1128
+ },
1129
+ description="Serve the workspace logo (public). Returns 404 when unset.",
1130
+ )
1131
+ def get(self, request: Request) -> FileResponse:
1132
+ workspace = Workspace.load()
1133
+ if not workspace.logo:
1134
+ raise Http404("No workspace logo set.")
1135
+ content_type = workspace.logo_mime or _LOGO_MIME_PNG
1136
+ response = FileResponse(workspace.logo.open("rb"), content_type=content_type)
1137
+ # Branding is non-sensitive but still user-uploaded: force inline rendering,
1138
+ # block content-type sniffing, and let caches hold it (busted via ?v= on the
1139
+ # serializer URL when the logo changes).
1140
+ response["Content-Disposition"] = "inline"
1141
+ response["X-Content-Type-Options"] = "nosniff"
1142
+ response["Cache-Control"] = "public, max-age=86400"
1143
+ return response
1144
+
1145
+ @extend_schema(
1146
+ request={
1147
+ "multipart/form-data": {
1148
+ "type": "object",
1149
+ "properties": {"file": {"type": "string", "format": "binary"}},
1150
+ }
1151
+ },
1152
+ responses={
1153
+ 200: WorkspaceSettingsSerializer,
1154
+ 401: OpenApiResponse(description="Not authenticated."),
1155
+ 403: OpenApiResponse(description="Caller is not a workspace admin."),
1156
+ 413: OpenApiResponse(
1157
+ response=LogoUploadErrorSerializer,
1158
+ description="Logo is larger than 2 MB.",
1159
+ ),
1160
+ 415: OpenApiResponse(
1161
+ response=LogoUploadErrorSerializer,
1162
+ description="Logo is not a PNG or WebP image.",
1163
+ ),
1164
+ },
1165
+ # The write methods authenticate, but the view's GET-only anonymous
1166
+ # carve-out makes the generator see no authenticators and emit
1167
+ # ``security: []``, which would advertise these writes as public.
1168
+ auth=_LOGO_WRITE_AUTH,
1169
+ )
1170
+ def post(self, request: Request) -> Response:
1171
+ upload = request.FILES.get("file")
1172
+ if upload is None:
1173
+ raise ValidationError({"file": "No file was uploaded."})
1174
+ if upload.size > _LOGO_MAX_BYTES:
1175
+ return Response(
1176
+ {"file": "Logo must be 2 MB or smaller."},
1177
+ status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
1178
+ )
1179
+ head = upload.read(32)
1180
+ upload.seek(0)
1181
+ mime = _sniff_logo_mime(head)
1182
+ if mime is None:
1183
+ return Response(
1184
+ {"file": "Logo must be a PNG or WebP image."},
1185
+ status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
1186
+ )
1187
+ workspace = services.set_workspace_logo(file=upload, mime=mime)
1188
+ return Response(WorkspaceSettingsSerializer(workspace).data)
1189
+
1190
+ @extend_schema(
1191
+ responses={
1192
+ 200: WorkspaceSettingsSerializer,
1193
+ 401: OpenApiResponse(description="Not authenticated."),
1194
+ 403: OpenApiResponse(description="Caller is not a workspace admin."),
1195
+ },
1196
+ auth=_LOGO_WRITE_AUTH,
1197
+ )
1198
+ def delete(self, request: Request) -> Response:
1199
+ workspace = services.clear_workspace_logo()
1200
+ return Response(WorkspaceSettingsSerializer(workspace).data)
1201
+
1202
+
1203
+ # ---------------------------------------------------------------------------
1204
+ # #519 — Groups & teams
1205
+ # ---------------------------------------------------------------------------
1206
+
1207
+
1208
+ class GroupListView(IdempotencyMixin, APIView):
1209
+ """GET/POST /api/v1/workspace/groups/ (#519). Read and create: admin only."""
1210
+
1211
+ # #1724: group rosters are org structure — IsWorkspaceAdmin would leak them to
1212
+ # any implicit member on GET. Gate reads at ADMIN, matching create.
1213
+ permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
1214
+ # Standard page-number envelope so this admin list matches every other list
1215
+ # endpoint's {count,next,previous,results} shape (#1355).
1216
+ pagination_class = PageNumberPagination
1217
+
1218
+ @extend_schema(responses={200: GroupSerializer(many=True)})
1219
+ def get(self, request: Request) -> Response:
1220
+ groups = Group.objects.filter(is_deleted=False).select_related("lead")
1221
+ # Paginate the queryset first, then build dicts only for the page —
1222
+ # _build_group_dicts fans out into membership/project lookups.
1223
+ paginator = self.pagination_class()
1224
+ page = paginator.paginate_queryset(groups, request, view=self)
1225
+ rows = _build_group_dicts(list(page) if page is not None else list(groups))
1226
+ data = GroupSerializer(rows, many=True).data
1227
+ return paginator.get_paginated_response(data)
1228
+
1229
+ @extend_schema(request=GroupWriteSerializer, responses={201: GroupSerializer})
1230
+ def post(self, request: Request) -> Response:
1231
+ serializer = GroupWriteSerializer(data=request.data)
1232
+ serializer.is_valid(raise_exception=True)
1233
+ group = serializer.save(workspace=Workspace.load())
1234
+ return Response(_build_group_dict(group), status=status.HTTP_201_CREATED)
1235
+
1236
+
1237
+ class GroupDetailView(IdempotencyMixin, APIView):
1238
+ """GET/PATCH/DELETE /api/v1/workspace/groups/{id}/ (#519)."""
1239
+
1240
+ # #1724: a group's roster is org structure — IsWorkspaceAdmin would leak it to
1241
+ # any implicit member on GET. Gate reads at ADMIN, matching patch/delete.
1242
+ permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
1243
+
1244
+ def _get_group_or_404(self, group_id: str) -> Group:
1245
+ return get_object_or_404(Group, pk=group_id, is_deleted=False)
1246
+
1247
+ @extend_schema(responses={200: GroupSerializer})
1248
+ def get(self, request: Request, group_id: str) -> Response:
1249
+ return Response(_build_group_dict(self._get_group_or_404(group_id)))
1250
+
1251
+ @extend_schema(request=GroupWriteSerializer, responses={200: GroupSerializer})
1252
+ def patch(self, request: Request, group_id: str) -> Response:
1253
+ group = self._get_group_or_404(group_id)
1254
+ serializer = GroupWriteSerializer(group, data=request.data, partial=True)
1255
+ serializer.is_valid(raise_exception=True)
1256
+ serializer.save()
1257
+ return Response(_build_group_dict(group))
1258
+
1259
+ def delete(self, request: Request, group_id: str) -> Response:
1260
+ group = self._get_group_or_404(group_id)
1261
+ with transaction.atomic():
1262
+ group.soft_delete()
1263
+ services.reconcile_group_access(group.pk)
1264
+ return Response(status=status.HTTP_204_NO_CONTENT)
1265
+
1266
+
1267
+ class GroupMemberView(IdempotencyMixin, APIView):
1268
+ """POST/DELETE /api/v1/workspace/groups/{id}/members/[{user_id}/] (#519)."""
1269
+
1270
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
1271
+
1272
+ def _get_group_or_404(self, group_id: str) -> Group:
1273
+ return get_object_or_404(Group, pk=group_id, is_deleted=False)
1274
+
1275
+ @extend_schema(request=GroupMemberAddSerializer, responses={201: GroupSerializer})
1276
+ def post(self, request: Request, group_id: str) -> Response:
1277
+ group = self._get_group_or_404(group_id)
1278
+ serializer = GroupMemberAddSerializer(data=request.data)
1279
+ serializer.is_valid(raise_exception=True)
1280
+ target = get_object_or_404(User, pk=serializer.validated_data["user"])
1281
+ with transaction.atomic():
1282
+ membership = (
1283
+ GroupMembership.objects.select_for_update().filter(group=group, user=target).first()
1284
+ )
1285
+ if membership is None:
1286
+ GroupMembership.objects.create(group=group, user=target)
1287
+ elif membership.is_deleted:
1288
+ membership.is_deleted = False
1289
+ membership.deleted_version = None
1290
+ membership.save()
1291
+ services.reconcile_group_access(group.pk)
1292
+ return Response(_build_group_dict(group), status=status.HTTP_201_CREATED)
1293
+
1294
+ def delete(self, request: Request, group_id: str, user_id: str) -> Response:
1295
+ group = self._get_group_or_404(group_id)
1296
+ with transaction.atomic():
1297
+ membership = (
1298
+ GroupMembership.objects.select_for_update()
1299
+ .filter(group=group, user_id=user_id, is_deleted=False)
1300
+ .first()
1301
+ )
1302
+ if membership is not None:
1303
+ membership.soft_delete()
1304
+ services.reconcile_group_access(group.pk)
1305
+ return Response(status=status.HTTP_204_NO_CONTENT)
1306
+
1307
+
1308
+ class GroupProjectView(IdempotencyMixin, APIView):
1309
+ """POST/DELETE /api/v1/workspace/groups/{id}/projects/[{project_id}/] (#519).
1310
+
1311
+ Linking a group to a project confers the chosen role on every group member
1312
+ via the cascade (ADR-0087 §5).
1313
+ """
1314
+
1315
+ permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
1316
+ # Exempt from the archived-write invariant (#3414). This is a WORKSPACE surface —
1317
+ # mounted under `/workspace/groups/`, gated by `IsWorkspaceAdmin`, and the resource
1318
+ # written is the group's access list; the project is a link target. Archiving freezes
1319
+ # a plan's contents, not who may read it, and an admin must be able to add or remove
1320
+ # an archived project from a group while reorganizing access.
1321
+ #
1322
+ # Note the asymmetry with `ProjectMembershipViewSet`, which DOES carry
1323
+ # `IsProjectNotArchived`: the two surfaces disagree about whether access
1324
+ # administration is a project write. Reconciling them is a product decision, not a
1325
+ # side effect of this invariant — see the MR for #3414.
1326
+ archived_write_exempt = (
1327
+ "workspace-level group access administration: the write is the group's access "
1328
+ "list and archiving freezes a plan's contents, not who may read it"
1329
+ )
1330
+
1331
+ def _get_group_or_404(self, group_id: str) -> Group:
1332
+ return get_object_or_404(Group, pk=group_id, is_deleted=False)
1333
+
1334
+ @extend_schema(request=GroupProjectWriteSerializer, responses={201: GroupSerializer})
1335
+ def post(self, request: Request, group_id: str) -> Response:
1336
+ from trueppm_api.apps.projects.models import Project
1337
+
1338
+ group = self._get_group_or_404(group_id)
1339
+ serializer = GroupProjectWriteSerializer(data=request.data)
1340
+ serializer.is_valid(raise_exception=True)
1341
+ project = get_object_or_404(
1342
+ Project, pk=serializer.validated_data["project"], is_deleted=False
1343
+ )
1344
+ with transaction.atomic():
1345
+ GroupProject.objects.update_or_create(
1346
+ group=group,
1347
+ project=project,
1348
+ defaults={
1349
+ "role": serializer.validated_data["role"],
1350
+ # IsAuthenticated guarantees a concrete User, not AnonymousUser.
1351
+ "added_by": request.user, # type: ignore[misc]
1352
+ },
1353
+ )
1354
+ services.reconcile_group_access(group.pk)
1355
+ return Response(_build_group_dict(group), status=status.HTTP_201_CREATED)
1356
+
1357
+ def delete(self, request: Request, group_id: str, project_id: str) -> Response:
1358
+ group = self._get_group_or_404(group_id)
1359
+ with transaction.atomic():
1360
+ GroupProject.objects.filter(group=group, project_id=project_id).delete()
1361
+ services.reconcile_group_access(group.pk)
1362
+ return Response(status=status.HTTP_204_NO_CONTENT)
1363
+
1364
+
1365
+ # ---------------------------------------------------------------------------
1366
+ # #641 — Workspace lifecycle: transfer ownership / export (ADR-0174)
1367
+ # (Workspace hard-delete is ``WorkspaceSettingsView.delete`` above.)
1368
+ # ---------------------------------------------------------------------------
1369
+
1370
+
1371
+ class TransferOwnershipView(IdempotencyMixin, APIView):
1372
+ """POST /api/v1/workspace/transfer-ownership/ — hand the workspace to a member.
1373
+
1374
+ Owner only. The target must already be an active member; the current owner is
1375
+ demoted to Admin (#641). Mirrors the project transfer action.
1376
+ """
1377
+
1378
+ permission_classes = [IsAuthenticated, IsWorkspaceOwner]
1379
+
1380
+ @extend_schema(
1381
+ request=TransferOwnershipSerializer,
1382
+ responses={200: OpenApiResponse(description="Ownership transferred.")},
1383
+ )
1384
+ def post(self, request: Request) -> Response:
1385
+ serializer = TransferOwnershipSerializer(data=request.data)
1386
+ serializer.is_valid(raise_exception=True)
1387
+ new_owner_id = serializer.validated_data["new_owner_user_id"]
1388
+
1389
+ try:
1390
+ new_owner = User.objects.get(pk=new_owner_id)
1391
+ except User.DoesNotExist:
1392
+ return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
1393
+
1394
+ try:
1395
+ services.transfer_workspace_ownership(new_owner=new_owner, actor=request.user)
1396
+ except DjangoValidationError as exc:
1397
+ detail = exc.messages[0] if exc.messages else str(exc)
1398
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
1399
+ return Response({"detail": detail}, status=status.HTTP_400_BAD_REQUEST)
1400
+
1401
+ return Response(
1402
+ {"detail": "Workspace ownership transferred.", "new_owner_user_id": new_owner_id}
1403
+ )
1404
+
1405
+
1406
+ class WorkspaceExportView(IdempotencyMixin, APIView):
1407
+ """GET lists recent export jobs; POST queues a new full export (#641). Owner only."""
1408
+
1409
+ permission_classes = [IsAuthenticated, IsWorkspaceOwner]
1410
+ # Standard page-number envelope so the job list matches every other list
1411
+ # endpoint's {count,next,previous,results} shape — the previous bare ``[:20]``
1412
+ # slice gave an integrator no way to tell a truncated list from a complete one
1413
+ # (#1355). Bounded in practice by the export-retention purge.
1414
+ pagination_class = PageNumberPagination
1415
+
1416
+ @extend_schema(responses={200: WorkspaceExportJobSerializer(many=True)})
1417
+ def get(self, request: Request) -> Response:
1418
+ jobs = WorkspaceExportJob.objects.all()
1419
+ paginator = self.pagination_class()
1420
+ page = paginator.paginate_queryset(jobs, request, view=self)
1421
+ data = WorkspaceExportJobSerializer(page if page is not None else jobs, many=True).data
1422
+ return paginator.get_paginated_response(data)
1423
+
1424
+ @extend_schema(request=None, responses={202: WorkspaceExportJobSerializer})
1425
+ def post(self, request: Request) -> Response:
1426
+ job = services.enqueue_workspace_export(requested_by=request.user)
1427
+ return Response(WorkspaceExportJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
1428
+
1429
+
1430
+ class WorkspaceExportDetailView(APIView):
1431
+ """GET /api/v1/workspace/export/{job_id}/ — poll an export's status (#641). Owner only."""
1432
+
1433
+ permission_classes = [IsAuthenticated, IsWorkspaceOwner]
1434
+
1435
+ @extend_schema(responses={200: WorkspaceExportJobSerializer})
1436
+ def get(self, request: Request, job_id: str) -> Response:
1437
+ job = get_object_or_404(WorkspaceExportJob, pk=job_id)
1438
+ return Response(WorkspaceExportJobSerializer(job).data)
1439
+
1440
+
1441
+ class WorkspaceExportDownloadView(APIView):
1442
+ """GET /api/v1/workspace/export/{job_id}/download/ — stream the archive (#641).
1443
+
1444
+ Owner only and authenticated — the archive contains every project's data, so it
1445
+ is never served from a raw, unauthenticated storage URL. ``409`` if not ready,
1446
+ ``410 Gone`` once the link has expired.
1447
+ """
1448
+
1449
+ permission_classes = [IsAuthenticated, IsWorkspaceOwner]
1450
+
1451
+ @extend_schema(
1452
+ responses={
1453
+ 200: OpenApiResponse(description="The archive bytes (``application/gzip``)."),
1454
+ 409: OpenApiResponse(description="Export is not ready yet (still queued/running)."),
1455
+ 410: OpenApiResponse(description="This export has expired. Request a new one."),
1456
+ },
1457
+ )
1458
+ def get(self, request: Request, job_id: str) -> Any:
1459
+ job = get_object_or_404(WorkspaceExportJob, pk=job_id)
1460
+ return stream_export_job_or_error(
1461
+ job,
1462
+ success_status=ExportJobStatus.SUCCESS,
1463
+ filename=f"workspace-export-{job.id}.tar.gz",
1464
+ )
1465
+
1466
+
1467
+ class AuditEventCursorPagination(CursorPagination):
1468
+ """Stable, depth-independent pagination for the unbounded audit log (ADR-0157).
1469
+
1470
+ Cursor (not page-number) pagination because the log grows without bound and is
1471
+ appended to concurrently — a cursor keyed on ``created_at`` stays correct under
1472
+ inserts and is O(1) regardless of how deep the reader scrolls.
1473
+ """
1474
+
1475
+ ordering = "-created_at"
1476
+ page_size = 50
1477
+ page_size_query_param = "page_size"
1478
+ max_page_size = 200
1479
+
1480
+
1481
+ def _parse_audit_bound(value: str) -> Any:
1482
+ """Parse a ``since``/``until`` filter into an aware datetime, or raise 400.
1483
+
1484
+ Accepts an ISO-8601 datetime or a bare date (interpreted as midnight). A naive
1485
+ value is made timezone-aware in the active timezone.
1486
+ """
1487
+ import datetime as _datetime
1488
+
1489
+ parsed = parse_datetime(value)
1490
+ if parsed is None:
1491
+ as_date = parse_date(value)
1492
+ if as_date is None:
1493
+ raise ValidationError({"detail": f"Invalid date/time value: {value!r}."})
1494
+ parsed = _datetime.datetime.combine(as_date, _datetime.time.min)
1495
+ if timezone.is_naive(parsed):
1496
+ parsed = timezone.make_aware(parsed)
1497
+ return parsed
1498
+
1499
+
1500
+ class WorkspaceAuditEventListView(APIView):
1501
+ """GET /api/v1/workspace/audit-events/ — operational audit log (ADR-0157, #859).
1502
+
1503
+ Owner/Admin only (``IsWorkspaceAuditViewer``). Cursor-paginated, newest first.
1504
+ Optional filters, all ANDed: ``?event_type=`` (an ``AuditEventType`` value),
1505
+ ``?actor=`` (user id), ``?since=`` / ``?until=`` (ISO-8601 on ``created_at``).
1506
+ """
1507
+
1508
+ permission_classes = [IsAuthenticated, IsWorkspaceAuditViewer]
1509
+
1510
+ @extend_schema(
1511
+ parameters=[
1512
+ OpenApiParameter(
1513
+ "event_type",
1514
+ str,
1515
+ description="Filter to a single AuditEventType value.",
1516
+ ),
1517
+ OpenApiParameter("actor", int, description="Filter by actor user id."),
1518
+ OpenApiParameter(
1519
+ "since", str, description="Only events at/after this ISO-8601 instant."
1520
+ ),
1521
+ OpenApiParameter(
1522
+ "until", str, description="Only events at/before this ISO-8601 instant."
1523
+ ),
1524
+ ],
1525
+ # The handler paginates (``AuditEventCursorPagination.get_paginated_response``),
1526
+ # so the 200 body is a cursor envelope, NOT a bare array. drf-spectacular's
1527
+ # auto-wrap only fires for a view with a class-level ``pagination_class``;
1528
+ # this is a plain APIView that instantiates its paginator inline, so the
1529
+ # heuristic never saw it and ``AuditEventSerializer(many=True)`` published a
1530
+ # ``type: array`` no response can satisfy — a generated SDK iterating the
1531
+ # result broke on the first call (#2583). Declare the envelope explicitly.
1532
+ # Cursor pagination carries no ``count`` (that is the point of a cursor).
1533
+ responses={
1534
+ 200: inline_serializer(
1535
+ name="AuditEventCursorPage",
1536
+ fields={
1537
+ "next": serializers.URLField(allow_null=True),
1538
+ "previous": serializers.URLField(allow_null=True),
1539
+ "results": AuditEventSerializer(many=True),
1540
+ },
1541
+ )
1542
+ },
1543
+ # Pinned: drf-spectacular derives the operationId's `_list`/`_retrieve`
1544
+ # suffix from whether the 200 is a ``many=True`` serializer, so replacing
1545
+ # the (wrong) array with the envelope silently renamed
1546
+ # ``v1_workspace_audit_events_list`` to ``…_retrieve``. operationId is the
1547
+ # method name in a generated client, so that rename would break every
1548
+ # existing caller — trading the bug this fixes for the same bug in a new
1549
+ # place. Also keeps the summary honest: this lists events, it does not
1550
+ # retrieve one.
1551
+ operation_id="v1_workspace_audit_events_list",
1552
+ summary="List workspace audit events",
1553
+ )
1554
+ def get(self, request: Request) -> Response:
1555
+ qs = AuditEvent.objects.select_related("actor").all()
1556
+
1557
+ event_type = request.query_params.get("event_type")
1558
+ if event_type:
1559
+ if event_type not in AuditEventType.values:
1560
+ raise ValidationError({"detail": f"Unknown event_type: {event_type!r}."})
1561
+ qs = qs.filter(event_type=event_type)
1562
+
1563
+ actor = request.query_params.get("actor")
1564
+ if actor:
1565
+ try:
1566
+ actor_id = int(actor)
1567
+ except (TypeError, ValueError) as exc:
1568
+ raise ValidationError({"detail": "actor must be an integer user id."}) from exc
1569
+ qs = qs.filter(actor_id=actor_id)
1570
+
1571
+ since = request.query_params.get("since")
1572
+ if since:
1573
+ qs = qs.filter(created_at__gte=_parse_audit_bound(since))
1574
+ until = request.query_params.get("until")
1575
+ if until:
1576
+ qs = qs.filter(created_at__lte=_parse_audit_bound(until))
1577
+
1578
+ paginator = AuditEventCursorPagination()
1579
+ page = paginator.paginate_queryset(qs, request, view=self)
1580
+ data = AuditEventSerializer(page, many=True).data
1581
+ return paginator.get_paginated_response(data)