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,967 @@
1
+ """Service-layer helpers for the workspace app (ADR-0087).
2
+
3
+ Three multi-row transactions live here:
4
+
5
+ - :func:`reconcile_group_access` — the Group→project access **cascade**. It
6
+ materializes, updates, and removes ``ProjectMembership`` rows so that every
7
+ (group member × linked project) pair has the project ``Role`` the group
8
+ confers. Reconciliation is idempotent and recomputed from scratch per affected
9
+ pair, so it is safe to call after any group/member/link/role change.
10
+
11
+ - :func:`accept_invite` — atomically provisions (or links) a user account and
12
+ creates the ``WorkspaceMembership`` from a token-bearing invite.
13
+
14
+ - :func:`create_invite` — issues a pending invite with a one-time token.
15
+
16
+ The owner-counting helpers back the last-owner guard: a workspace must never be
17
+ left without at least one active owner (explicit OWNER membership or an active
18
+ Django superuser).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import logging
25
+ import secrets
26
+ import uuid
27
+ from typing import Any
28
+
29
+ from django.contrib.auth import get_user_model
30
+ from django.contrib.auth.password_validation import validate_password
31
+
32
+ # ``ValidationError`` here is Django's (used for the ownership-transfer guards and
33
+ # the password-policy check) — distinct from DRF's ValidationError raised in views.
34
+ from django.core.exceptions import ValidationError
35
+ from django.db import IntegrityError, transaction
36
+ from django.utils import timezone
37
+
38
+ from trueppm_api.apps.access.models import ProjectMembership
39
+ from trueppm_api.apps.workspace.models import (
40
+ AuditEvent,
41
+ AuditEventType,
42
+ ExportJobStatus,
43
+ GroupMembership,
44
+ GroupProject,
45
+ InviteStatus,
46
+ MemberStatus,
47
+ Workspace,
48
+ WorkspaceExportJob,
49
+ WorkspaceInvite,
50
+ WorkspaceMembership,
51
+ WorkspaceRole,
52
+ )
53
+ from trueppm_api.apps.workspace.signals import audit_event_created
54
+ from trueppm_api.core.extension_signals import dispatch_extension_signal
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ _PK = uuid.UUID | str
59
+
60
+
61
+ class InviteSenderUnauthorizedError(Exception):
62
+ """Resend refused: the invite's sender no longer holds a role above the invited role (#4589)."""
63
+
64
+
65
+ class InviteError(Exception):
66
+ """Raised when an invite cannot be accepted (invalid/expired token, etc.).
67
+
68
+ The message is deliberately generic for token failures to avoid leaking
69
+ whether a given token exists (enumeration).
70
+ """
71
+
72
+
73
+ # Deliberately generic so an invalid/expired/consumed token never reveals whether
74
+ # a given token exists (enumeration). Shared by every failure path in the accept
75
+ # flow so the three sites cannot drift apart (S1192).
76
+ _INVALID_INVITE_MESSAGE = "This invitation link is invalid or has expired."
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Owner counting / last-owner guard
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ def workspace_owner_user_ids(exclude_user_id: _PK | None = None) -> set[Any]:
85
+ """Return the set of user ids that currently hold workspace-owner authority.
86
+
87
+ An owner is an active explicit OWNER ``WorkspaceMembership`` **or** an active
88
+ Django superuser with no explicit (overriding) membership row. ``exclude_user_id``
89
+ drops a candidate so callers can ask "who would remain if I changed this user?".
90
+ """
91
+ User = get_user_model()
92
+ owners: set[Any] = set(
93
+ WorkspaceMembership.objects.filter(
94
+ role=WorkspaceRole.OWNER,
95
+ status=MemberStatus.ACTIVE,
96
+ is_deleted=False,
97
+ ).values_list("user_id", flat=True)
98
+ )
99
+ # Active superusers are implicit owners unless an explicit row overrides them
100
+ # (that row's role is already reflected above if it is OWNER).
101
+ explicit_ids = set(
102
+ WorkspaceMembership.objects.filter(is_deleted=False).values_list("user_id", flat=True)
103
+ )
104
+ for uid in User.objects.filter(is_superuser=True, is_active=True).values_list("id", flat=True):
105
+ if uid not in explicit_ids:
106
+ owners.add(uid)
107
+ if exclude_user_id is not None:
108
+ owners.discard(exclude_user_id)
109
+ return owners
110
+
111
+
112
+ def would_strand_workspace(user_id: _PK) -> bool:
113
+ """True if removing/demoting ``user_id`` would leave the workspace ownerless."""
114
+ return len(workspace_owner_user_ids(exclude_user_id=user_id)) == 0
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Operational audit log (ADR-0157, #859)
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def _actor_label(actor: Any) -> str:
123
+ """Human-readable display for an audit actor, captured at event time.
124
+
125
+ Denormalized onto the row so the log stays readable after the user is deleted
126
+ (the ``actor`` FK then resolves to NULL). Prefers a full name, then email,
127
+ then username; an absent actor (system action) yields an empty string.
128
+ """
129
+ if actor is None or not getattr(actor, "pk", None):
130
+ return ""
131
+ full_name = ""
132
+ get_full_name = getattr(actor, "get_full_name", None)
133
+ if callable(get_full_name):
134
+ full_name = (get_full_name() or "").strip()
135
+ label = full_name or getattr(actor, "email", "") or actor.get_username()
136
+ return label[:255]
137
+
138
+
139
+ def record_audit_event(
140
+ *,
141
+ event_type: str,
142
+ actor: Any,
143
+ target_type: str = "",
144
+ target_id: Any = None,
145
+ target_label: str = "",
146
+ metadata: dict[str, Any] | None = None,
147
+ ) -> AuditEvent:
148
+ """Write one operational ``AuditEvent`` and fan it out to enterprise receivers.
149
+
150
+ The single choke point for the OSS audit log (ADR-0157): every emission site
151
+ calls this rather than ``AuditEvent.objects.create`` directly. The row is
152
+ written synchronously inside the caller's transaction (so it rolls back with a
153
+ failed action — the log never claims an action that did not happen), and the
154
+ ``audit_event_created`` signal is fired from ``transaction.on_commit`` via
155
+ ``dispatch_extension_signal`` so a raising enterprise receiver is contained
156
+ and logged, and can never break the OSS write path. OSS itself connects no
157
+ receiver.
158
+ """
159
+ event = AuditEvent.objects.create(
160
+ actor=actor if (actor is not None and getattr(actor, "pk", None)) else None,
161
+ actor_label=_actor_label(actor),
162
+ event_type=event_type,
163
+ target_type=target_type,
164
+ target_id=target_id,
165
+ target_label=(target_label or "")[:512],
166
+ metadata=metadata or {},
167
+ )
168
+ transaction.on_commit(
169
+ lambda: dispatch_extension_signal(audit_event_created, sender=AuditEvent, audit_event=event)
170
+ )
171
+ return event
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Lifecycle: transfer ownership / export / delete (ADR-0174, #641)
176
+ # ---------------------------------------------------------------------------
177
+
178
+
179
+ def transfer_workspace_ownership(*, new_owner: Any, actor: Any) -> WorkspaceMembership:
180
+ """Atomically promote a workspace member to OWNER, demote the actor to ADMIN.
181
+
182
+ Mirrors :func:`access.services.transfer_project_ownership`. The target must
183
+ already be an **active** workspace member — requiring an explicit membership
184
+ forces an invite step and keeps a clean "joined → promoted" audit sequence.
185
+
186
+ The actor's explicit OWNER row (if any) is demoted to ADMIN. If the actor is
187
+ an *implicit* owner (an active superuser with no explicit row), nothing is
188
+ demoted — there is no row to change and the superuser bootstrap is intentional.
189
+ Promoting the target always leaves at least one owner, so this never strands
190
+ the workspace.
191
+
192
+ Args:
193
+ new_owner: The user who will become OWNER.
194
+ actor: The current owner initiating the transfer.
195
+
196
+ Returns:
197
+ The new owner's ``WorkspaceMembership`` row.
198
+
199
+ Raises:
200
+ ValidationError: if ``new_owner`` is the actor, or has no active membership.
201
+ """
202
+ if new_owner.pk == actor.pk:
203
+ raise ValidationError("You already own this workspace.")
204
+
205
+ # Defense in depth — the view gates with ``IsWorkspaceOwner``, but the service
206
+ # is reusable (management commands, signals, future endpoints). Without this an
207
+ # actor who is not actually an owner would promote the target while no one is
208
+ # demoted, minting an extra owner. Assert authority before any state change.
209
+ if actor.pk not in workspace_owner_user_ids():
210
+ raise ValidationError("Only an existing workspace Owner can transfer ownership.")
211
+
212
+ with transaction.atomic():
213
+ try:
214
+ target = WorkspaceMembership.objects.select_for_update().get(
215
+ user=new_owner,
216
+ is_deleted=False,
217
+ )
218
+ except WorkspaceMembership.DoesNotExist as exc:
219
+ raise ValidationError(
220
+ "The new owner must already be an active workspace member."
221
+ ) from exc
222
+ if target.status == MemberStatus.DEACTIVATED:
223
+ raise ValidationError("The new owner must be an active workspace member.")
224
+
225
+ # Promote the target first so the workspace is never momentarily ownerless.
226
+ if target.role != WorkspaceRole.OWNER:
227
+ target.role = WorkspaceRole.OWNER
228
+ target.role_changed_at = timezone.now()
229
+ target.save(update_fields=["role", "role_changed_at"])
230
+
231
+ # Demote the actor's explicit OWNER row, if they have one.
232
+ actor_row = (
233
+ WorkspaceMembership.objects.select_for_update()
234
+ .filter(user=actor, is_deleted=False)
235
+ .first()
236
+ )
237
+ if actor_row is not None and actor_row.role == WorkspaceRole.OWNER:
238
+ actor_row.role = WorkspaceRole.ADMIN
239
+ actor_row.role_changed_at = timezone.now()
240
+ actor_row.save(update_fields=["role", "role_changed_at"])
241
+ # The demoted Owner can no longer vouch for invites at Admin or above.
242
+ from trueppm_api.apps.access.services import revoke_pending_workspace_invites
243
+
244
+ revoke_pending_workspace_invites(actor, actor=actor, min_role=WorkspaceRole.ADMIN)
245
+
246
+ # Audit inside the same transaction so the row rolls back with a failed
247
+ # transfer. target_id is left null — a User PK is an int, not a UUID — and
248
+ # the new owner's id is carried in metadata instead (ADR-0157).
249
+ record_audit_event(
250
+ event_type=AuditEventType.OWNERSHIP_TRANSFERRED,
251
+ actor=actor,
252
+ target_type="member",
253
+ target_label=_actor_label(new_owner),
254
+ metadata={"new_owner_user_id": new_owner.pk},
255
+ )
256
+
257
+ return target
258
+
259
+
260
+ def enqueue_workspace_export(*, requested_by: Any) -> WorkspaceExportJob:
261
+ """Create an export job row and best-effort dispatch the Celery task (ADR-0174).
262
+
263
+ Follows the transactional-outbox convention (ADR-0080): the row commits with
264
+ the request; ``.delay()`` is attempted in ``transaction.on_commit`` and broker
265
+ errors are swallowed because ``drain_workspace_exports`` re-dispatches stuck
266
+ ``pending`` rows. ``.delay()`` is only ever called from here and the drain.
267
+
268
+ De-dupes in-flight work: a full-workspace archive is expensive, so if an export
269
+ is already ``pending``/``running`` the existing job is returned rather than
270
+ queuing a second build (also bounds an owner triggering repeated exports).
271
+ """
272
+ existing = WorkspaceExportJob.objects.filter(
273
+ status__in=[ExportJobStatus.PENDING, ExportJobStatus.RUNNING]
274
+ ).first()
275
+ if existing is not None:
276
+ return existing
277
+
278
+ job = WorkspaceExportJob.objects.create(requested_by=requested_by)
279
+
280
+ # Audit only the path that actually queues a new export — the dedupe branch
281
+ # above returns an in-flight job without minting work, so it is not an event.
282
+ record_audit_event(
283
+ event_type=AuditEventType.EXPORT_TRIGGERED,
284
+ actor=requested_by,
285
+ target_type="workspace_export",
286
+ target_id=job.id,
287
+ target_label="Workspace export",
288
+ )
289
+
290
+ def _dispatch() -> None:
291
+ from trueppm_api.apps.workspace.tasks import run_workspace_export
292
+
293
+ try:
294
+ run_workspace_export.delay(str(job.id))
295
+ except Exception: # pragma: no cover - broker-down path, drain recovers
296
+ logger.warning(
297
+ "broker unavailable; drain_workspace_exports will pick up export %s", job.id
298
+ )
299
+
300
+ transaction.on_commit(_dispatch)
301
+ return job
302
+
303
+
304
+ def purge_workspace() -> None:
305
+ """Hard-delete every workspace-scoped row and the singleton itself (ADR-0174).
306
+
307
+ Because ``Workspace.load()`` re-materializes the singleton on next access,
308
+ deleting the row is a factory reset: the next request gets a fresh default
309
+ workspace. Deletes run in FK-safe order in one transaction — ``PROTECT``
310
+ membership rows (project/program) and ``Project``-referenced ``Calendar`` rows
311
+ must be removed before their referents, so a naive ``Workspace.delete()``
312
+ cascade is not enough.
313
+ """
314
+ # Imported lazily: this is the only place the workspace service reaches across
315
+ # into the projects/access/resources models, and doing it at module load would
316
+ # widen the import graph for every workspace request.
317
+ from trueppm_api.apps.access.models import ProgramMembership
318
+ from trueppm_api.apps.projects.models import Calendar, Program, Project
319
+ from trueppm_api.apps.resources.models import Resource, Skill
320
+
321
+ with transaction.atomic():
322
+ # Group access cascade first (GroupProject/GroupMembership → Group).
323
+ GroupProject.objects.all().delete()
324
+ GroupMembership.objects.all().delete()
325
+ from trueppm_api.apps.workspace.models import Group
326
+
327
+ Group.objects.all().delete()
328
+ WorkspaceInvite.objects.all().delete()
329
+ WorkspaceMembership.objects.all().delete()
330
+ # PROTECT memberships must precede their parents.
331
+ ProjectMembership.objects.all().delete()
332
+ ProgramMembership.objects.all().delete()
333
+ # Projects cascade tasks/deps/baselines/sprints/risks/attachments/etc.
334
+ Project.objects.all().delete()
335
+ Program.objects.all().delete()
336
+ # Workspace-global flat tables (no project/workspace FK).
337
+ Resource.objects.all().delete()
338
+ Skill.objects.all().delete()
339
+ # Calendars are PROTECT-referenced by Project; safe now projects are gone.
340
+ Calendar.objects.all().delete()
341
+ WorkspaceExportJob.objects.all().delete()
342
+ Workspace.objects.all().delete()
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Group → project access cascade (ADR-0087 §5)
347
+ # ---------------------------------------------------------------------------
348
+
349
+
350
+ def _build_confer_map(
351
+ user_ids: set[Any], project_ids: set[Any]
352
+ ) -> dict[tuple[Any, Any], tuple[int, Any]]:
353
+ """Bulk-compute the conferred project ``Role`` per (user, project) pair.
354
+
355
+ Returns ``{(user_id, project_id): (role, source_group_id)}`` for the highest
356
+ role any active group the user belongs to confers on that project. Replaces a
357
+ per-pair query with two bulk reads (GroupProject + GroupMembership), so a
358
+ cascade over M members × P projects costs O(1) queries, not O(M·P).
359
+ """
360
+ gp_rows = list(
361
+ GroupProject.objects.filter(
362
+ project_id__in=project_ids, group__is_deleted=False
363
+ ).values_list("project_id", "group_id", "role")
364
+ )
365
+ involved_group_ids = {gid for _, gid, _ in gp_rows}
366
+ members_by_group: dict[Any, set[Any]] = {}
367
+ for gid, uid in GroupMembership.objects.filter(
368
+ group_id__in=involved_group_ids, is_deleted=False, user_id__in=user_ids
369
+ ).values_list("group_id", "user_id"):
370
+ members_by_group.setdefault(gid, set()).add(uid)
371
+
372
+ confer: dict[tuple[Any, Any], tuple[int, Any]] = {}
373
+ for project_id, gid, role in gp_rows:
374
+ for uid in members_by_group.get(gid, ()):
375
+ key = (uid, project_id)
376
+ current = confer.get(key)
377
+ if current is None or role > current[0]:
378
+ confer[key] = (int(role), gid)
379
+ return confer
380
+
381
+
382
+ def _sync_existing_membership(
383
+ existing: ProjectMembership,
384
+ confer_role: int,
385
+ src_group_id: Any,
386
+ now: Any,
387
+ user_id: Any,
388
+ project_id: Any,
389
+ events: list[tuple[Any, str, dict[str, Any]]],
390
+ ) -> None:
391
+ """Role-sync an existing *active* group-derived row to the conferred role.
392
+
393
+ Only broadcasts ``member_role_changed`` when the role actually moved — a pure
394
+ source_group reattribution (e.g. one of two overlapping groups removed, role
395
+ unchanged) is invisible to board consumers.
396
+ """
397
+ changed: list[str] = []
398
+ role_changed = existing.role != confer_role
399
+ if role_changed:
400
+ existing.role = confer_role
401
+ existing.role_changed_at = now
402
+ changed += ["role", "role_changed_at"]
403
+ if existing.source_group_id != src_group_id:
404
+ existing.source_group_id = src_group_id
405
+ changed.append("source_group")
406
+ if not changed:
407
+ return
408
+ existing.save(update_fields=changed)
409
+ if role_changed:
410
+ events.append(
411
+ (
412
+ project_id,
413
+ "member_role_changed",
414
+ {
415
+ "membership_id": str(existing.pk),
416
+ "user_id": str(user_id),
417
+ "role": existing.role,
418
+ },
419
+ )
420
+ )
421
+
422
+
423
+ def _reconcile_pair(
424
+ user_id: Any,
425
+ project_id: Any,
426
+ confer_entry: tuple[int, Any] | None,
427
+ existing: ProjectMembership | None,
428
+ now: Any,
429
+ events: list[tuple[Any, str, dict[str, Any]]],
430
+ ) -> None:
431
+ """Reconcile one (user, project) pair from pre-fetched confer + existing data.
432
+
433
+ A direct membership (``source_group IS NULL``) always wins and is never
434
+ touched. Otherwise the group-derived row is created, role-synced, resurrected,
435
+ or removed to match the conferred role. Board events are appended to ``events``.
436
+ """
437
+ confer_role, src_group_id = confer_entry if confer_entry is not None else (None, None)
438
+
439
+ # Direct grant wins — group reconciliation must not alter or revoke it.
440
+ if existing is not None and not existing.is_deleted and existing.source_group_id is None:
441
+ return
442
+
443
+ if confer_role is None:
444
+ if (
445
+ existing is not None
446
+ and not existing.is_deleted
447
+ and existing.source_group_id is not None
448
+ ):
449
+ existing.soft_delete()
450
+ events.append(
451
+ (
452
+ project_id,
453
+ "member_removed",
454
+ {"membership_id": str(existing.pk), "user_id": str(user_id)},
455
+ )
456
+ )
457
+ return
458
+
459
+ if existing is None:
460
+ pm = ProjectMembership.objects.create(
461
+ project_id=project_id,
462
+ user_id=user_id,
463
+ role=confer_role,
464
+ source_group_id=src_group_id,
465
+ )
466
+ events.append(
467
+ (
468
+ project_id,
469
+ "member_added",
470
+ {"membership_id": str(pm.pk), "user_id": str(user_id), "role": confer_role},
471
+ )
472
+ )
473
+ elif existing.is_deleted:
474
+ # Resurrect a previously-removed row as group-derived (unique_together on
475
+ # (project, user) means we cannot insert a second row).
476
+ existing.is_deleted = False
477
+ existing.deleted_version = None
478
+ existing.role = confer_role
479
+ existing.source_group_id = src_group_id
480
+ existing.role_changed_at = now
481
+ existing.save()
482
+ events.append(
483
+ (
484
+ project_id,
485
+ "member_added",
486
+ {"membership_id": str(existing.pk), "user_id": str(user_id), "role": confer_role},
487
+ )
488
+ )
489
+ else:
490
+ _sync_existing_membership(
491
+ existing, confer_role, src_group_id, now, user_id, project_id, events
492
+ )
493
+
494
+
495
+ @transaction.atomic
496
+ def reconcile_group_access(group_id: Any) -> None:
497
+ """Reconcile all affected (member × project) pairs for a group.
498
+
499
+ Considers the union of the group's current desired pairs (active members ×
500
+ linked projects) and any pair still attributed to this group in
501
+ ``ProjectMembership.source_group`` (so removals are caught when a member or
502
+ project link is dropped, or the group is soft-deleted). Reconciliation is
503
+ global across groups (overlaps resolve to the highest conferred role) and is
504
+ driven by bulk reads — three queries regardless of group size.
505
+ """
506
+ member_ids = list(
507
+ GroupMembership.objects.filter(group_id=group_id, is_deleted=False).values_list(
508
+ "user_id", flat=True
509
+ )
510
+ )
511
+ project_ids = list(
512
+ GroupProject.objects.filter(group_id=group_id).values_list("project_id", flat=True)
513
+ )
514
+ desired = {(u, p) for u in member_ids for p in project_ids}
515
+ attributed = set(
516
+ ProjectMembership.objects.filter(source_group_id=group_id, is_deleted=False).values_list(
517
+ "user_id", "project_id"
518
+ )
519
+ )
520
+ pairs = desired | attributed
521
+ if not pairs:
522
+ return
523
+
524
+ user_ids = {u for u, _ in pairs}
525
+ proj_ids = {p for _, p in pairs}
526
+ confer = _build_confer_map(user_ids, proj_ids)
527
+ # Lock the candidate membership rows once (one query), then reconcile in memory.
528
+ # order_by("pk"): access.views' `create` / `partial_update` lock ProjectMembership
529
+ # rows in ascending-pk order to avoid deadlocking against each other on a
530
+ # reversed actor/target pair (#3438) — this bulk lock joins the same
531
+ # convention rather than leaving Postgres to pick its own row order.
532
+ existing = {
533
+ (m.user_id, m.project_id): m
534
+ for m in ProjectMembership.objects.select_for_update()
535
+ .filter(user_id__in=user_ids, project_id__in=proj_ids)
536
+ .order_by("pk")
537
+ }
538
+
539
+ now = timezone.now()
540
+ events: list[tuple[Any, str, dict[str, Any]]] = []
541
+ for user_id, project_id in pairs:
542
+ _reconcile_pair(
543
+ user_id,
544
+ project_id,
545
+ confer.get((user_id, project_id)),
546
+ existing.get((user_id, project_id)),
547
+ now,
548
+ events,
549
+ )
550
+ _broadcast_membership_events(events)
551
+
552
+
553
+ def _broadcast_membership_events(events: list[tuple[Any, str, dict[str, Any]]]) -> None:
554
+ """Defer best-effort board broadcasts for each cascade-affected project."""
555
+ if not events:
556
+ return
557
+
558
+ def _send() -> None:
559
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
560
+
561
+ for project_id, event_type, payload in events:
562
+ broadcast_board_event(str(project_id), event_type, payload)
563
+
564
+ transaction.on_commit(_send)
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Invites
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def create_invite(
573
+ *,
574
+ workspace: Workspace,
575
+ email: str,
576
+ role: int,
577
+ invited_by: Any,
578
+ ) -> WorkspaceInvite:
579
+ """Create a pending invite with a one-time token and queue its email.
580
+
581
+ The raw token is generated here, hashed for the durable credential, and kept
582
+ in ``email_token`` only until the drain sends the link (ADR-0087 §4).
583
+
584
+ The drain is nudged from ``on_commit`` so the email leaves in seconds rather
585
+ than on the next Beat tick (#4191). The nudge is only a latency optimization:
586
+ the row is the durable record and the periodic drain still sends it if the
587
+ broker drops the nudge. Deferring to commit means a rolled-back create never
588
+ enqueues anything, and the drain — the only sender — is lock-serialized, so the
589
+ nudge cannot cause a second send.
590
+ """
591
+ raw_token = secrets.token_urlsafe(32)
592
+ token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
593
+ invite = WorkspaceInvite.objects.create(
594
+ workspace=workspace,
595
+ email=email,
596
+ role=role,
597
+ token_hash=token_hash,
598
+ email_token=raw_token,
599
+ invited_by=invited_by,
600
+ expires_at=WorkspaceInvite.default_expiry(),
601
+ email_pending=True,
602
+ )
603
+ # Registered after the INSERT: outside an atomic block on_commit runs at once,
604
+ # and the nudged drain must be able to see the row.
605
+ transaction.on_commit(drain_invite_emails_soon)
606
+ return invite
607
+
608
+
609
+ # Statuses an admin may resend (ADR-0149). PENDING covers a lost/bounced live
610
+ # invite; FAILED is a send-exhausted invite — resend is its recovery path. An
611
+ # ACCEPTED/REVOKED/EXPIRED invite is intentionally *not* revivable.
612
+ RESENDABLE_INVITE_STATUSES = (InviteStatus.PENDING, InviteStatus.FAILED)
613
+
614
+ _RESEND_UPDATE_FIELDS = [
615
+ "token_hash",
616
+ "email_token",
617
+ "status",
618
+ "expires_at",
619
+ "email_pending",
620
+ "email_sent_at",
621
+ "email_failed_at",
622
+ "email_attempts",
623
+ ]
624
+
625
+
626
+ def _reissue_invite_token(invite: WorkspaceInvite) -> None:
627
+ """Reset an (unsaved, locked) invite back into the drain-eligible queued shape.
628
+
629
+ Regenerates the one-time token — so any link in a previously-sent email stops
630
+ working, which is the correct posture for a re-issue (ADR-0149) — and clears the
631
+ outbox columns (``email_pending`` on, ``email_sent_at``/``email_failed_at`` off,
632
+ attempts zeroed) so the existing ``drain_invite_emails`` picks the row up on its
633
+ next 30 s tick (or sooner, when the view nudges it on commit).
634
+ """
635
+ raw_token = secrets.token_urlsafe(32)
636
+ invite.token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
637
+ invite.email_token = raw_token
638
+ invite.status = InviteStatus.PENDING
639
+ invite.expires_at = WorkspaceInvite.default_expiry()
640
+ invite.email_pending = True
641
+ invite.email_sent_at = None
642
+ invite.email_failed_at = None
643
+ invite.email_attempts = 0
644
+
645
+
646
+ def _is_in_flight(invite: WorkspaceInvite) -> bool:
647
+ """True if a send is already queued and not yet sent (idempotent-resend guard).
648
+
649
+ A double-click on Resend (or a created-but-not-yet-drained invite) must not
650
+ re-issue a token the drain is about to send with the *current* token, so resend
651
+ is a no-op for these rows.
652
+ """
653
+ return invite.email_pending and invite.email_sent_at is None
654
+
655
+
656
+ def drain_invite_emails_soon() -> None:
657
+ """Best-effort nudge the invite-email drain to run now instead of next tick.
658
+
659
+ Every queued invite (a fresh create or a resend) is drain-eligible as soon as it
660
+ commits — the drain has no age floor (#4191) — so this shortens the wait from the
661
+ 30 s Beat cadence to "right after commit". Broker errors are swallowed: the
662
+ periodic ``drain_invite_emails`` is the durability guarantee, this is only an
663
+ optimization (ADR-0149). If a drain already holds the singleton lock the nudged
664
+ run skips, and the row goes on the next tick. Call inside
665
+ ``transaction.on_commit``.
666
+ """
667
+ from trueppm_api.apps.workspace.tasks import drain_invite_emails
668
+
669
+ try:
670
+ drain_invite_emails.delay()
671
+ except Exception: # pragma: no cover - broker-down path, periodic drain recovers
672
+ logger.warning("broker unavailable; periodic drain_invite_emails will send queued invites")
673
+
674
+
675
+ def resend_invite(invite_id: _PK) -> WorkspaceInvite | None:
676
+ """Re-queue one resendable invite's email with a fresh token (ADR-0149).
677
+
678
+ Returns the invite on success, the unchanged invite when it is already in
679
+ flight (idempotent no-op), or ``None`` when it does not exist or is not
680
+ resendable (accepted/revoked/expired) — the view maps ``None`` to 409/404.
681
+ Locks the row so a concurrent double-submit re-issues at most once.
682
+ """
683
+ with transaction.atomic():
684
+ invite = WorkspaceInvite.objects.select_for_update().filter(pk=invite_id).first()
685
+ if invite is None or invite.status not in RESENDABLE_INVITE_STATUSES:
686
+ return None
687
+ # Never silently re-attribute: a fresh token for a sender who could not
688
+ # accept their own invite today would launder the grant (#4589).
689
+ if not _inviter_still_authorized(invite):
690
+ raise InviteSenderUnauthorizedError
691
+ if _is_in_flight(invite):
692
+ return invite
693
+ _reissue_invite_token(invite)
694
+ invite.save(update_fields=_RESEND_UPDATE_FIELDS)
695
+ return invite
696
+
697
+
698
+ def resend_all_pending(workspace: Workspace) -> int:
699
+ """Re-queue every resendable invite in one transaction; return the count.
700
+
701
+ Bundled into a single transaction so "Resend all" is one throttle bucket hit
702
+ (ADR-0149) and can never email-bomb regardless of how many invites are pending.
703
+ In-flight rows are skipped, so the returned count reflects only rows actually
704
+ re-issued.
705
+ """
706
+ count = 0
707
+ with transaction.atomic():
708
+ invites = list(
709
+ WorkspaceInvite.objects.select_for_update().filter(
710
+ workspace=workspace,
711
+ status__in=RESENDABLE_INVITE_STATUSES,
712
+ )
713
+ )
714
+ for invite in invites:
715
+ if _is_in_flight(invite) or not _inviter_still_authorized(invite):
716
+ continue
717
+ _reissue_invite_token(invite)
718
+ invite.save(update_fields=_RESEND_UPDATE_FIELDS)
719
+ count += 1
720
+ return count
721
+
722
+
723
+ # ---------------------------------------------------------------------------
724
+ # Workspace logo (ADR-0149, #969)
725
+ # ---------------------------------------------------------------------------
726
+
727
+
728
+ def _delete_storage_file_on_commit(name: str) -> None:
729
+ """Best-effort delete of a storage blob after the current transaction commits.
730
+
731
+ Deferred to ``on_commit`` so a rolled-back logo write never orphans the *new*
732
+ file by deleting the *old* one prematurely; storage drift (already gone) is
733
+ logged, not raised.
734
+ """
735
+ if not name:
736
+ return
737
+
738
+ def _delete() -> None:
739
+ from django.core.files.storage import default_storage
740
+
741
+ try:
742
+ default_storage.delete(name)
743
+ except OSError: # pragma: no cover - storage drift, nothing to clean up
744
+ logger.warning("could not delete old workspace logo file %s", name)
745
+
746
+ transaction.on_commit(_delete)
747
+
748
+
749
+ def set_workspace_logo(*, file: Any, mime: str) -> Workspace:
750
+ """Store a new workspace logo, deleting the previously-stored file on commit.
751
+
752
+ The validated content type is pinned in ``logo_mime`` so the public serve
753
+ endpoint sets Content-Type from a trusted column rather than re-sniffing. The
754
+ UUID-prefixed ``upload_to`` guarantees the new key differs from the old, so the
755
+ old blob is always safe to delete once the row commits (ADR-0149).
756
+ """
757
+ ws = Workspace.load()
758
+ old_name = ws.logo.name
759
+ ws.logo = file
760
+ ws.logo_mime = mime
761
+ ws.save(update_fields=["logo", "logo_mime", "updated_at"])
762
+ if old_name and old_name != ws.logo.name:
763
+ _delete_storage_file_on_commit(old_name)
764
+ return ws
765
+
766
+
767
+ def clear_workspace_logo() -> Workspace:
768
+ """Remove the workspace logo and delete its stored file on commit (ADR-0149)."""
769
+ ws = Workspace.load()
770
+ old_name = ws.logo.name
771
+ if old_name:
772
+ ws.logo = ""
773
+ ws.logo_mime = ""
774
+ ws.save(update_fields=["logo", "logo_mime", "updated_at"])
775
+ _delete_storage_file_on_commit(old_name)
776
+ return ws
777
+
778
+
779
+ def _load_pending_invite(token: str) -> WorkspaceInvite:
780
+ """Resolve a token to a pending, unexpired invite; raise ``InviteError`` otherwise.
781
+
782
+ A lapsed (still-PENDING but expired) invite is marked ``EXPIRED`` via a
783
+ standalone autocommit ``update`` — not inside a provisioning ``atomic`` block —
784
+ so the state change survives the ``InviteError`` raise instead of being rolled
785
+ back and leaving the invite pending.
786
+ """
787
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
788
+ try:
789
+ invite = WorkspaceInvite.objects.get(token_hash=token_hash)
790
+ except WorkspaceInvite.DoesNotExist as exc:
791
+ raise InviteError(_INVALID_INVITE_MESSAGE) from exc
792
+
793
+ if invite.status != InviteStatus.PENDING or invite.is_expired:
794
+ if invite.status == InviteStatus.PENDING and invite.is_expired:
795
+ WorkspaceInvite.objects.filter(pk=invite.pk).update(
796
+ status=InviteStatus.EXPIRED, email_pending=False, email_token=""
797
+ )
798
+ raise InviteError(_INVALID_INVITE_MESSAGE)
799
+ return invite
800
+
801
+
802
+ def _inviter_still_authorized(invite: WorkspaceInvite) -> bool:
803
+ """True only if the inviter is an active user who still resolves to workspace Admin+.
804
+
805
+ Catches an account deactivated outside the app (``is_active=False`` directly),
806
+ a deleted inviter (``invited_by`` is SET_NULL), a demoted or deactivated
807
+ membership, and an invite whose role is no longer strictly below the sender's.
808
+ Uses ``workspace_role_for_user`` so the answer matches RBAC (including the
809
+ superuser-with-no-row bootstrap OWNER).
810
+ """
811
+ from trueppm_api.apps.workspace.permissions import workspace_role_for_user
812
+
813
+ inviter = invite.invited_by
814
+ if inviter is None or not inviter.is_active:
815
+ return False
816
+ role = workspace_role_for_user(inviter)
817
+ # Mirror the create-time ceiling: an invite may only grant a role strictly below
818
+ # the sender's *current* role, so a demoted Owner cannot still mint Admins.
819
+ return role is not None and role >= WorkspaceRole.ADMIN and invite.role < role
820
+
821
+
822
+ def _create_invited_user(*, username: str, email: str, password: str) -> Any:
823
+ """Mint a new account for an invitee, enforcing the configured password policy.
824
+
825
+ ``create_user`` hashes but does NOT run ``AUTH_PASSWORD_VALIDATORS``, and the
826
+ serializer accepts any non-empty string — so the policy is enforced here before
827
+ minting the account. Without this an unauthenticated invitee could set a
828
+ trivially-guessable password.
829
+ """
830
+ User = get_user_model()
831
+ try:
832
+ validate_password(password)
833
+ except ValidationError as exc:
834
+ raise InviteError(" ".join(exc.messages)) from exc
835
+ try:
836
+ return User.objects.create_user(username=username, email=email, password=password)
837
+ except IntegrityError as exc:
838
+ raise InviteError("That username is already taken.") from exc
839
+
840
+
841
+ def _link_existing_membership(
842
+ membership: WorkspaceMembership, invite: WorkspaceInvite, user: Any, now: Any
843
+ ) -> None:
844
+ """Elevate (never reactivate) an existing membership when replaying an invite.
845
+
846
+ A deactivated member must NOT be silently reactivated (or re-elevated) by
847
+ replaying a pending invite — that would let an admin's deactivation be undone
848
+ without admin consent. Reactivation is an explicit admin action; refuse the
849
+ invite instead. Otherwise the role is raised (never lowered) to the invite's.
850
+ """
851
+ if membership.status == MemberStatus.DEACTIVATED:
852
+ raise InviteError(
853
+ "Membership is deactivated; an admin must reactivate it before this invite can be used."
854
+ )
855
+ if invite.role <= membership.role:
856
+ return
857
+ old_role = membership.role
858
+ membership.role = invite.role
859
+ membership.role_changed_at = now
860
+ membership.save()
861
+ record_audit_event(
862
+ event_type=AuditEventType.MEMBER_ROLE_CHANGED,
863
+ actor=user,
864
+ target_type="member",
865
+ target_label=_actor_label(user),
866
+ metadata={
867
+ "old_role": WorkspaceRole(old_role).label,
868
+ "new_role": WorkspaceRole(membership.role).label,
869
+ "source": "invite",
870
+ },
871
+ )
872
+
873
+
874
+ def accept_invite(*, token: str, username: str = "", password: str = "") -> Any:
875
+ """Provision (or link) a user and create their workspace membership.
876
+
877
+ Looks the invite up by token hash, validates it is pending and unexpired,
878
+ then atomically links an existing user matching the invite email or creates
879
+ a new account from ``username``/``password``. Idempotent under a double-submit:
880
+ the status flips ``pending → accepted`` under a row lock.
881
+
882
+ Raises:
883
+ InviteError: invalid/expired token, or account-creation conflict.
884
+ """
885
+ invite = _load_pending_invite(token)
886
+
887
+ User = get_user_model()
888
+ user = User.objects.filter(email__iexact=invite.email).first()
889
+ if user is None and (not username or not password):
890
+ raise InviteError("A username and password are required to accept this invitation.")
891
+
892
+ with transaction.atomic():
893
+ # Re-fetch under a row lock to make a double-submit create exactly one
894
+ # membership (the second caller sees status != PENDING and bails).
895
+ invite = WorkspaceInvite.objects.select_for_update().get(pk=invite.pk)
896
+ if invite.status != InviteStatus.PENDING:
897
+ raise InviteError(_INVALID_INVITE_MESSAGE)
898
+ # Defense in depth behind the revocation sweep (#4589): the grant is only as
899
+ # good as its minter's standing *now*. Same body as any invalid invite.
900
+ if not _inviter_still_authorized(invite):
901
+ raise InviteError(_INVALID_INVITE_MESSAGE)
902
+
903
+ if user is None:
904
+ user = _create_invited_user(
905
+ username=username, email=invite.email.lower(), password=password
906
+ )
907
+
908
+ now = timezone.now()
909
+ membership, created = WorkspaceMembership.objects.get_or_create(
910
+ workspace=invite.workspace,
911
+ user=user,
912
+ defaults={"role": invite.role, "status": MemberStatus.ACTIVE},
913
+ )
914
+ if created:
915
+ # Self-service join: the actor is the invitee provisioning their own
916
+ # membership via a token (the endpoint is unauthenticated). Audited
917
+ # inside the provisioning transaction so it rolls back with it.
918
+ record_audit_event(
919
+ event_type=AuditEventType.MEMBER_ADDED,
920
+ actor=user,
921
+ target_type="member",
922
+ target_label=_actor_label(user),
923
+ metadata={
924
+ "role": WorkspaceRole(membership.role).label,
925
+ "source": "invite",
926
+ },
927
+ )
928
+ else:
929
+ _link_existing_membership(membership, invite, user, now)
930
+
931
+ invite.status = InviteStatus.ACCEPTED
932
+ invite.accepted_at = now
933
+ invite.accepted_user = user
934
+ invite.email_pending = False
935
+ invite.email_token = "" # consume the raw token
936
+ invite.save(
937
+ update_fields=[
938
+ "status",
939
+ "accepted_at",
940
+ "accepted_user",
941
+ "email_pending",
942
+ "email_token",
943
+ ]
944
+ )
945
+
946
+ # The invite's own durable record (#2911). Distinct from the MEMBER_ADDED
947
+ # row above and not redundant with it: that row's actor is the *invitee*,
948
+ # because this endpoint is unauthenticated and the invitee provisions
949
+ # themselves. It therefore cannot answer "who let them in". The inviter is
950
+ # carried here explicitly, denormalized to a label as well as an id so the
951
+ # answer survives the inviter's own off-boarding (the AuditEvent.actor_label
952
+ # idiom). Written inside the same transaction, so it rolls back with a
953
+ # failed accept rather than claiming a join that did not happen.
954
+ record_audit_event(
955
+ event_type=AuditEventType.INVITE_ACCEPTED,
956
+ actor=user,
957
+ target_type="invite",
958
+ target_id=invite.pk,
959
+ target_label=invite.email,
960
+ metadata={
961
+ "role": WorkspaceRole(invite.role).label,
962
+ "invited_by": _actor_label(invite.invited_by),
963
+ "invited_by_id": str(invite.invited_by_id) if invite.invited_by_id else None,
964
+ "invited_at": invite.created_at.isoformat(),
965
+ },
966
+ )
967
+ return user