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,1158 @@
1
+ """ViewSets for the integrations API (ADR-0049 §3).
2
+
3
+ Two surfaces live here:
4
+
5
+ - ``IntegrationCredentialViewSet`` — the per-user credentials store. Exposes
6
+ list, upsert (connect/rotate), and delete (revoke); read-of-the-secret is
7
+ intentionally absent and every action auto-scopes to ``request.user``.
8
+ - ``TaskLinkViewSet`` (#637) — git/PM links on a task, nested under
9
+ ``/projects/{project_pk}/tasks/{task_pk}/links/`` with a synchronous refresh
10
+ action. Scoped to project membership; write follows task-edit, read follows
11
+ task-read.
12
+
13
+ Credentials URL contract:
14
+ GET /api/v1/me/credentials/
15
+ POST /api/v1/me/credentials/<provider>/
16
+ DELETE /api/v1/me/credentials/<provider>/
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import secrets
23
+ from typing import TYPE_CHECKING, Any, cast
24
+
25
+ from django.core.exceptions import ImproperlyConfigured
26
+ from django.db import transaction
27
+ from django.db.models import F
28
+ from django.http import Http404
29
+ from django.shortcuts import get_object_or_404
30
+ from django.utils import timezone
31
+ from drf_spectacular.utils import OpenApiResponse, extend_schema
32
+ from rest_framework import mixins, status, viewsets
33
+ from rest_framework.decorators import action
34
+ from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated
35
+ from rest_framework.response import Response
36
+ from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
37
+ from rest_framework.views import APIView
38
+
39
+ from trueppm_api.apps.access.models import ProjectMembership
40
+ from trueppm_api.apps.access.permissions import (
41
+ IsNotTokenAuthenticated,
42
+ IsProjectAdmin,
43
+ IsProjectMember,
44
+ IsProjectMemberWrite,
45
+ IsProjectNotArchived,
46
+ ProjectScopedViewSet,
47
+ )
48
+ from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
49
+ from trueppm_api.apps.projects.models import Project, Task
50
+ from trueppm_api.core.openapi import state_refusal_400, suppress_list_pagination
51
+
52
+ from . import git_webhook_auth, providers
53
+ from .encryption import CredentialEncryptionError, decrypt_secret
54
+ from .git_automation_services import apply_git_event_to_card
55
+ from .models import BoardAutomation, IntegrationCredential, TaskLink
56
+ from .registry import LINK_STATUS_UNKNOWN, TASK_LINK_PROVIDERS
57
+ from .serializers import (
58
+ GIT_WEBHOOK_PAYLOAD_SCHEMA,
59
+ GIT_WEBHOOK_RESULT_SCHEMA,
60
+ CredentialSummarySerializer,
61
+ CredentialUpsertSerializer,
62
+ CredentialVerificationErrorSerializer,
63
+ GitAutomationConfigSerializer,
64
+ GitAutomationSecretSerializer,
65
+ GitAutomationUpdateSerializer,
66
+ TaskLinkCredentialRequiredSerializer,
67
+ TaskLinkSerializer,
68
+ serialize_credential_summaries,
69
+ )
70
+
71
+ if TYPE_CHECKING:
72
+ from django.db.models import QuerySet
73
+ from rest_framework.request import Request
74
+ from rest_framework.serializers import BaseSerializer
75
+
76
+ # Used only in ``cast("type[TaskLinkProvider]", ...)`` string annotations, so
77
+ # it needs no runtime import (keeps ruff and CodeQL in agreement).
78
+ from .registry import TaskLinkProvider
79
+
80
+
81
+ # Human-readable detail per ``VerifyResult.reason`` for the 422 body. The
82
+ # machine-readable ``reason`` is returned alongside so the client can branch
83
+ # without string-matching the prose.
84
+ _VERIFY_FAILURE_DETAIL: dict[str | None, str] = {
85
+ "invalid_token": (
86
+ "The provider rejected this token. Check it is valid, unexpired, and "
87
+ "issued for the correct host."
88
+ ),
89
+ "provider_unreachable": (
90
+ "Could not reach the provider to verify this token. Check the host URL and try again."
91
+ ),
92
+ "provider_timeout": "Verifying this token with the provider timed out. Try again.",
93
+ "blocked_host": ("The host URL is not allowed — it resolves to a private or internal address."),
94
+ }
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+ # ── Inbound Git-webhook delivery outcomes (#2882) ────────────────────────────
99
+ # The vocabulary written to ``BoardAutomation.last_delivery_outcome`` and logged by
100
+ # the receiver. Pre-verification outcomes are what an operator cannot otherwise see
101
+ # at all: every one of them returns the same opaque 404 to the caller (#2881), so
102
+ # this record and the receiver log are the *only* places the reason survives.
103
+ # Post-verification outcomes duplicate the ``reason`` already in the 200 body, which
104
+ # only the provider's delivery log shows — the record is what puts them in front of
105
+ # the admin who is asking "why didn't my card move?".
106
+ GIT_DELIVERY_DISABLED = "automation_disabled"
107
+ GIT_DELIVERY_NO_SECRET = "no_secret"
108
+ GIT_DELIVERY_UNKNOWN_PROVIDER = "unknown_provider"
109
+ GIT_DELIVERY_SECRET_UNREADABLE = "secret_unreadable"
110
+ GIT_DELIVERY_BAD_SIGNATURE = "bad_signature"
111
+ #: The project is archived, so the receiver refuses to move its cards (#3414).
112
+ #: A *refusal*, not a delivery outcome: it is decided before the signature is
113
+ #: verified, so it answers the same bare 404 as every other pre-verification
114
+ #: refusal and cannot tell an unauthenticated caller that the project exists,
115
+ #: is configured, or is archived.
116
+ GIT_DELIVERY_PROJECT_ARCHIVED = "project_archived"
117
+ GIT_DELIVERY_MALFORMED = "malformed_payload"
118
+ GIT_DELIVERY_IGNORED = "ignored"
119
+ GIT_DELIVERY_DUPLICATE = "duplicate"
120
+
121
+ # Body cap for the inbound Git webhook, enforced *before* the body is buffered.
122
+ # Without it the only bound was the global 100 MB ``DATA_UPLOAD_MAX_MEMORY_SIZE``,
123
+ # so an anonymous caller could push 20 MB at a project UUID that has no automation
124
+ # at all and have it fully accepted before the 404 (#2881). A ``pull_request`` /
125
+ # ``merge_request` payload is tens of kilobytes; GitHub itself caps a delivery at
126
+ # 25 MB. 1 MB leaves two orders of magnitude of headroom over real traffic while
127
+ # taking the amplification factor from 100 MB to 1 MB. Not operator-tunable on
128
+ # purpose — an operator has no legitimate reason to widen it, and every knob on an
129
+ # unauthenticated path is a knob that can be widened by mistake.
130
+ _WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024
131
+
132
+
133
+ @extend_schema(tags=["me"])
134
+ class IntegrationCredentialViewSet(
135
+ IdempotencyMixin, viewsets.GenericViewSet[IntegrationCredential]
136
+ ):
137
+ """Per-user integration credentials.
138
+
139
+ Lookup is by ``provider`` key rather than the row PK so the URL is
140
+ stable across rotations (rotating creates a new ciphertext but does
141
+ not change the row identity from the client's perspective).
142
+ """
143
+
144
+ serializer_class = CredentialSummarySerializer
145
+ # Session/JWT only (#2878), same rule as the API-token surface: a credential store
146
+ # is not something a credential may rewrite. A leaked personal access token could
147
+ # otherwise overwrite or delete the owner's connected-account credential — swapping
148
+ # in the attacker's own Jira so it feeds the owner's "My Work", or simply breaking
149
+ # the link. There is no exfiltration risk (the ciphertext is never serialized), but
150
+ # "my script manages my credentials" is not an automation story worth leaving open
151
+ # to a bearer the owner did not knowingly hand it to.
152
+ permission_classes: list[type[BasePermission]] = [IsAuthenticated, IsNotTokenAuthenticated]
153
+ lookup_field = "provider"
154
+ lookup_url_kwarg = "provider"
155
+ # Rate-limit the whole viewset under one per-user scope (#1551). connect/rotate
156
+ # mint a fresh ciphertext and revoke/read/list all touch the credential store;
157
+ # a shared 10/min bucket bounds credential-store abuse without a per-action idiom.
158
+ throttle_classes = [ScopedRateThrottle]
159
+ throttle_scope = "credential_rotate"
160
+
161
+ def get_queryset(self) -> QuerySet[IntegrationCredential]:
162
+ """Restrict to the authenticated user's rows only.
163
+
164
+ The queryset is the single permission boundary — list, retrieve,
165
+ upsert, and delete all read through it, so cross-user access is
166
+ impossible by construction.
167
+ """
168
+ user = self.request.user
169
+ if not user.is_authenticated:
170
+ return IntegrationCredential.objects.none()
171
+ return IntegrationCredential.objects.filter(user=user)
172
+
173
+ @extend_schema(responses={200: CredentialSummarySerializer(many=True)})
174
+ @suppress_list_pagination
175
+ def list(self, request: Request) -> Response:
176
+ """Return one summary row per registered provider.
177
+
178
+ Providers without a stored credential still appear (``exists=False``)
179
+ so the page can render the "Not connected" state without two round-
180
+ trips. The encrypted secret is never serialized.
181
+ """
182
+ queryset = list(self.get_queryset())
183
+ payload = serialize_credential_summaries(request.user, queryset)
184
+ return Response(CredentialSummarySerializer(payload, many=True).data)
185
+
186
+ @extend_schema(
187
+ request=CredentialUpsertSerializer,
188
+ responses={
189
+ 200: CredentialSummarySerializer(many=True),
190
+ 422: CredentialVerificationErrorSerializer,
191
+ },
192
+ )
193
+ @suppress_list_pagination
194
+ def create(self, request: Request, provider: str | None = None) -> Response:
195
+ """Connect or rotate the credential for ``provider``.
196
+
197
+ ``create`` is a deliberate misnomer here: connect-and-rotate are
198
+ the same upsert. The viewset uses ``create`` so the standard DRF
199
+ action set names the route ``POST /<provider>/`` rather than
200
+ ``POST /<provider>/rotate/``, which is what ADR-0049 §3 spells out.
201
+
202
+ Before persisting, the PAT is verified against the provider (#677):
203
+ we ping the provider's ``/user`` endpoint with the token so a wrong,
204
+ expired, wrong-scope, or wrong-host token is rejected here with 422
205
+ rather than silently accepted and discovered later by #637's status
206
+ fetch. The plaintext is never written to the DB on a failed verify —
207
+ ``upsert`` (and therefore encryption) is not reached.
208
+ """
209
+ provider_cls = TASK_LINK_PROVIDERS.get(provider) if provider is not None else None
210
+ if provider is None or provider_cls is None:
211
+ return Response(
212
+ {"detail": f"Unknown provider {provider!r}."},
213
+ status=status.HTTP_400_BAD_REQUEST,
214
+ )
215
+ serializer = CredentialUpsertSerializer(data=request.data)
216
+ serializer.is_valid(raise_exception=True)
217
+ secret = serializer.validated_data["secret"]
218
+ base_url = serializer.validated_data.get("base_url", "")
219
+
220
+ # Gate the host BEFORE verify_token: verify ships the PAT to base_url, so
221
+ # an attacker-controlled host must be rejected before the token is on the
222
+ # wire (#902), not merely have its 422 surfaced after exfiltration.
223
+ try:
224
+ providers.assert_base_url_allowed(provider, base_url)
225
+ except providers.BaseUrlNotAllowed as exc:
226
+ return Response(
227
+ # codeql[py/stack-trace-exposure] -- intentional user-facing validation message
228
+ {"detail": str(exc), "code": "base_url_not_allowed"},
229
+ status=status.HTTP_400_BAD_REQUEST,
230
+ )
231
+
232
+ provider_handler = cast("type[TaskLinkProvider]", provider_cls)
233
+ result = provider_handler.verify_token(secret, base_url=base_url or None)
234
+ if not result.ok:
235
+ return Response(
236
+ {
237
+ "detail": _VERIFY_FAILURE_DETAIL.get(
238
+ result.reason,
239
+ f"Could not verify the credential with {provider}.",
240
+ ),
241
+ "code": "provider_verification_failed",
242
+ "reason": result.reason,
243
+ },
244
+ status=status.HTTP_422_UNPROCESSABLE_ENTITY,
245
+ )
246
+
247
+ IntegrationCredential.upsert(
248
+ user=request.user,
249
+ provider=provider,
250
+ secret=secret,
251
+ base_url=base_url,
252
+ expires_at=serializer.validated_data.get("expires_at"),
253
+ )
254
+ # Re-render the summary list so the client doesn't need a second
255
+ # request to refresh after a connect / rotate.
256
+ queryset = list(self.get_queryset())
257
+ payload = serialize_credential_summaries(request.user, queryset)
258
+ return Response(
259
+ CredentialSummarySerializer(payload, many=True).data,
260
+ status=status.HTTP_200_OK,
261
+ )
262
+
263
+ @extend_schema(
264
+ responses={
265
+ 204: None,
266
+ 400: state_refusal_400(
267
+ "``provider`` does not name a registered task-link provider. The "
268
+ "path segment is free text, so an unregistered value reaches the "
269
+ "handler and is refused here rather than 404ing (#3319)."
270
+ ),
271
+ }
272
+ )
273
+ def destroy(self, request: Request, provider: str | None = None) -> Response:
274
+ """Revoke (delete) the credential for ``provider``.
275
+
276
+ Returns 204 even if no row exists — DELETE is idempotent. Clients
277
+ polling the list after a revoke see ``exists=False`` either way.
278
+ """
279
+ if provider is None or TASK_LINK_PROVIDERS.get(provider) is None:
280
+ return Response(
281
+ {"detail": f"Unknown provider {provider!r}."},
282
+ status=status.HTTP_400_BAD_REQUEST,
283
+ )
284
+ # filter().delete() rather than get()+delete() so DELETE on a
285
+ # never-connected provider is a 204 no-op rather than a 404.
286
+ self.get_queryset().filter(provider=provider).delete()
287
+ return Response(status=status.HTTP_204_NO_CONTENT)
288
+
289
+ @extend_schema(responses={200: CredentialSummarySerializer})
290
+ def retrieve(self, request: Request, provider: str | None = None) -> Response:
291
+ """Return the single-provider summary row.
292
+
293
+ Convenience endpoint for the deep-link case (``#github`` anchor on
294
+ the page) when the client wants a single row's freshness rather
295
+ than the full list.
296
+ """
297
+ if provider is None or TASK_LINK_PROVIDERS.get(provider) is None:
298
+ return Response(
299
+ {"detail": f"Unknown provider {provider!r}."},
300
+ status=status.HTTP_400_BAD_REQUEST,
301
+ )
302
+ queryset = list(self.get_queryset().filter(provider=provider))
303
+ payload = serialize_credential_summaries(request.user, queryset)
304
+ match = next((row for row in payload if row["provider"] == provider), None)
305
+ if match is None: # pragma: no cover — provider validated above
306
+ raise Http404
307
+ return Response(CredentialSummarySerializer(match).data)
308
+
309
+
310
+ @extend_schema(tags=["tasks"])
311
+ class TaskLinkViewSet(
312
+ ProjectScopedViewSet,
313
+ mixins.ListModelMixin,
314
+ mixins.CreateModelMixin,
315
+ mixins.RetrieveModelMixin,
316
+ mixins.UpdateModelMixin,
317
+ mixins.DestroyModelMixin,
318
+ viewsets.GenericViewSet[TaskLink],
319
+ ):
320
+ """Git/PM links on a task (ADR-0049 §3, #637; redesign #970).
321
+
322
+ Routes (relative to ``/projects/{project_pk}/tasks/{task_pk}/``):
323
+ GET links/
324
+ POST links/ (body ``{url, custom_title?, labels?}``; provider detected)
325
+ GET links/{pk}/
326
+ PATCH links/{pk}/ (edit custom_title / labels / url / display_order)
327
+ DELETE links/{pk}/ (soft-delete)
328
+ POST links/{pk}/refresh/ (synchronous, 5s; refresh cached status)
329
+
330
+ Permissions: create/update/destroy follow task-edit (Member+, ``IsProjectMemberWrite``);
331
+ list/retrieve/refresh follow task-read (Viewer+, ``IsProjectMember``). The
332
+ queryset is the IDOR boundary — it scopes to the caller's project membership
333
+ and the task in the URL, so a link id from another project is a 404.
334
+ """
335
+
336
+ serializer_class = TaskLinkSerializer
337
+ # A task carries a handful of links — return the bare array (matching the
338
+ # client contract and the credentials viewset) rather than a paged envelope.
339
+ pagination_class = None
340
+ # PATCH-only edits (custom_title/labels/url). A full PUT replace of the
341
+ # server-owned status/provider/title fields is meaningless, so drop it.
342
+ http_method_names = ["get", "post", "patch", "delete", "head", "options"]
343
+
344
+ def get_queryset(self) -> QuerySet[TaskLink]:
345
+ """Scope to the caller's project membership + the task in the URL.
346
+
347
+ Explicit membership check (rather than ``ProjectScopedViewSet``'s
348
+ ``project``/``predecessor`` auto-filter) because ``TaskLink`` reaches
349
+ the project via ``task__project`` — the same pattern as
350
+ ``TaskAttachmentViewSet``.
351
+ """
352
+ user = self.request.user
353
+ if not user.is_authenticated:
354
+ return TaskLink.objects.none()
355
+ project_pk = self.kwargs["project_pk"]
356
+ task_pk = self.kwargs["task_pk"]
357
+ if not ProjectMembership.objects.filter(
358
+ user=user, project_id=project_pk, is_deleted=False
359
+ ).exists():
360
+ return TaskLink.objects.none()
361
+ return TaskLink.objects.filter(
362
+ task__project_id=project_pk,
363
+ task_id=task_pk,
364
+ is_deleted=False,
365
+ ).select_related("task")
366
+
367
+ def get_permissions(self) -> list[BasePermission]:
368
+ if self.action in ("create", "update", "partial_update", "destroy"):
369
+ return [IsAuthenticated(), IsProjectMemberWrite(), IsProjectNotArchived()]
370
+ return [IsAuthenticated(), IsProjectMember(), IsProjectNotArchived()]
371
+
372
+ def get_throttles(self) -> list[BaseThrottle]:
373
+ """Rate-limit only the refresh action — it makes an outbound fetch (#571).
374
+
375
+ The cloud-file unfurl is an anonymous outbound GET, so refresh is a
376
+ potential egress amplifier; the per-user throttle caps the call rate on
377
+ top of the SSRF guard. CRUD reads/writes touch only the DB and stay
378
+ un-throttled.
379
+ """
380
+ if self.action == "refresh":
381
+ from .throttles import TaskLinkRefreshThrottle
382
+
383
+ return [TaskLinkRefreshThrottle()]
384
+ return super().get_throttles()
385
+
386
+ def _get_task(self) -> Task:
387
+ return get_object_or_404(
388
+ Task,
389
+ pk=self.kwargs["task_pk"],
390
+ project_id=self.kwargs["project_pk"],
391
+ is_deleted=False,
392
+ )
393
+
394
+ def perform_create(self, serializer: BaseSerializer[TaskLink]) -> None:
395
+ """Create a link, resolving its provider server-side from the URL.
396
+
397
+ ``provider`` is never trusted from the client — it is resolved against
398
+ the SaaS hosts and the caller's connected self-hosted ``base_url`` hosts.
399
+ Status starts ``unknown`` (no fetch on add, per ADR-0049 §3).
400
+ """
401
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
402
+
403
+ task = self._get_task()
404
+ self.check_object_permissions(self.request, task)
405
+ url = serializer.validated_data["url"]
406
+ provider = providers.resolve_provider_key(url, user=self.request.user)
407
+ # created_by records whose authority may later persist provider-fetched
408
+ # metadata onto this shared row (see ``refresh``, #4081).
409
+ instance = serializer.save(task=task, provider=provider, created_by=self.request.user)
410
+ link_id = str(instance.pk)
411
+ task_id = str(task.pk)
412
+ project_id = str(task.project_id)
413
+ transaction.on_commit(
414
+ lambda: broadcast_board_event(
415
+ project_id, "task_link_created", {"id": link_id, "task_id": task_id}
416
+ )
417
+ )
418
+
419
+ def perform_update(self, serializer: BaseSerializer[TaskLink]) -> None:
420
+ """Edit a link's user fields (custom_title / labels / url) (#970).
421
+
422
+ If the ``url`` itself changes the provider is re-resolved server-side
423
+ (same rule as create — never trusted from the client). Editing only
424
+ ``custom_title`` / ``labels`` / ``display_order`` leaves the cached
425
+ fetch fields and ``created_by`` untouched. Broadcasts
426
+ ``task_link_updated`` on commit so other board viewers see the edit live.
427
+
428
+ A changed ``url`` makes the editor the link's owner and drops the cached
429
+ fetch (#4081). ``created_by`` is whose refresh may persist
430
+ credential-fetched metadata onto this shared row; the creator chose the
431
+ *old* URL, not this one. Keeping them as owner would let any Member
432
+ repoint a colleague's link and have the colleague's next Refresh fetch
433
+ the attacker's URL with the colleague's token and broadcast the result
434
+ to the project. The cached title/status/preview described the old URL,
435
+ so they are cleared rather than shown against the new one.
436
+ """
437
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
438
+
439
+ update_kwargs: dict[str, object] = {}
440
+ new_url = serializer.validated_data.get("url")
441
+ instance = serializer.instance
442
+ if new_url is not None and (instance is None or new_url != instance.url):
443
+ update_kwargs.update(
444
+ provider=providers.resolve_provider_key(new_url, user=self.request.user),
445
+ created_by=self.request.user,
446
+ status=LINK_STATUS_UNKNOWN,
447
+ title="",
448
+ description="",
449
+ thumbnail_url="",
450
+ preview_type="",
451
+ fetched_at=None,
452
+ )
453
+ link = serializer.save(**update_kwargs)
454
+ link_id = str(link.pk)
455
+ task_id = str(link.task_id)
456
+ project_id = str(link.task.project_id)
457
+ transaction.on_commit(
458
+ lambda: broadcast_board_event(
459
+ project_id, "task_link_updated", {"id": link_id, "task_id": task_id}
460
+ )
461
+ )
462
+
463
+ def perform_destroy(self, instance: TaskLink) -> None:
464
+ """Soft-delete so the removal reaches mobile as a sync tombstone."""
465
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
466
+
467
+ instance.soft_delete()
468
+ link_id = str(instance.pk)
469
+ # instance.task is select_related-loaded by get_queryset, so .pk is free
470
+ # and avoids the django-stubs task_id descriptor gap.
471
+ task_id = str(instance.task.pk)
472
+ project_id = str(instance.task.project_id)
473
+ transaction.on_commit(
474
+ lambda: broadcast_board_event(
475
+ project_id, "task_link_deleted", {"id": link_id, "task_id": task_id}
476
+ )
477
+ )
478
+
479
+ @extend_schema(
480
+ request=None,
481
+ responses={200: TaskLinkSerializer, 422: TaskLinkCredentialRequiredSerializer},
482
+ )
483
+ @action(detail=True, methods=["post"])
484
+ def refresh(
485
+ self,
486
+ request: Request,
487
+ project_pk: str | None = None,
488
+ task_pk: str | None = None,
489
+ pk: str | None = None,
490
+ ) -> Response:
491
+ """Refresh the cached status/title from the provider (synchronous, 5s).
492
+
493
+ Read-permission action (a Viewer can refresh). If the provider needs a
494
+ PAT and the caller has not connected one, returns 422 ``credential_required``
495
+ so the UI can prompt a connect rather than silently leaving the link
496
+ ``unknown``. Transport/parse failures degrade the status to ``unknown``
497
+ — ``fetch_metadata`` does not raise for an unreachable provider.
498
+ """
499
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
500
+
501
+ user = request.user
502
+ if not user.is_authenticated:
503
+ return Response(status=status.HTTP_403_FORBIDDEN)
504
+
505
+ link = self.get_object()
506
+ handler_cls = TASK_LINK_PROVIDERS.get(link.provider)
507
+ if handler_cls is None:
508
+ # Provider un-registered (Enterprise downgraded / plugin removed) —
509
+ # nothing to fetch; return the row unchanged rather than 500ing.
510
+ return Response(TaskLinkSerializer(link).data)
511
+
512
+ requires_credential = getattr(handler_cls, "requires_credential", True)
513
+ credential = IntegrationCredential.objects.filter(user=user, provider=link.provider).first()
514
+ if requires_credential and credential is None:
515
+ return Response(
516
+ {
517
+ "detail": f"Connect your {link.provider} account to refresh this link.",
518
+ "code": "credential_required",
519
+ "provider": link.provider,
520
+ "requires_credential": True,
521
+ },
522
+ status=status.HTTP_422_UNPROCESSABLE_ENTITY,
523
+ )
524
+
525
+ handler = cast("type[TaskLinkProvider]", handler_cls)()
526
+ metadata = handler.fetch_metadata(link.url, credential)
527
+ # Confused-deputy guard (#4081): metadata fetched with a credential is
528
+ # authorized by *this caller's* token but the row is shared with the whole
529
+ # project. Persist (and broadcast) only when the caller is the link's
530
+ # creator — who chose the URL — or the provider needs no credential
531
+ # (public metadata). Anyone else gets the fetched values in the response
532
+ # body only, so a Member cannot plant a URL and harvest a victim's
533
+ # private title/status by waiting for them to click Refresh.
534
+ persist = (not requires_credential) or (
535
+ link.created_by_id is not None and link.created_by_id == user.pk
536
+ )
537
+ link.status = metadata.status
538
+ if metadata.title:
539
+ link.title = metadata.title
540
+ # Cloud-file preview cache (#571, ADR-0163). A file provider returns these;
541
+ # git/generic providers leave them None and the row's empty defaults stand.
542
+ # Coalesce None → "" so the column type (CharField/URLField) is satisfied
543
+ # and a provider that returns no preview clears any stale one.
544
+ link.description = metadata.description or ""
545
+ link.thumbnail_url = metadata.thumbnail_url or ""
546
+ link.preview_type = metadata.preview_type or ""
547
+ link.fetched_at = timezone.now()
548
+
549
+ if credential is not None:
550
+ credential.last_used_at = timezone.now()
551
+ credential.save(update_fields=["last_used_at"])
552
+
553
+ if not persist:
554
+ # Transient view: the in-memory row is serialized but never saved.
555
+ return Response(TaskLinkSerializer(link).data)
556
+
557
+ with transaction.atomic():
558
+ # The fetch above can take seconds. If another member repointed the
559
+ # link meanwhile, its url and owner moved on; writing this result
560
+ # would stamp the old target's metadata onto the new URL and broadcast
561
+ # it. Re-check under a row lock and fall back to the transient view.
562
+ current = (
563
+ TaskLink.objects.select_for_update()
564
+ .filter(pk=link.pk)
565
+ .values("url", "created_by_id")
566
+ .first()
567
+ )
568
+ if (
569
+ current is None
570
+ or current["url"] != link.url
571
+ or current["created_by_id"] != link.created_by_id
572
+ ):
573
+ return Response(TaskLinkSerializer(link).data)
574
+ # VersionedModel.save() bumps server_version atomically; we pass the
575
+ # changed fields only and let it handle the version bump + sync delta.
576
+ link.save(
577
+ update_fields=[
578
+ "status",
579
+ "title",
580
+ "description",
581
+ "thumbnail_url",
582
+ "preview_type",
583
+ "fetched_at",
584
+ ]
585
+ )
586
+
587
+ link_id = str(link.pk)
588
+ task_id = str(link.task_id)
589
+ project_id = str(link.task.project_id)
590
+ transaction.on_commit(
591
+ lambda: broadcast_board_event(
592
+ project_id, "task_link_updated", {"id": link_id, "task_id": task_id}
593
+ )
594
+ )
595
+ return Response(TaskLinkSerializer(link).data)
596
+
597
+
598
+ def _git_webhook_url(request: Request, project_pk: Any) -> str:
599
+ """Absolute URL of a project's inbound Git-webhook receiver (admin pastes this).
600
+
601
+ Prefers the explicit ``TRUEPPM_PUBLIC_API_BASE_URL`` for the same reason
602
+ ``sso.views._derive_redirect_uri`` does (#3515): this value leaves the system
603
+ — an admin copies it into GitHub/GitLab — so it must be the origin the
604
+ operator chose, not the ``Host`` header of whoever happened to ask. Behind a
605
+ proxy that rewrites ``Host`` the request-derived form yields an unreachable
606
+ internal name and fails silently forever, since the wrong URL only shows up
607
+ as webhooks that never arrive. Falls back to the request's absolute URI for
608
+ zero-config single-origin dev, where the two agree.
609
+ """
610
+ from django.conf import settings
611
+ from django.urls import reverse
612
+
613
+ path = reverse("git-webhook", kwargs={"project_pk": str(project_pk)})
614
+ base = (getattr(settings, "TRUEPPM_PUBLIC_API_BASE_URL", "") or "").rstrip("/")
615
+ if base:
616
+ return f"{base}{path}"
617
+ return request.build_absolute_uri(path)
618
+
619
+
620
+ class _WebhookRefusal(Exception):
621
+ """A pre-verification refusal and the reason for it (#2881).
622
+
623
+ Internal control flow, never surfaced: the receiver catches it and answers with
624
+ the one uniform 404 that every refusal shares, after recording ``outcome`` where
625
+ only a project admin can read it.
626
+ """
627
+
628
+ def __init__(
629
+ self, outcome: str, automation_id: Any = None, provider: str | None = None
630
+ ) -> None:
631
+ super().__init__(outcome)
632
+ self.outcome = outcome
633
+ # ``None`` when there is no row to record against (no project, or automation
634
+ # never configured at all) — then the receiver log is the only record.
635
+ self.automation_id = automation_id
636
+ self.provider = provider
637
+
638
+
639
+ def _record_git_delivery(automation_id: Any, provider: str | None, outcome: str) -> None:
640
+ """Stamp the last **verified** delivery on a ``BoardAutomation`` row (#2882).
641
+
642
+ Only reached once the signature has verified, which is the whole point — see
643
+ :func:`_record_git_refusal` for why the two are separate columns.
644
+
645
+ A queryset ``.update()`` rather than ``instance.save()`` on purpose: ``save()``
646
+ would fire ``auto_now`` on ``updated_at``, and that field means "an admin last
647
+ changed this config" — a webhook arriving must not look like a config change on
648
+ the settings card. One UPDATE, no model state reload, no signals.
649
+ """
650
+ BoardAutomation.objects.filter(pk=automation_id).update(
651
+ last_delivery_at=timezone.now(),
652
+ last_delivery_outcome=outcome[:32],
653
+ last_delivery_provider=(provider or "")[:16],
654
+ )
655
+
656
+
657
+ def _record_git_refusal(automation_id: Any, provider: str | None, outcome: str) -> None:
658
+ """Stamp the last **refused** delivery, in its own columns (#2882).
659
+
660
+ Deliberately not the same columns as :func:`_record_git_delivery`, and the reason
661
+ is not tidiness. A refusal is recorded *before* the signature is checked, so
662
+ anyone who knows the project's UUID — which is not a secret; every project Viewer
663
+ reads it out of the SPA URL, and that is #2881's own premise — can force one at
664
+ will with two forged headers. Sharing one column would hand that caller two
665
+ things: the power to erase the genuine ``no_link`` outcome an admin is in the
666
+ middle of diagnosing, and a UI that then tells the admin their secret is wrong and
667
+ to rotate it — taking down a working integration on an unauthenticated party's
668
+ say-so. Split, a hostile caller can only ever write to the row that is already
669
+ labeled "unauthenticated, may not be your provider".
670
+ """
671
+ BoardAutomation.objects.filter(pk=automation_id).update(
672
+ last_refusal_at=timezone.now(),
673
+ last_refusal_outcome=outcome[:32],
674
+ last_refusal_provider=(provider or "")[:16],
675
+ )
676
+
677
+
678
+ def _client_ident(request: Request) -> str:
679
+ """Client identity for the receiver log, resolved the way the throttle resolves it.
680
+
681
+ Reuses ``BaseThrottle.get_ident`` so the logged address is the same
682
+ ``NUM_PROXIES``-aware value the per-IP bucket keys on — logging a raw
683
+ ``REMOTE_ADDR`` would name the ingress on every line, and logging a raw
684
+ ``X-Forwarded-For`` would name whatever the caller put there.
685
+ """
686
+ from .throttles import GitWebhookIpThrottle
687
+
688
+ try:
689
+ return str(GitWebhookIpThrottle().get_ident(request) or "-")
690
+ except Exception: # pragma: no cover - diagnostics must never break the response
691
+ return "-"
692
+
693
+
694
+ def _provider_delivery_id(request: Request) -> str:
695
+ """The provider's own delivery id, so a log line can be matched to their UI.
696
+
697
+ GitHub stamps ``X-GitHub-Delivery``; GitLab stamps ``X-Gitlab-Event-UUID``.
698
+ Truncated because it is attacker-controlled on a refusal — it is a correlation
699
+ hint, not evidence.
700
+ """
701
+ raw = request.headers.get("X-GitHub-Delivery") or request.headers.get("X-Gitlab-Event-UUID")
702
+ return str(raw)[:64] if raw else ""
703
+
704
+
705
+ def _webhook_body_within_cap(request: Request) -> bool:
706
+ """Whether the request body is inside :data:`_WEBHOOK_MAX_BODY_BYTES`.
707
+
708
+ Checks the declared ``Content-Length`` **first**, so an oversized delivery is
709
+ refused before this process copies it anywhere — the old code read ``request.body``
710
+ as its first statement and materialized up to the global 100 MB cap before it even
711
+ knew whether the project had automation.
712
+
713
+ Be precise about what this does and does not buy, because the obvious reading is
714
+ too strong. Under ASGI, Django assembles the request body into a spooled temporary
715
+ file during request setup, before any middleware, throttle, or view code runs — so
716
+ the bytes have already arrived by the time this executes, and no view-level check
717
+ can prevent that. What the cap removes is the *second*, in-memory copy and every
718
+ allocation downstream of it (HMAC over the full body, JSON parse, envelope). The
719
+ wire-level ceiling is the ingress ``proxy-body-size``, and bounding an oversized
720
+ delivery before it reaches the pod is an edge-config job, tracked separately.
721
+
722
+ Both providers always send ``Content-Length``. The ``request.body`` length check
723
+ behind it is the net for a chunked or header-less caller, where measuring the bytes
724
+ that actually arrived is the only option available (same trade-off as the
725
+ seed-import reader) — that path does take the in-memory copy before refusing.
726
+ """
727
+ try:
728
+ declared = int(request.META.get("CONTENT_LENGTH") or 0)
729
+ except (TypeError, ValueError):
730
+ declared = 0
731
+ if declared > _WEBHOOK_MAX_BODY_BYTES:
732
+ return False
733
+ return len(request.body) <= _WEBHOOK_MAX_BODY_BYTES
734
+
735
+
736
+ @extend_schema(
737
+ tags=["integrations"],
738
+ # Not ``None`` (#3364). ``request=None`` published "this endpoint accepts no
739
+ # body" while ``post`` reads ``request.data`` and refuses a non-object — a
740
+ # generated client had no parameter to send the provider payload through at all.
741
+ # See GIT_WEBHOOK_PAYLOAD_SCHEMA for why this is an open object rather than a
742
+ # serializer: the shape belongs to GitHub/GitLab, not to us.
743
+ #
744
+ # Keyed by media type, NOT passed bare. ``responses=`` accepts a raw schema dict
745
+ # (which is why the sibling GIT_WEBHOOK_RESULT_SCHEMA below is written that way),
746
+ # but ``request=`` does not: handed a bare dict it reads the dict's own top-level
747
+ # keys as media types, so ``type``/``additionalProperties``/``description`` became
748
+ # three bogus content entries and the real schema was replaced by drf-spectacular's
749
+ # "Unspecified request body" placeholder. It fails silently — the operation still
750
+ # has a ``requestBody``, so it looks declared from every angle except reading the
751
+ # generated document.
752
+ request={"application/json": GIT_WEBHOOK_PAYLOAD_SCHEMA},
753
+ responses={
754
+ 200: GIT_WEBHOOK_RESULT_SCHEMA,
755
+ 400: OpenApiResponse(description="Malformed webhook payload (signature verified)."),
756
+ 404: OpenApiResponse(
757
+ description=(
758
+ "The uniform refusal for every pre-verification failure — no automation, "
759
+ "an archived project, disabled, no secret set, unrecognized provider, or "
760
+ "an invalid signature. "
761
+ "Deliberately indistinguishable so the endpoint cannot be used to discover "
762
+ "which projects have automation configured."
763
+ )
764
+ ),
765
+ 413: OpenApiResponse(description="Payload above the 1 MB webhook body cap."),
766
+ },
767
+ )
768
+ class GitWebhookIngestView(IdempotencyMixin, APIView):
769
+ """Inbound Git-event receiver — ``POST .../{project_pk}/git-webhook/`` (#329, ADR-0158).
770
+
771
+ The OSS Git-event board-card auto-move receiver. **The signature is the gate** —
772
+ this endpoint is unauthenticated-by-session (``AllowAny``); a GitHub
773
+ ``X-Hub-Signature-256`` HMAC or a GitLab ``X-Gitlab-Token`` over the project's
774
+ secret is what authorizes the call.
775
+
776
+ **Every pre-verification refusal is the same bare 404** — no automation row, a
777
+ disabled toggle, no secret set, an unrecognized provider, an undecryptable
778
+ secret, and a bad or missing signature are byte-for-byte indistinguishable to the
779
+ caller. This is stricter than the original design, which used 404 for
780
+ "not configured" and 401 for "bad signature" and so published exactly the fact
781
+ the 404 existed to hide: with a project UUID (which every project Viewer has,
782
+ straight out of the SPA URL) a 401 meant *automation is enabled and a secret is
783
+ set*, admin-only state that ``GitAutomationConfigView`` gates at Owner/Admin. A
784
+ non-ASCII byte in the signature header made it worse still by raising an
785
+ unhandled ``TypeError``, so a 500 read as the same disclosure (#2881).
786
+
787
+ The refusal *reason* is not thrown away — it goes to the structured receiver log
788
+ and to ``BoardAutomation.last_delivery_*``, which the admin-only config GET
789
+ returns and the settings card renders. Both require the reader to already be an
790
+ Owner/Admin of that project, which is the difference that matters.
791
+
792
+ Two refusals stay distinct on purpose, because neither depends on project state
793
+ and so neither can leak it: **413** for a body over
794
+ :data:`_WEBHOOK_MAX_BODY_BYTES` (checked before the body is buffered at all) and
795
+ **429** from the throttles. Post-verification responses are informative too — the
796
+ caller has already proven it holds the secret, so there is nothing left to hide.
797
+
798
+ OSS boundary (ADR-0097 carve-out, mirroring ADR-0148): a single project-scoped,
799
+ off-by-default, one-way Git→card receiver. No org connector, no OAuth, no
800
+ bidirectional sync, no conflict resolution, no reconciliation loop. The general
801
+ multi-provider bidirectional Integration Hub remains Enterprise.
802
+ """
803
+
804
+ from .throttles import GitWebhookIpThrottle, GitWebhookThrottle
805
+
806
+ # Exempt from the HTTP idempotency model: an inbound webhook carries no JWT user
807
+ # to key the Idempotency-Key store on. Replay safety comes from two purpose-built
808
+ # layers instead — the Redis SET NX EX delivery claim and the forward-only status
809
+ # guard (a redelivered move is a no-op even if Redis is unavailable).
810
+ idempotency_exempt = True
811
+ authentication_classes: list[type] = []
812
+ permission_classes = [AllowAny]
813
+
814
+ # There is no principal on this route to hold a membership, so the role x route
815
+ # matrix (#3441) cannot judge it the way it judges every other project-scoped URL.
816
+ # The constant-time HMAC comparison over the raw body against the project's own
817
+ # stored secret is what stands in for membership: possession of that secret is the
818
+ # authorization, and it is scoped to exactly the one project the URL names.
819
+ role_gate_exempt = (
820
+ "Unauthenticated by design: the per-project HMAC signature over the raw body "
821
+ "is the gate, and it authorizes exactly the project the URL names. No "
822
+ "principal exists on this request to hold a membership (ADR-0158, #2881)."
823
+ )
824
+ # STACKED, and both are needed. The per-project bucket keys on a ``project_pk``
825
+ # the caller picks out of the URL, so rotating it defeated the limit entirely;
826
+ # the per-IP bucket closes that. Declaring ``throttle_classes`` here also
827
+ # replaces ``DEFAULT_THROTTLE_CLASSES``, which is why the global anon throttle
828
+ # never applied and the per-IP class has to be named explicitly (#2881).
829
+ throttle_classes = [GitWebhookThrottle, GitWebhookIpThrottle]
830
+
831
+ def _authorize(
832
+ self, request: Request, project_pk: str, raw_body: bytes
833
+ ) -> tuple[BoardAutomation, str, str]:
834
+ """Resolve the automation row, provider, and secret, or raise ``_WebhookRefusal``.
835
+
836
+ Every failure funnels through one exception type carrying its own outcome
837
+ token, which is what makes the indistinguishability property hold by
838
+ construction: there is a single ``Response`` literal for all six refusals
839
+ instead of six that have to stay in sync with each other.
840
+ """
841
+ # Fetch the row regardless of ``enabled`` so a disabled toggle and a missing
842
+ # secret can be *recorded* (they are the two most common "I followed every
843
+ # step and nothing moves" causes) even though both still answer 404. Scope
844
+ # out soft-deleted projects.
845
+ automation = (
846
+ BoardAutomation.objects.filter(
847
+ project_id=project_pk,
848
+ project__is_deleted=False,
849
+ )
850
+ # The archived flag rides the join the ``project__is_deleted`` filter
851
+ # already forces, so this costs one extra column and no extra query — it
852
+ # deliberately does NOT ``select_related("project")``, for the 51-unused-
853
+ # columns reason recorded just below (#3414).
854
+ .annotate(project_is_archived=F("project__is_archived"))
855
+ # ``configured_by`` — not ``project``. ``project`` is never dereferenced
856
+ # on this path (the filter needs the join, not the columns) and pulling it
857
+ # cost 51 unused columns on every anonymous POST, including the ones that
858
+ # go on to 404. ``configured_by`` IS dereferenced, on every successful
859
+ # move, and was the lazy one.
860
+ .select_related("configured_by")
861
+ .first()
862
+ )
863
+ provider = git_webhook_auth.detect_provider(request.headers)
864
+
865
+ if automation is None:
866
+ # Nothing to record against — no project, or automation never touched.
867
+ raise _WebhookRefusal("no_automation", provider=provider)
868
+ if automation.project_is_archived:
869
+ # Archived projects are hard read-only (#530), and moving a board card is a
870
+ # write. The receiver cannot be gated with ``IsProjectNotArchived``: this
871
+ # endpoint is ``AllowAny`` and a permission-class 403 would publish archived
872
+ # state — admin-only state — to anyone holding the project UUID, re-opening
873
+ # the disclosure #2881 closed. Refusing through the same funnel keeps the
874
+ # response byte-identical to every other refusal while still recording the
875
+ # reason where an Owner/Admin can read it.
876
+ raise _WebhookRefusal(GIT_DELIVERY_PROJECT_ARCHIVED, automation.pk, provider)
877
+ if not automation.enabled:
878
+ raise _WebhookRefusal(GIT_DELIVERY_DISABLED, automation.pk, provider)
879
+ if not automation.has_secret:
880
+ raise _WebhookRefusal(GIT_DELIVERY_NO_SECRET, automation.pk, provider)
881
+ if provider is None:
882
+ raise _WebhookRefusal(GIT_DELIVERY_UNKNOWN_PROVIDER, automation.pk, provider)
883
+
884
+ try:
885
+ secret = decrypt_secret(automation.secret_ciphertext)
886
+ except (CredentialEncryptionError, ImproperlyConfigured, ValueError) as exc:
887
+ # Every way the stored secret can fail to come back, not just one. The
888
+ # obvious arm is CredentialEncryptionError (key rotated without
889
+ # re-encrypt), but ``decrypt_secret`` also raises ImproperlyConfigured
890
+ # when INTEGRATION_ENCRYPTION_KEY is unset and ValueError when it is
891
+ # malformed — the *same* operator scenario, and both would have escaped
892
+ # this funnel as an unhandled 500. That matters beyond the crash: a 500 is
893
+ # only reachable on a project that has automation with a secret set, which
894
+ # is precisely the disclosure #2881 exists to remove. Leaving two
895
+ # exception types out would have re-opened it one type over.
896
+ raise _WebhookRefusal(GIT_DELIVERY_SECRET_UNREADABLE, automation.pk, provider) from exc
897
+
898
+ try:
899
+ git_webhook_auth.verify_signature(provider, secret, raw_body, request.headers)
900
+ except git_webhook_auth.WebhookSignatureError as exc:
901
+ raise _WebhookRefusal(GIT_DELIVERY_BAD_SIGNATURE, automation.pk, provider) from exc
902
+
903
+ return automation, provider, secret
904
+
905
+ def post(self, request: Request, project_pk: str) -> Response:
906
+ from .throttles import claim_webhook_delivery
907
+
908
+ # Bound the body BEFORE anything reads or allocates it.
909
+ if not _webhook_body_within_cap(request):
910
+ logger.warning("git webhook refused: project=%s reason=payload_too_large", project_pk)
911
+ return Response(
912
+ {"detail": "Webhook payload too large."},
913
+ status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
914
+ )
915
+
916
+ # Read the raw body BEFORE touching request.data — the HMAC must be checked
917
+ # against the exact bytes the provider signed, and accessing request.data
918
+ # consumes the stream.
919
+ raw_body = request.body
920
+
921
+ try:
922
+ automation, provider, _secret = self._authorize(request, project_pk, raw_body)
923
+ except _WebhookRefusal as refusal:
924
+ if refusal.automation_id is not None:
925
+ _record_git_refusal(refusal.automation_id, refusal.provider, refusal.outcome)
926
+ # Carry the client identity and the provider's own delivery id. Without
927
+ # them neither surface answers "who sent this?", so an admin staring at
928
+ # "Signature rejected" cannot tell a misconfigured provider from someone
929
+ # poking a public endpoint — and the two call for opposite actions.
930
+ logger.warning(
931
+ "git webhook refused: project=%s provider=%s reason=%s client=%s delivery=%s",
932
+ project_pk,
933
+ refusal.provider or "unknown",
934
+ refusal.outcome,
935
+ _client_ident(request),
936
+ _provider_delivery_id(request) or "-",
937
+ )
938
+ # Returned, NOT ``raise Http404``. DRF's exception handler calls
939
+ # ``set_rollback()`` for every APIException, and ``ATOMIC_REQUESTS`` is on
940
+ # — so raising here would roll the delivery record back out of existence
941
+ # and the whole diagnostic surface would silently record nothing. The body
942
+ # is byte-identical to what DRF's ``NotFound`` renders.
943
+ return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
944
+
945
+ # --- Past the gate: the caller holds the secret, so be informative. -------
946
+ payload = request.data
947
+ if not isinstance(payload, dict):
948
+ _record_git_delivery(automation.pk, provider, GIT_DELIVERY_MALFORMED)
949
+ logger.warning(
950
+ "git webhook malformed payload: project=%s provider=%s", project_pk, provider
951
+ )
952
+ return Response(
953
+ {"detail": "Malformed webhook payload."},
954
+ status=status.HTTP_400_BAD_REQUEST,
955
+ )
956
+
957
+ envelope = git_webhook_auth.parse_envelope(provider, request.headers, payload)
958
+ if envelope.event is None:
959
+ # A configured-but-irrelevant event (push, comment, close) or a draft/WIP
960
+ # open, which deliberately does not promote the card. Not an error —
961
+ # return 2xx so the provider does not retry. ``reason`` distinguishes the
962
+ # two, because "I opened a draft and nothing happened" is a question
963
+ # someone will ask and the answer should not require reading Python.
964
+ outcome = envelope.ignored_reason or GIT_DELIVERY_IGNORED
965
+ _record_git_delivery(automation.pk, provider, outcome)
966
+ body: dict[str, Any] = {
967
+ "matched": False,
968
+ "moved": False,
969
+ "ignored": envelope.raw_event_name,
970
+ }
971
+ if envelope.ignored_reason:
972
+ body["reason"] = envelope.ignored_reason
973
+ return Response(body, status=status.HTTP_200_OK)
974
+
975
+ # Idempotency: a provider redelivery of the same event is a no-op. The
976
+ # forward-only guard in the service is a second, Redis-independent layer.
977
+ if not claim_webhook_delivery(project_pk, envelope.delivery_key):
978
+ _record_git_delivery(automation.pk, provider, GIT_DELIVERY_DUPLICATE)
979
+ return Response(
980
+ {"matched": False, "moved": False, "reason": "duplicate"},
981
+ status=status.HTTP_200_OK,
982
+ )
983
+
984
+ result = apply_git_event_to_card(automation, provider, envelope.event, envelope.pr_url)
985
+ _record_git_delivery(automation.pk, provider, result.reason)
986
+ if not result.matched:
987
+ # ``no_url`` / ``no_link``: the delivery verified but matched nothing.
988
+ # This is THE silent failure of the feature — the provider shows a green
989
+ # check and no card moves, because matching needs a TaskLink on the task
990
+ # pointing at the exact PR/MR URL and nobody created one.
991
+ logger.warning(
992
+ "git webhook matched no task: project=%s provider=%s event=%s reason=%s",
993
+ project_pk,
994
+ provider,
995
+ envelope.event,
996
+ result.reason,
997
+ )
998
+ return Response(
999
+ {
1000
+ "matched": result.matched,
1001
+ "moved": result.moved,
1002
+ "task": result.task_id,
1003
+ "from": result.from_status,
1004
+ "to": result.to_status,
1005
+ "reason": result.reason,
1006
+ },
1007
+ status=status.HTTP_200_OK,
1008
+ )
1009
+
1010
+
1011
+ @extend_schema(tags=["integrations"], responses={200: GitAutomationConfigSerializer})
1012
+ class GitAutomationConfigView(IdempotencyMixin, APIView):
1013
+ """``GET|PUT /api/v1/integrations/projects/{project_pk}/git-automation/`` — config (#329).
1014
+
1015
+ Project-admin only (Owner/Admin). Reads/sets the off-by-default toggle and
1016
+ surfaces the webhook URL and whether a secret is set. The secret itself is
1017
+ minted/rotated through :class:`GitAutomationRotateSecretView` and returned once.
1018
+ """
1019
+
1020
+ # The PUT sets ``enabled`` to an explicit value on the per-project singleton, so a
1021
+ # replay converges to the same state (naturally idempotent — no replayable resource).
1022
+ idempotency_exempt = True
1023
+ # IsProjectNotArchived gates the PUT only — the class returns True for GET/HEAD, so
1024
+ # an admin can still read an archived project's automation config. It fires in
1025
+ # `has_permission` because the route names `project_pk` (#3414).
1026
+ permission_classes = [IsAuthenticated, IsProjectAdmin, IsProjectNotArchived]
1027
+ # Reveals whether a webhook secret is set / lets an admin flip the toggle; scope it
1028
+ # under the shared credential bucket (#1551) so config-probing is rate-bounded.
1029
+ throttle_classes = [ScopedRateThrottle]
1030
+ throttle_scope = "credential_rotate"
1031
+
1032
+ def _config_payload(self, request: Request, automation: BoardAutomation) -> dict[str, Any]:
1033
+ return {
1034
+ "enabled": automation.enabled,
1035
+ "secret_set": automation.has_secret,
1036
+ "webhook_url": _git_webhook_url(request, automation.project_id),
1037
+ "configured_by": automation.configured_by_id,
1038
+ "secret_set_at": automation.secret_set_at,
1039
+ "updated_at": automation.updated_at,
1040
+ "last_delivery_at": automation.last_delivery_at,
1041
+ "last_delivery_outcome": automation.last_delivery_outcome,
1042
+ "last_delivery_provider": automation.last_delivery_provider,
1043
+ "last_refusal_at": automation.last_refusal_at,
1044
+ "last_refusal_outcome": automation.last_refusal_outcome,
1045
+ "last_refusal_provider": automation.last_refusal_provider,
1046
+ }
1047
+
1048
+ def _get_or_init(self, project_pk: str) -> BoardAutomation:
1049
+ project = get_object_or_404(Project, pk=project_pk, is_deleted=False)
1050
+ automation, _ = BoardAutomation.objects.get_or_create(project=project)
1051
+ return automation
1052
+
1053
+ def get(self, request: Request, project_pk: str) -> Response:
1054
+ automation = self._get_or_init(project_pk)
1055
+ data = GitAutomationConfigSerializer(self._config_payload(request, automation)).data
1056
+ return Response(data)
1057
+
1058
+ # The class-level ``@extend_schema`` covers GET and PUT alike and names no
1059
+ # request, so PUT published no ``requestBody`` while requiring ``enabled``
1060
+ # (#3364). Declared on the method, not the class, because GET must keep taking
1061
+ # no body. This is a plain ``APIView``: there is no ``serializer_class`` for
1062
+ # drf-spectacular to infer a writable shape from, so nothing supplies this but
1063
+ # the annotation.
1064
+ @extend_schema(
1065
+ request=GitAutomationUpdateSerializer,
1066
+ responses={
1067
+ 200: GitAutomationConfigSerializer,
1068
+ 403: OpenApiResponse(
1069
+ description=(
1070
+ "Caller is not a project Admin, or the project is archived (#3414). "
1071
+ "Declared on the method, not the class: the class-level annotation "
1072
+ "covers GET too, and GET stays available while archived."
1073
+ )
1074
+ ),
1075
+ },
1076
+ )
1077
+ def put(self, request: Request, project_pk: str) -> Response:
1078
+ serializer = GitAutomationUpdateSerializer(data=request.data)
1079
+ serializer.is_valid(raise_exception=True)
1080
+ automation = self._get_or_init(project_pk)
1081
+ automation.enabled = serializer.validated_data["enabled"]
1082
+ # request.user is a real User here (IsAuthenticated gates this view); the
1083
+ # cast narrows away the AnonymousUser arm the field FK does not accept.
1084
+ automation.configured_by = cast("Any", request.user)
1085
+ automation.save(update_fields=["enabled", "configured_by", "updated_at"])
1086
+ data = GitAutomationConfigSerializer(self._config_payload(request, automation)).data
1087
+ return Response(data)
1088
+
1089
+
1090
+ @extend_schema(
1091
+ tags=["integrations"],
1092
+ request=None,
1093
+ responses={
1094
+ 201: GitAutomationSecretSerializer,
1095
+ 403: OpenApiResponse(
1096
+ description=(
1097
+ "Caller is not a project Admin, or the project is archived (#3414) — "
1098
+ "minting a credential is a write. Nothing is stranded by the refusal: "
1099
+ "the receiver refuses an archived project's deliveries outright."
1100
+ )
1101
+ ),
1102
+ },
1103
+ )
1104
+ class GitAutomationRotateSecretView(IdempotencyMixin, APIView):
1105
+ """``POST .../git-automation/rotate-secret/`` — mint a new webhook secret (#329).
1106
+
1107
+ Project-admin only. Generates a fresh URL-safe secret, stores it Fernet-encrypted,
1108
+ and returns the plaintext **once** in the response — it is never retrievable again
1109
+ (the GET endpoint only reports whether a secret is set).
1110
+ """
1111
+
1112
+ # Exempt: the secret lives in a single column on the per-project singleton, so a
1113
+ # double rotation is last-write-wins — only one secret is ever active and a replay
1114
+ # cannot mint a parallel credential. This is a deliberate admin action, not a
1115
+ # retry-prone client mutation.
1116
+ idempotency_exempt = True
1117
+ # Minting a credential on an archived project is a write (#3414). Nothing is
1118
+ # stranded by refusing it: the receiver now refuses an archived project's
1119
+ # deliveries outright, so there is no live webhook left to rotate away from.
1120
+ # IsNotTokenAuthenticated (#2939): this mints a durable, plaintext-once
1121
+ # credential a leaked PAT should not be able to extend itself with — the
1122
+ # same rule #2878 applied to /me/api-tokens/.
1123
+ permission_classes = [
1124
+ IsAuthenticated,
1125
+ IsProjectAdmin,
1126
+ IsProjectNotArchived,
1127
+ IsNotTokenAuthenticated,
1128
+ ]
1129
+ # Mints and returns a fresh plaintext webhook secret; scope it under the shared
1130
+ # credential bucket (#1551) so secret rotation cannot be hammered.
1131
+ throttle_classes = [ScopedRateThrottle]
1132
+ throttle_scope = "credential_rotate"
1133
+
1134
+ def post(self, request: Request, project_pk: str) -> Response:
1135
+ project = get_object_or_404(Project, pk=project_pk, is_deleted=False)
1136
+ # 256 bits of entropy, URL-safe so it pastes cleanly into a provider's
1137
+ # webhook secret field.
1138
+ plaintext = secrets.token_urlsafe(32)
1139
+ with transaction.atomic():
1140
+ automation, _ = BoardAutomation.objects.get_or_create(project=project)
1141
+ automation.set_secret(plaintext)
1142
+ automation.configured_by = cast("Any", request.user)
1143
+ automation.save(
1144
+ update_fields=[
1145
+ "secret_ciphertext",
1146
+ "secret_set_at",
1147
+ "configured_by",
1148
+ "updated_at",
1149
+ ]
1150
+ )
1151
+ return Response(
1152
+ {
1153
+ "secret": plaintext,
1154
+ "webhook_url": _git_webhook_url(request, project.pk),
1155
+ "secret_set_at": automation.secret_set_at,
1156
+ },
1157
+ status=status.HTTP_201_CREATED,
1158
+ )