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,910 @@
1
+ """Team-signal privacy service layer (ADR-0104 §2–§4).
2
+
3
+ Every read-suppression decision and every policy write goes through here so the
4
+ gate lives in exactly one place (ADR-0104 risk #1: a forgotten gate on a future
5
+ signal). The model exposes no bare field write — `set_signal_audience` and
6
+ `raise_signal_ceiling` are the *only* writers of `signal_visibility`, which is the
7
+ sprint-sovereignty contract: a non-team principal can never move a project's
8
+ audience or ceiling.
9
+
10
+ **Reader-gate direction (security-critical).** The audience ladder measures how far
11
+ *up* (toward management) a signal has been shared. A requester reads a signal's
12
+ gated detail iff their reader band is **within** the audience — ``tier <= audience``
13
+ (suppress when ``tier > audience``). The team band (TEAM — ordinary members,
14
+ viewers, and the Scrum Master) is the floor, so the team **always** reads its own
15
+ signals (ordinary members' read is never regressed); the PM band (TEAM_SM_PM) is
16
+ excluded until the team raises a signal's audience to include it; a non-member is
17
+ outside the ladder and never reads. This delivers ADR-0104's core guarantee
18
+ (velocity/pulse are team-private by default, the PM does not read them automatically
19
+ — Morgan's hard-NO) while keeping every ordinary member's existing read intact.
20
+
21
+ This reconciles ADR-0104's internal tension: §1's "no regression" is about *ordinary
22
+ members*, and Decision-1's "the PM no longer reads velocity automatically" is the
23
+ intent — both hold under ``tier <= audience``. §2's earlier "suppress when tier <
24
+ audience" wording (which would have left the PM reading velocity by default) was the
25
+ inverted statement and is corrected here and in the ADR.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Callable
31
+ from datetime import timedelta
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ from django.conf import settings as django_settings
35
+ from django.db import transaction
36
+ from django.utils import timezone
37
+ from rest_framework.exceptions import ValidationError
38
+
39
+ from trueppm_api.apps.access.models import Role
40
+ from trueppm_api.apps.access.permissions import _membership_role
41
+ from trueppm_api.apps.projects.models import (
42
+ SIGNAL_DEFAULTS,
43
+ CeilingRaiseStatus,
44
+ CeilingVoteChoice,
45
+ ProjectSignalPrivacyPolicy,
46
+ SignalAudience,
47
+ SignalCeilingRaiseProposal,
48
+ SignalCeilingRaiseVote,
49
+ signal_audience_rank,
50
+ )
51
+ from trueppm_api.core.extension_providers import guard_single_provider
52
+ from trueppm_api.core.extension_signals import dispatch_extension_signal
53
+
54
+ if TYPE_CHECKING:
55
+ from rest_framework.request import Request
56
+
57
+ from trueppm_api.apps.projects.models import Project
58
+
59
+ # The signal keys this policy governs — the iteration set for ratchet / sharing.
60
+ SIGNAL_KEYS = tuple(SIGNAL_DEFAULTS.keys())
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Default-posture seam — neutral OSS extension point (ADR-0029 slot; ADR-0104 §1)
65
+ # ---------------------------------------------------------------------------
66
+ #
67
+ # When a project's policy is first created, its initial signal_visibility is
68
+ # seeded from a registered provider. The OSS core registers NO provider, so the
69
+ # community edition always falls back to the coded SIGNAL_DEFAULTS (all-TEAM,
70
+ # unchanged behavior). trueppm-enterprise registers a provider in its
71
+ # AppConfig.ready() to supply an org-governance default posture (which may open
72
+ # signals) — see trueppm-enterprise#143. This is the integrations-registry idiom
73
+ # (ADR-0049): OSS ships the neutral hook, Enterprise supplies the value; OSS never
74
+ # imports enterprise. The seam runs ONLY at creation, so it can never reach down
75
+ # and reopen a team's already-set ceiling (the G1 team-override guarantee holds
76
+ # structurally — there is no override to clobber at create time).
77
+ _DEFAULT_POSTURE_PROVIDER: Callable[[Project], dict[str, dict[str, str]]] | None = None
78
+
79
+
80
+ def register_default_posture_provider(
81
+ provider: Callable[[Project], dict[str, dict[str, str]]] | None,
82
+ ) -> None:
83
+ """Register (or clear) the initial-posture provider. Enterprise calls this."""
84
+ global _DEFAULT_POSTURE_PROVIDER
85
+ _DEFAULT_POSTURE_PROVIDER = guard_single_provider(
86
+ _DEFAULT_POSTURE_PROVIDER, provider, "signal-privacy default posture"
87
+ )
88
+
89
+
90
+ def _seed_initial_visibility(policy: ProjectSignalPrivacyPolicy, project: Project) -> None:
91
+ """Seed a freshly-created policy from the registered provider, if any.
92
+
93
+ Each provided ``{audience, ceiling}`` is clamped to the ladder and to the
94
+ ``audience <= ceiling`` invariant before it is stored, so a misconfigured
95
+ provider can never persist an inconsistent posture. No-op (coded defaults) when
96
+ no provider is registered — the OSS path.
97
+ """
98
+ if _DEFAULT_POSTURE_PROVIDER is None:
99
+ return
100
+ seed = _DEFAULT_POSTURE_PROVIDER(project) or {}
101
+ cleaned: dict[str, dict[str, str]] = {}
102
+ for signal_key, pair in seed.items():
103
+ if signal_key not in SIGNAL_DEFAULTS or not isinstance(pair, dict):
104
+ continue
105
+ default = SIGNAL_DEFAULTS[signal_key]
106
+ ceiling = pair.get("ceiling", default["ceiling"])
107
+ audience = pair.get("audience", default["audience"])
108
+ if ceiling not in SignalAudience.values or audience not in SignalAudience.values:
109
+ continue
110
+ # Enforce audience <= ceiling at the seam too (defense in depth).
111
+ if signal_audience_rank(audience) > signal_audience_rank(ceiling):
112
+ audience = ceiling
113
+ cleaned[signal_key] = {"audience": audience, "ceiling": ceiling}
114
+ if cleaned:
115
+ policy.signal_visibility = cleaned
116
+ policy._change_reason = "initial org-default posture" # type: ignore[attr-defined]
117
+ policy.save(update_fields=["signal_visibility"])
118
+
119
+
120
+ def get_or_create_policy(project: Project) -> ProjectSignalPrivacyPolicy:
121
+ """Get-or-create a project's privacy policy, seeding new rows via the seam.
122
+
123
+ The single entry point every read/write path uses to obtain the policy, so the
124
+ default-posture seam runs exactly once (at creation) and nowhere else.
125
+ """
126
+ policy, created = ProjectSignalPrivacyPolicy.objects.get_or_create(project=project)
127
+ if created:
128
+ _seed_initial_visibility(policy, project)
129
+ return policy
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Reader tier + the suppression gate (§2)
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ def requester_signal_tier(request: Request, project_id: Any) -> str | None:
138
+ """Resolve a requester's reader band on the ladder, or None for a non-member.
139
+
140
+ The ladder measures **management distance**: ``TEAM`` is the team itself
141
+ (ordinary members, viewers, and the Scrum Master — all team insiders who always
142
+ read their own team's signals), and ``TEAM_SM_PM`` is the PM/management band.
143
+ A signal is read iff the requester's band is *within* the signal's audience
144
+ (``tier <= audience``, see :func:`audience_can_read`), so the team always reads
145
+ its own signals while the PM is excluded until the team shares upward.
146
+
147
+ ``None`` (non-member — the only way an org/PMO principal arrives) is below the
148
+ ladder and is denied every signal regardless of role ordinal: an Enterprise
149
+ custom role above OWNER that is not a project member has no ``ProjectMembership``
150
+ row, so it never passes (the back-door close, ADR-0104 §2). The Scrum-Master
151
+ facet (ADR-0078 / #927) does **not** raise the *read* band — the SM is a team
152
+ insider who reads as TEAM; the facet grants the *write* gate instead.
153
+ """
154
+ role = _membership_role(request, project_id)
155
+ if role is None:
156
+ return None
157
+ if role >= Role.ADMIN:
158
+ return SignalAudience.TEAM_SM_PM # the PM / management band
159
+ return SignalAudience.TEAM # ordinary members, viewers, and the SM are the team
160
+
161
+
162
+ def audience_can_read(
163
+ policy: ProjectSignalPrivacyPolicy, signal_key: str, requester_tier: str | None
164
+ ) -> bool:
165
+ """Whether a requester at ``requester_tier`` may read ``signal_key``'s gated detail.
166
+
167
+ Read iff the requester's band is **within** the signal's audience on the ladder
168
+ (``tier <= audience``) — the team (TEAM) always reads its own signals; the PM
169
+ (TEAM_SM_PM) reads only once the team has shared the signal up to its band; the
170
+ program rollup reads only at PROGRAM_SHARED. Raising the audience *widens* who
171
+ can see a signal (shares upward); it never hides it from the team. A non-member
172
+ (``requester_tier is None``) is outside the ladder and never reads.
173
+ """
174
+ if requester_tier is None:
175
+ return False
176
+ return signal_audience_rank(requester_tier) <= signal_audience_rank(
177
+ policy.audience_of(signal_key)
178
+ )
179
+
180
+
181
+ def can_read_signal(request: Request, project_id: Any, signal_key: str) -> bool:
182
+ """Convenience: resolve the requester's tier and apply the gate in one call.
183
+
184
+ Read-only: a read path must never *create* a policy (that would be a write on a
185
+ GET, and would run the default-posture seam off a read request). When no policy
186
+ row exists yet the signal resolves to its coded default audience (TEAM), which
187
+ is exactly what an unseeded ``get_or_create`` would have produced.
188
+ """
189
+ tier = requester_signal_tier(request, project_id)
190
+ if tier is None:
191
+ return False
192
+ policy = ProjectSignalPrivacyPolicy.objects.filter(project_id=project_id).first()
193
+ audience = (
194
+ policy.audience_of(signal_key)
195
+ if policy is not None
196
+ else SIGNAL_DEFAULTS.get(signal_key, {"audience": SignalAudience.TEAM})["audience"]
197
+ )
198
+ return signal_audience_rank(tier) <= signal_audience_rank(audience)
199
+
200
+
201
+ # Velocity_summary fields that are team-private detail (the series + the
202
+ # point-based rolling/forecast numbers). Suppressed for a below-tier reader; the
203
+ # task-count rollups and the keys themselves are retained so existing clients keep
204
+ # a stable shape (ADR-0104 §2.1 — suppress, don't 403).
205
+ _VELOCITY_GATED_FIELDS = (
206
+ "rolling_avg_points",
207
+ "rolling_stdev_points",
208
+ "forecast_range_low",
209
+ "forecast_range_high",
210
+ "team_velocity_per_day",
211
+ )
212
+
213
+
214
+ def suppress_velocity_summary(summary: dict[str, Any]) -> dict[str, Any]:
215
+ """Return a copy of a velocity_summary with the team-private detail stripped.
216
+
217
+ Empties the per-sprint ``sprints`` series, nulls the point-based rolling/forecast
218
+ numbers, and sets ``velocity_suppressed=True`` so the client can render the
219
+ gated empty-state. The milestone-health % and schedule confidence (computed
220
+ elsewhere) are never touched.
221
+ """
222
+ # excluded_count (ADR-0113) is zeroed alongside the emptied series: it is an
223
+ # organisational fact ("this team flagged N setup sprints") that the ADR-0104
224
+ # privacy boundary excludes from a below-audience reader, and a non-zero count
225
+ # over an empty sprints[] would be incoherent anyway.
226
+ redacted = {**summary, "sprints": [], "excluded_count": 0, "velocity_suppressed": True}
227
+ for field in _VELOCITY_GATED_FIELDS:
228
+ if field in redacted:
229
+ redacted[field] = None
230
+ return redacted
231
+
232
+
233
+ # flow_metrics arrays that are team-private historical performance detail
234
+ # (cycle/lead distributions, the CFD series, the weekly throughput series).
235
+ # Emptied for a below-tier reader; the keys themselves and the aggregate-only
236
+ # data_integrity block are retained so existing clients keep a stable shape
237
+ # (ADR-0130 D4 / ADR-0104 §2.1 — suppress, don't 403). data_integrity is NOT
238
+ # stripped: it is aggregate-only advisory data (no per-person rows) and carries
239
+ # no team-performance signal on its own.
240
+ _FLOW_METRICS_GATED_ARRAYS = ("cfd", "throughput")
241
+
242
+
243
+ def suppress_flow_metrics(payload: dict[str, Any]) -> dict[str, Any]:
244
+ """Return a copy of a flow_metrics payload with the team-private detail stripped.
245
+
246
+ Empties the cycle/lead-time distributions, the CFD series, and the weekly
247
+ throughput series, and sets ``flow_metrics_suppressed=True`` so the client can
248
+ render the gated empty-state (ADR-0130 D4). Models
249
+ :func:`suppress_velocity_summary`.
250
+
251
+ The ``data_integrity`` counts are zeroed too: ``bulk_moved_count`` /
252
+ ``backdated_count`` are derived from the same completed-task replay as the
253
+ throughput series, so a non-zero count reveals a floor on completions in the
254
+ window and partially reconstructs the very signal being suppressed. This mirrors
255
+ ADR-0104's treatment of ``excluded_count`` on velocity suppression. The block is
256
+ kept in the shape (zeroed) so the response contract survives suppression.
257
+ """
258
+ redacted = {
259
+ **payload,
260
+ "cycle_time": {"p50": None, "p80": None, "p95": None},
261
+ "lead_time": {"p50": None, "p80": None, "p95": None},
262
+ "data_integrity": {
263
+ "bulk_moved_count": 0,
264
+ "backdated_count": 0,
265
+ "missing_transition_count": 0,
266
+ },
267
+ "flow_metrics_suppressed": True,
268
+ }
269
+ for field in _FLOW_METRICS_GATED_ARRAYS:
270
+ if field in redacted:
271
+ redacted[field] = []
272
+ return redacted
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Writes — two gates (§1.1). Audience within [TEAM, ceiling]; ceiling team-owned.
277
+ # ---------------------------------------------------------------------------
278
+
279
+
280
+ def _validate_signal_key(signal_key: str) -> None:
281
+ if signal_key not in SIGNAL_DEFAULTS:
282
+ raise ValidationError({"signal": f"Unknown signal '{signal_key}'."})
283
+
284
+
285
+ def _emit_consent_changed(
286
+ project_id: Any, signal_key: str, change: str, old: str, new: str
287
+ ) -> None:
288
+ """Fire the supply-only extension-point signal on commit (ADR-0104 §3).
289
+
290
+ Also broadcasts the policy change to the project's live clients (#2845). This is
291
+ the one chokepoint every policy writer passes through — ``set_signal_audience``,
292
+ ``raise_signal_ceiling`` (including the clamp it may apply) and
293
+ ``ratchet_down_to_team`` all land here — so wiring the broadcast here rather than
294
+ at each call site is what keeps a future fourth writer from being silently
295
+ unbroadcast. Nothing in this module previously contained the string "broadcast",
296
+ while the settings UI it backs is a real multi-user surface with a 60s/30s
297
+ ``staleTime`` and no ``refetchInterval``: a facilitator's ratchet-to-team was
298
+ invisible to every other member for up to a minute.
299
+ """
300
+ from trueppm_api.apps.projects.signals import team_signal_consent_changed
301
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
302
+
303
+ project_id_str = str(project_id)
304
+ transaction.on_commit(
305
+ lambda: dispatch_extension_signal(
306
+ team_signal_consent_changed,
307
+ sender=ProjectSignalPrivacyPolicy,
308
+ project_id=project_id_str,
309
+ signal_key=signal_key,
310
+ change=change,
311
+ old=old,
312
+ new=new,
313
+ )
314
+ )
315
+ # Values are already plain strings here (broadcast-check H-1: nothing in this
316
+ # closure re-reads an ORM instance that could have moved on by commit time).
317
+ transaction.on_commit(
318
+ lambda: broadcast_board_event(
319
+ project_id_str,
320
+ "signal_privacy_changed",
321
+ {"id": project_id_str, "signal_key": signal_key, "change": change},
322
+ )
323
+ )
324
+
325
+
326
+ def set_signal_audience(
327
+ policy: ProjectSignalPrivacyPolicy,
328
+ signal_key: str,
329
+ new_audience: str,
330
+ *,
331
+ actor: Any = None,
332
+ ) -> ProjectSignalPrivacyPolicy:
333
+ """Move a signal's *audience* within ``[TEAM, ceiling]`` — the day-to-day write.
334
+
335
+ This is the facilitator's dial. It is the **only** path that touches an
336
+ audience, and it **refuses to touch a ceiling** (ADR-0104 §1.1 + threat-model
337
+ 🔴-1: a generic field PATCH that could write ``ceiling`` reopens the unilateral
338
+ PM-raise hole). Rejects an audience above the team-authorized ceiling with 400.
339
+ Idempotent: setting the current value writes no history row.
340
+ """
341
+ _validate_signal_key(signal_key)
342
+ if new_audience not in SignalAudience.values:
343
+ raise ValidationError({"audience": f"Invalid audience '{new_audience}'."})
344
+
345
+ resolved = policy.resolved(signal_key)
346
+ old_audience = resolved["audience"]
347
+ ceiling = resolved["ceiling"]
348
+ if signal_audience_rank(new_audience) > signal_audience_rank(ceiling):
349
+ raise ValidationError(
350
+ {
351
+ "audience": (
352
+ f"Audience '{new_audience}' exceeds the team-authorized ceiling "
353
+ f"'{ceiling}'. Raise the ceiling first (a team decision)."
354
+ )
355
+ }
356
+ )
357
+ if new_audience == old_audience:
358
+ return policy # no-op — no history row (ADR-0104 §DE idempotency)
359
+
360
+ entry = dict(policy.signal_visibility.get(signal_key, {}))
361
+ entry["audience"] = new_audience
362
+ policy.signal_visibility = {**policy.signal_visibility, signal_key: entry}
363
+ policy._change_reason = f"{signal_key} audience: {old_audience} -> {new_audience}" # type: ignore[attr-defined]
364
+ policy.save()
365
+ _emit_consent_changed(policy.project_id, signal_key, "audience", old_audience, new_audience)
366
+ return policy
367
+
368
+
369
+ def raise_signal_ceiling(
370
+ policy: ProjectSignalPrivacyPolicy,
371
+ signal_key: str,
372
+ new_ceiling: str,
373
+ *,
374
+ actor: Any = None,
375
+ ) -> ProjectSignalPrivacyPolicy:
376
+ """Set a signal's *ceiling* — authorizing wider exposure (the team-owned act).
377
+
378
+ Raising a ceiling authorizes the facilitator to later move the audience that far;
379
+ it never moves the audience itself. **Lowering** a ceiling is always allowed
380
+ (more private) and clamps the audience down with it (ADR-0104 §1.1). Audited and
381
+ emitted as a team-visible consent event.
382
+ """
383
+ _validate_signal_key(signal_key)
384
+ if new_ceiling not in SignalAudience.values:
385
+ raise ValidationError({"ceiling": f"Invalid ceiling '{new_ceiling}'."})
386
+
387
+ resolved = policy.resolved(signal_key)
388
+ old_ceiling = resolved["ceiling"]
389
+ audience = resolved["audience"]
390
+ if new_ceiling == old_ceiling:
391
+ return policy
392
+
393
+ entry = dict(policy.signal_visibility.get(signal_key, {}))
394
+ entry["ceiling"] = new_ceiling
395
+ # Lowering the ceiling below the current audience clamps the audience down with
396
+ # it — the signal can never sit above what the team now authorizes.
397
+ clamped = audience
398
+ if signal_audience_rank(new_ceiling) < signal_audience_rank(audience):
399
+ clamped = new_ceiling
400
+ entry["audience"] = clamped
401
+ policy.signal_visibility = {**policy.signal_visibility, signal_key: entry}
402
+
403
+ direction = (
404
+ "raise"
405
+ if signal_audience_rank(new_ceiling) > signal_audience_rank(old_ceiling)
406
+ else "lower"
407
+ )
408
+ reason = f"{signal_key} ceiling: {old_ceiling} -> {new_ceiling} (team-owned {direction})"
409
+ policy._change_reason = reason # type: ignore[attr-defined]
410
+ policy.save()
411
+ _emit_consent_changed(policy.project_id, signal_key, "ceiling", old_ceiling, new_ceiling)
412
+ if clamped != audience:
413
+ _emit_consent_changed(policy.project_id, signal_key, "audience", audience, clamped)
414
+ return policy
415
+
416
+
417
+ def ratchet_down_to_team(
418
+ policy: ProjectSignalPrivacyPolicy, *, actor: Any = None
419
+ ) -> ProjectSignalPrivacyPolicy:
420
+ """Set every signal's audience to TEAM in one call — the SM panic button (§1).
421
+
422
+ Never touches ceilings (it is the convenience form of set-audience). Idempotent;
423
+ writes one audited history entry only for signals that actually changed.
424
+ """
425
+ changed = False
426
+ new_map = dict(policy.signal_visibility)
427
+ reasons: list[str] = []
428
+ moves: list[tuple[str, str]] = []
429
+ for signal_key in SIGNAL_KEYS:
430
+ old_audience = policy.audience_of(signal_key)
431
+ if old_audience == SignalAudience.TEAM:
432
+ continue
433
+ entry = dict(new_map.get(signal_key, {}))
434
+ entry["audience"] = SignalAudience.TEAM
435
+ new_map[signal_key] = entry
436
+ reasons.append(f"{signal_key} audience: {old_audience} -> team")
437
+ moves.append((signal_key, old_audience))
438
+ changed = True
439
+ if not changed:
440
+ return policy
441
+ policy.signal_visibility = new_map
442
+ policy._change_reason = "ratchet to team-only: " + "; ".join(reasons) # type: ignore[attr-defined]
443
+ policy.save()
444
+ for signal_key, old_audience in moves:
445
+ _emit_consent_changed(
446
+ policy.project_id, signal_key, "audience", old_audience, SignalAudience.TEAM
447
+ )
448
+ return policy
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # Enterprise extension point — supply-only, opt-in only (§3)
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def get_shared_team_signals(project: Project) -> dict[str, str] | None:
457
+ """Return the signals a project has opted into the program rollup, or None.
458
+
459
+ Returns ``{signal_key: audience}`` for *only* the signals whose audience is
460
+ ``PROGRAM_SHARED`` (reachable only after a team raised the ceiling there and set
461
+ the audience there). Returns **None** when the project shared nothing — the
462
+ consumer skips a ``None`` rather than zero-filling, so a non-consenting team is
463
+ excluded from the aggregate, never inferred as a zero (ADR-0104 §3, Alternative
464
+ D rejected). Pure read; no side effects.
465
+ """
466
+ policy = get_or_create_policy(project)
467
+ shared = {
468
+ signal_key: policy.audience_of(signal_key)
469
+ for signal_key in SIGNAL_KEYS
470
+ if policy.audience_of(signal_key) == SignalAudience.PROGRAM_SHARED
471
+ }
472
+ return shared or None
473
+
474
+
475
+ def velocity_shared_externally(project: Project) -> bool:
476
+ """Whether velocity may travel to an *external* webhook consumer (ADR-0147).
477
+
478
+ A webhook recipient has no ``ProjectMembership`` and therefore no ladder tier —
479
+ the reader gate would deny it every signal, which would suppress velocity for
480
+ *every* webhook and defeat the legitimate "relay our cadence to our own
481
+ dashboard" use case. Instead an external consumer is mapped to the single
482
+ explicit outward-share rung: velocity (``completed_points`` / ``completed_task_count``
483
+ on ``sprint.closed``) leaves the team boundary **iff** the team has raised the
484
+ ``velocity`` signal's audience to ``PROGRAM_SHARED`` — the one rung reachable only
485
+ by a deliberate, team-owned ceiling+audience raise. This is the same authority
486
+ ``get_shared_team_signals`` consults, kept here so the webhook gate and the rollup
487
+ gate can never drift. Pure read; uses the resolved default (TEAM) when no policy
488
+ row exists, so the default suppresses.
489
+ """
490
+ policy = ProjectSignalPrivacyPolicy.objects.filter(project=project).first()
491
+ audience = (
492
+ policy.audience_of("velocity")
493
+ if policy is not None
494
+ else SIGNAL_DEFAULTS["velocity"]["audience"]
495
+ )
496
+ return audience == SignalAudience.PROGRAM_SHARED
497
+
498
+
499
+ # ---------------------------------------------------------------------------
500
+ # Ceiling-raise ratification (ADR-0104 Amendment A, #930)
501
+ # ---------------------------------------------------------------------------
502
+ #
503
+ # Raising a ceiling authorizes *wider* upward exposure of a team signal, so it is the
504
+ # team-owned act (ADR-0104 §1.1). It no longer applies immediately: a raise opens a
505
+ # ratification proposal and the ceiling is applied only when a strict majority of the
506
+ # current team roster approves. A lone facilitator can never widen exposure alone.
507
+ # Lowering a ceiling and set-audience moves stay immediate single actions — tightening
508
+ # is never gated heavier than loosening. Silence is never consent: an unratified
509
+ # proposal expires with the ceiling unchanged (no auto-apply-on-timeout). There is no
510
+ # management bypass — that would reopen the §2 back-door this whole model closes.
511
+
512
+
513
+ class CeilingProposalConflict(Exception):
514
+ """A ceiling-proposal action conflicts with the proposal's current state.
515
+
516
+ A plain ``Exception`` (not an ``APIException``) so the view maps it to a structured
517
+ ``409 {code, detail}`` — mirroring ADR-0102's ``ScopeAcceptForbidden`` → structured
518
+ response pattern rather than DRF's default ``400`` for a ``ValidationError``.
519
+ """
520
+
521
+ def __init__(self, code: str, detail: str) -> None:
522
+ self.code = code
523
+ self.detail = detail
524
+ super().__init__(detail)
525
+
526
+
527
+ def _is_real_user(actor: Any) -> bool:
528
+ """Whether ``actor`` is an authenticated user (not None / AnonymousUser)."""
529
+ return actor is not None and bool(getattr(actor, "is_authenticated", False))
530
+
531
+
532
+ def _ceiling_proposal_ttl() -> timedelta:
533
+ """The OPEN-proposal lifetime (default 72h, settings-overridable; Amendment A.3).
534
+
535
+ Clamped to >= 1 hour so a misconfigured ``SIGNAL_CEILING_PROPOSAL_TTL_HOURS`` of 0
536
+ or negative can never mint a proposal that expires before anyone can vote on it.
537
+ """
538
+ hours = getattr(django_settings, "SIGNAL_CEILING_PROPOSAL_TTL_HOURS", 72)
539
+ return timedelta(hours=max(1, int(hours)))
540
+
541
+
542
+ def ratification_threshold(eligible_count: int) -> int:
543
+ """Strict majority of the current roster — ``floor(n/2) + 1`` (Amendment A.2).
544
+
545
+ Yields 1->1, 2->2, 3->2, 4->3, 5->3. For any team of >=2 members the proposer's
546
+ own approval (1) never meets the bar, so a lone facilitator can never raise alone;
547
+ a 1-member team's sole member ratifies (there is no other member to consult). Not
548
+ unanimity and not whole-roster, so one disengaged member can never deadlock a raise.
549
+ """
550
+ if eligible_count <= 0:
551
+ return 1
552
+ return eligible_count // 2 + 1
553
+
554
+
555
+ def _team_voter_ids(project_id: Any) -> set[Any]:
556
+ """The eligible-voter set (Amendment A.2 as amended by Amendment C, #3387).
557
+
558
+ Default-team ``TeamMembership`` **intersected with live ``ProjectMembership``** —
559
+ the intersection lives in ``team_member_user_ids`` and is defaulted on, so this is
560
+ a plain delegation rather than a local filter. Both the denominator
561
+ (:func:`ratification_threshold`) and the numerator (:func:`_count_current_votes`)
562
+ read this one set, which is what keeps a tally internally consistent when the
563
+ roster moves mid-proposal.
564
+ """
565
+ from trueppm_api.apps.teams.services import team_member_user_ids
566
+
567
+ return team_member_user_ids(project_id)
568
+
569
+
570
+ def _emit_proposal_changed(proposal: SignalCeilingRaiseProposal) -> None:
571
+ """Fire the supply-only proposal-lifecycle signal on commit (Amendment A.6).
572
+
573
+ Also broadcasts the transition (#2845). Every status change routes through
574
+ ``_resolve_proposal`` or the open path, both of which land here, so this covers
575
+ opened / ratified / rejected / expired / superseded / withdrawn. A vote that does
576
+ *not* move the status broadcasts separately from :func:`cast_ceiling_vote` — the
577
+ running tally is itself the live signal a voting UI needs.
578
+
579
+ The architecturally equivalent feature is Planning Poker, which broadcasts on
580
+ every open/vote/reveal/reopen/commit/cancel; this one shipped with none.
581
+ """
582
+ from trueppm_api.apps.projects.signals import team_signal_ceiling_proposal_changed
583
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
584
+
585
+ project_id = str(proposal.project_id)
586
+ signal_key = proposal.signal_key
587
+ proposal_id = str(proposal.id)
588
+ status = proposal.status
589
+ transaction.on_commit(
590
+ lambda: dispatch_extension_signal(
591
+ team_signal_ceiling_proposal_changed,
592
+ sender=SignalCeilingRaiseProposal,
593
+ project_id=project_id,
594
+ signal_key=signal_key,
595
+ proposal_id=proposal_id,
596
+ status=status,
597
+ )
598
+ )
599
+ # Plain scalars snapshotted above, before the closure (broadcast-check H-1). No
600
+ # vote values or voter identities in the payload — the event is a nudge to
601
+ # re-read through the REST surface, which re-applies the privacy gate.
602
+ transaction.on_commit(
603
+ lambda: broadcast_board_event(
604
+ project_id,
605
+ "signal_ceiling_proposal_changed",
606
+ {"id": proposal_id, "signal_key": signal_key, "status": status},
607
+ )
608
+ )
609
+
610
+
611
+ def _resolve_proposal(
612
+ proposal: SignalCeilingRaiseProposal, new_status: str
613
+ ) -> SignalCeilingRaiseProposal:
614
+ """Move an OPEN proposal to a terminal state; apply the raise only on RATIFIED.
615
+
616
+ RATIFIED re-resolves the *current* ceiling and applies via ``raise_signal_ceiling``
617
+ only when ``to_ceiling`` is still strictly higher — so a team that already raised
618
+ the ceiling by other means never gets a surprise double-jump, and a ratification can
619
+ never *lower* a ceiling (Amendment A.4). Every terminal state emits the lifecycle
620
+ signal on commit. Must be called under a row lock on ``proposal``.
621
+ """
622
+ proposal.status = new_status
623
+ proposal.resolved_at = timezone.now()
624
+ proposal.save(update_fields=["status", "resolved_at"])
625
+ if new_status == CeilingRaiseStatus.RATIFIED:
626
+ policy = get_or_create_policy(proposal.project)
627
+ current = policy.ceiling_of(proposal.signal_key)
628
+ if signal_audience_rank(proposal.to_ceiling) > signal_audience_rank(current):
629
+ raise_signal_ceiling(
630
+ policy, proposal.signal_key, proposal.to_ceiling, actor=proposal.proposed_by
631
+ )
632
+ _emit_proposal_changed(proposal)
633
+ return proposal
634
+
635
+
636
+ def _count_current_votes(
637
+ proposal: SignalCeilingRaiseProposal, voter_ids: set[Any]
638
+ ) -> tuple[int, int, int]:
639
+ """(approve, reject, disregarded) — votes restricted to *current* eligible members.
640
+
641
+ The third value is the count of cast votes whose caster is no longer eligible. It
642
+ exists so the tally can **explain itself**: with a live roster both the numerator
643
+ and the denominator can move between two reads of the same OPEN proposal, and a
644
+ bare pair of shrinking integers gives a reader no way to tell a withdrawn vote from
645
+ a departed voter. Surfaced as ``disregarded_vote_count`` on the API tally.
646
+
647
+ **This is the already-cast-vote rule of Amendment C, and it is deliberate.** A vote
648
+ counts only while its caster is eligible, so the numerator and the denominator move
649
+ together and the tally always reconciles with the roster the threshold is computed
650
+ against. Since #3387 "eligible" also means holding a live ``ProjectMembership``, so
651
+ an offboarded member's vote stops counting at the same instant they stop counting
652
+ toward the bar — a proposal can never ratify on an absent member's approval.
653
+
654
+ The vote **row is not deleted**: it stays as the audit record Amendment A.6 promises,
655
+ and re-seating the member restores their vote to the tally. And the recomputation
656
+ can only ever reach an OPEN proposal — :func:`_tally_and_maybe_apply` returns
657
+ immediately once the status is terminal and a ratified raise is never revisited —
658
+ so no offboarding can retroactively flip a decision that has already been applied.
659
+ """
660
+ approve = 0
661
+ reject = 0
662
+ disregarded = 0
663
+ for vote in proposal.votes.all():
664
+ if vote.voter_id not in voter_ids:
665
+ disregarded += 1
666
+ continue
667
+ if vote.choice == CeilingVoteChoice.APPROVE:
668
+ approve += 1
669
+ elif vote.choice == CeilingVoteChoice.REJECT:
670
+ reject += 1
671
+ return approve, reject, disregarded
672
+
673
+
674
+ def _tally_and_maybe_apply(proposal: SignalCeilingRaiseProposal) -> SignalCeilingRaiseProposal:
675
+ """Recompute the outcome from current votes + roster; resolve when decided.
676
+
677
+ Call under ``select_for_update`` on the proposal. Idempotent: a no-op once the
678
+ proposal has left OPEN. Expiry is evaluated first (lazy, Amendment A.3).
679
+ """
680
+ if proposal.status != CeilingRaiseStatus.OPEN:
681
+ return proposal
682
+ if timezone.now() >= proposal.expires_at:
683
+ return _resolve_proposal(proposal, CeilingRaiseStatus.EXPIRED)
684
+ voter_ids = _team_voter_ids(proposal.project_id)
685
+ eligible = len(voter_ids)
686
+ threshold = ratification_threshold(eligible)
687
+ approve, reject, _disregarded = _count_current_votes(proposal, voter_ids)
688
+ if approve >= threshold:
689
+ return _resolve_proposal(proposal, CeilingRaiseStatus.RATIFIED)
690
+ # Approval can no longer reach the bar (even every remaining member voting yes
691
+ # falls short) — reject early rather than wait out the timeout. Guarded on a
692
+ # non-empty roster: with no eligible voters yet the proposal stays OPEN (it will
693
+ # expire) rather than auto-rejecting on a vacuous "unreachable" check.
694
+ if eligible > 0 and reject > eligible - threshold:
695
+ return _resolve_proposal(proposal, CeilingRaiseStatus.REJECTED)
696
+ return proposal
697
+
698
+
699
+ def expire_stale_proposals(project_id: Any, signal_key: str | None = None) -> None:
700
+ """Lazily expire past-due OPEN proposals (Amendment A.3) — no Celery/Beat.
701
+
702
+ Called on the write paths before the one-open-per-signal check, so a stale OPEN
703
+ row never blocks a fresh proposal. An expired proposal dies UNRATIFIED.
704
+ """
705
+ with transaction.atomic():
706
+ stale = SignalCeilingRaiseProposal.objects.select_for_update().filter(
707
+ project_id=project_id,
708
+ status=CeilingRaiseStatus.OPEN,
709
+ expires_at__lte=timezone.now(),
710
+ )
711
+ if signal_key is not None:
712
+ stale = stale.filter(signal_key=signal_key)
713
+ for proposal in stale:
714
+ _resolve_proposal(proposal, CeilingRaiseStatus.EXPIRED)
715
+
716
+
717
+ def _supersede_open_proposals(project_id: Any, signal_key: str) -> None:
718
+ """Supersede any OPEN raise proposal for a signal whose ceiling is being lowered.
719
+
720
+ A lower moves the baseline the proposal was relative to, so the pending raise is no
721
+ longer valid — it is SUPERSEDED (audited), forcing a fresh proposal + vote rather
722
+ than letting a stale ratification apply an unexpected jump. This is the audit
723
+ surface for the lower-then-raise pattern (Amendment A.4).
724
+ """
725
+ with transaction.atomic():
726
+ for proposal in SignalCeilingRaiseProposal.objects.select_for_update().filter(
727
+ project_id=project_id,
728
+ signal_key=signal_key,
729
+ status=CeilingRaiseStatus.OPEN,
730
+ ):
731
+ _resolve_proposal(proposal, CeilingRaiseStatus.SUPERSEDED)
732
+
733
+
734
+ def propose_or_apply_ceiling_change(
735
+ policy: ProjectSignalPrivacyPolicy,
736
+ signal_key: str,
737
+ new_ceiling: str,
738
+ *,
739
+ actor: Any,
740
+ ) -> SignalCeilingRaiseProposal | None:
741
+ """Route a ceiling change: a RAISE opens a ratification proposal; a LOWER applies now.
742
+
743
+ Returns the proposal on a raise (OPEN, or already RATIFIED when a solo team's sole
744
+ member is the proposer), or ``None`` on a lower / no-op (applied immediately via
745
+ ``raise_signal_ceiling``). The policy is unchanged until ratification, so the caller
746
+ re-reads it after this returns.
747
+ """
748
+ _validate_signal_key(signal_key)
749
+ if new_ceiling not in SignalAudience.values:
750
+ raise ValidationError({"ceiling": f"Invalid ceiling '{new_ceiling}'."})
751
+ current = policy.ceiling_of(signal_key)
752
+ if signal_audience_rank(new_ceiling) <= signal_audience_rank(current):
753
+ # Lower or no-op: immediate single action. A lower supersedes any open raise
754
+ # proposal for this signal (its baseline just moved).
755
+ if signal_audience_rank(new_ceiling) < signal_audience_rank(current):
756
+ _supersede_open_proposals(policy.project_id, signal_key)
757
+ raise_signal_ceiling(policy, signal_key, new_ceiling, actor=actor)
758
+ return None
759
+ # Raise — the team-owned act: open a ratification proposal.
760
+ project_id = policy.project_id
761
+ with transaction.atomic():
762
+ expire_stale_proposals(project_id, signal_key)
763
+ existing = (
764
+ SignalCeilingRaiseProposal.objects.select_for_update()
765
+ .filter(
766
+ project_id=project_id,
767
+ signal_key=signal_key,
768
+ status=CeilingRaiseStatus.OPEN,
769
+ )
770
+ .first()
771
+ )
772
+ if existing is not None:
773
+ raise CeilingProposalConflict(
774
+ "proposal_already_open",
775
+ "A ceiling-raise proposal is already open for this signal.",
776
+ )
777
+ proposal = SignalCeilingRaiseProposal.objects.create(
778
+ project_id=project_id,
779
+ signal_key=signal_key,
780
+ from_ceiling=current,
781
+ to_ceiling=new_ceiling,
782
+ proposed_by=actor if _is_real_user(actor) else None,
783
+ expires_at=timezone.now() + _ceiling_proposal_ttl(),
784
+ )
785
+ # Proposing is an implicit APPROVE — but only when the proposer is a team
786
+ # member (a non-team project Admin may kick off the team's vote without
787
+ # casting one). A solo-team proposer thereby ratifies immediately.
788
+ voter_ids = _team_voter_ids(project_id)
789
+ if _is_real_user(actor) and actor.pk in voter_ids:
790
+ SignalCeilingRaiseVote.objects.create(
791
+ proposal=proposal, voter=actor, choice=CeilingVoteChoice.APPROVE
792
+ )
793
+ _emit_proposal_changed(proposal) # opened
794
+ proposal = _tally_and_maybe_apply(proposal)
795
+ return proposal
796
+
797
+
798
+ def cast_ceiling_vote(proposal_id: Any, voter: Any, choice: str) -> SignalCeilingRaiseProposal:
799
+ """Record (or change) a team member's vote and re-tally under a row lock.
800
+
801
+ Idempotent: the vote is an upsert (``unique(proposal, voter)``) and the apply runs
802
+ once behind the OPEN->RATIFIED status guard. Raises ``CeilingProposalConflict`` when
803
+ the proposal is no longer open (including a lazy expiry evaluated under the lock).
804
+ """
805
+ if choice not in CeilingVoteChoice.values:
806
+ raise ValidationError({"choice": f"Invalid choice '{choice}'."})
807
+ with transaction.atomic():
808
+ try:
809
+ proposal = SignalCeilingRaiseProposal.objects.select_for_update().get(pk=proposal_id)
810
+ except SignalCeilingRaiseProposal.DoesNotExist:
811
+ raise CeilingProposalConflict("not_found", "Proposal not found.") from None
812
+ if proposal.status == CeilingRaiseStatus.OPEN and timezone.now() >= proposal.expires_at:
813
+ _resolve_proposal(proposal, CeilingRaiseStatus.EXPIRED)
814
+ if proposal.status != CeilingRaiseStatus.OPEN:
815
+ raise CeilingProposalConflict(
816
+ "proposal_closed", "This proposal is no longer open for voting."
817
+ )
818
+ SignalCeilingRaiseVote.objects.update_or_create(
819
+ proposal=proposal, voter=voter, defaults={"choice": choice}
820
+ )
821
+ settled = _tally_and_maybe_apply(proposal)
822
+ # A vote that does not tip the tally leaves the status OPEN, so
823
+ # _emit_proposal_changed never fires — yet the running count IS what the
824
+ # other voters are watching (#2845). Broadcast the vote itself in that case;
825
+ # when the tally did settle the proposal, the status event already covers it
826
+ # and a second frame would only make clients re-read twice.
827
+ if settled.status == CeilingRaiseStatus.OPEN:
828
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
829
+
830
+ project_id = str(settled.project_id)
831
+ proposal_id = str(settled.pk)
832
+ signal_key = settled.signal_key
833
+ transaction.on_commit(
834
+ lambda: broadcast_board_event(
835
+ project_id,
836
+ "signal_ceiling_vote_cast",
837
+ {"id": proposal_id, "signal_key": signal_key},
838
+ )
839
+ )
840
+ return settled
841
+
842
+
843
+ def withdraw_ceiling_proposal(proposal_id: Any, actor: Any) -> SignalCeilingRaiseProposal:
844
+ """Withdraw an OPEN proposal (the proposer's cancel), recorded as REJECTED.
845
+
846
+ Frees the signal for a fresh proposal without waiting out the TTL. The caller
847
+ enforces that ``actor`` is the proposer (still a Member or above) or a
848
+ facilitator/Admin.
849
+ """
850
+ with transaction.atomic():
851
+ try:
852
+ proposal = SignalCeilingRaiseProposal.objects.select_for_update().get(pk=proposal_id)
853
+ except SignalCeilingRaiseProposal.DoesNotExist:
854
+ raise CeilingProposalConflict("not_found", "Proposal not found.") from None
855
+ if proposal.status != CeilingRaiseStatus.OPEN:
856
+ raise CeilingProposalConflict("proposal_closed", "This proposal is no longer open.")
857
+ return _resolve_proposal(proposal, CeilingRaiseStatus.REJECTED)
858
+
859
+
860
+ def live_open_proposals(project_id: Any) -> dict[str, SignalCeilingRaiseProposal]:
861
+ """Return ``{signal_key: live OPEN proposal}`` for the project, in ONE query.
862
+
863
+ A read helper for the GET surface: it does NOT persist an expiry (reads stay
864
+ side-effect-free) — a past-due OPEN row is treated as absent and is GC'd to
865
+ EXPIRED on the next write path. Votes are prefetched so the caller's per-proposal
866
+ tally/serialize do not re-query (the partial-unique constraint bounds the result
867
+ to at most one OPEN row per signal). The eligible roster should be fetched once by
868
+ the caller and threaded into ``proposal_tally``/serialization.
869
+ """
870
+ now = timezone.now()
871
+ result: dict[str, SignalCeilingRaiseProposal] = {}
872
+ for proposal in (
873
+ SignalCeilingRaiseProposal.objects.filter(
874
+ project_id=project_id, status=CeilingRaiseStatus.OPEN
875
+ )
876
+ .prefetch_related("votes")
877
+ .order_by("-created_at")
878
+ ):
879
+ if proposal.expires_at > now and proposal.signal_key not in result:
880
+ result[proposal.signal_key] = proposal
881
+ return result
882
+
883
+
884
+ def proposal_tally(
885
+ proposal: SignalCeilingRaiseProposal, voter_ids: set[Any] | None = None
886
+ ) -> dict[str, int]:
887
+ """Current approve/reject/eligible/threshold counts for a proposal (read).
888
+
889
+ Pass ``voter_ids`` (the precomputed roster, identical across a project's proposals)
890
+ to avoid one roster query per proposal when serializing a list.
891
+
892
+ ``disregarded_vote_count`` is the explainability handle Amendment C owes the reader:
893
+ eligibility is evaluated at *tally* time, so ``eligible_count``, ``threshold`` and
894
+ the approve/reject pair can all move between two reads of the same OPEN proposal
895
+ when the roster changes underneath it. Without this field a client can see the
896
+ numbers shift but cannot distinguish "someone changed their mind" from "someone left
897
+ the project", and cannot tell a member their own vote has stopped counting. It is
898
+ the count of cast votes whose caster is no longer eligible — normally 0.
899
+ """
900
+ if voter_ids is None:
901
+ voter_ids = _team_voter_ids(proposal.project_id)
902
+ eligible = len(voter_ids)
903
+ approve, reject, disregarded = _count_current_votes(proposal, voter_ids)
904
+ return {
905
+ "approve_count": approve,
906
+ "reject_count": reject,
907
+ "eligible_count": eligible,
908
+ "threshold": ratification_threshold(eligible),
909
+ "disregarded_vote_count": disregarded,
910
+ }