trueppm-api 0.4.0b2__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 (759) hide show
  1. trueppm_api/__init__.py +14 -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 +164 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -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 +167 -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_userdefinedmentiongroup.py +82 -0
  24. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  25. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  26. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  27. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
  28. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  29. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  30. trueppm_api/apps/access/migrations/__init__.py +0 -0
  31. trueppm_api/apps/access/models.py +588 -0
  32. trueppm_api/apps/access/permissions.py +2837 -0
  33. trueppm_api/apps/access/serializers.py +946 -0
  34. trueppm_api/apps/access/services.py +667 -0
  35. trueppm_api/apps/access/signals.py +103 -0
  36. trueppm_api/apps/access/tasks.py +50 -0
  37. trueppm_api/apps/access/throttles.py +95 -0
  38. trueppm_api/apps/access/urls.py +191 -0
  39. trueppm_api/apps/access/views.py +1931 -0
  40. trueppm_api/apps/agents/__init__.py +6 -0
  41. trueppm_api/apps/agents/apps.py +11 -0
  42. trueppm_api/apps/agents/canonical.py +73 -0
  43. trueppm_api/apps/agents/deferred.py +108 -0
  44. trueppm_api/apps/agents/management/__init__.py +0 -0
  45. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  46. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  47. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  48. trueppm_api/apps/agents/middleware.py +44 -0
  49. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  50. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  51. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  52. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  53. trueppm_api/apps/agents/models.py +371 -0
  54. trueppm_api/apps/agents/refusal.py +103 -0
  55. trueppm_api/apps/agents/serializers.py +85 -0
  56. trueppm_api/apps/agents/services.py +397 -0
  57. trueppm_api/apps/agents/signals.py +30 -0
  58. trueppm_api/apps/agents/urls.py +15 -0
  59. trueppm_api/apps/agents/views.py +108 -0
  60. trueppm_api/apps/csvimport/__init__.py +12 -0
  61. trueppm_api/apps/csvimport/apps.py +11 -0
  62. trueppm_api/apps/csvimport/mapping.py +363 -0
  63. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  64. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  65. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  66. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  67. trueppm_api/apps/csvimport/models.py +111 -0
  68. trueppm_api/apps/csvimport/parser.py +1825 -0
  69. trueppm_api/apps/csvimport/services.py +48 -0
  70. trueppm_api/apps/csvimport/tasks.py +418 -0
  71. trueppm_api/apps/csvimport/template.py +37 -0
  72. trueppm_api/apps/csvimport/urls.py +50 -0
  73. trueppm_api/apps/csvimport/views.py +793 -0
  74. trueppm_api/apps/history/__init__.py +0 -0
  75. trueppm_api/apps/history/apps.py +12 -0
  76. trueppm_api/apps/history/changelog.py +359 -0
  77. trueppm_api/apps/history/diff_policy.py +177 -0
  78. trueppm_api/apps/history/serializers.py +104 -0
  79. trueppm_api/apps/history/signals.py +82 -0
  80. trueppm_api/apps/history/tasks.py +105 -0
  81. trueppm_api/apps/history/urls.py +38 -0
  82. trueppm_api/apps/history/views.py +443 -0
  83. trueppm_api/apps/idempotency/__init__.py +0 -0
  84. trueppm_api/apps/idempotency/apps.py +11 -0
  85. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  86. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  87. trueppm_api/apps/idempotency/mixins.py +226 -0
  88. trueppm_api/apps/idempotency/models.py +75 -0
  89. trueppm_api/apps/idempotency/tasks.py +61 -0
  90. trueppm_api/apps/integrations/__init__.py +13 -0
  91. trueppm_api/apps/integrations/apps.py +59 -0
  92. trueppm_api/apps/integrations/connections.py +806 -0
  93. trueppm_api/apps/integrations/encryption.py +92 -0
  94. trueppm_api/apps/integrations/external_sources.py +837 -0
  95. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  96. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  97. trueppm_api/apps/integrations/http.py +403 -0
  98. trueppm_api/apps/integrations/me_work.py +218 -0
  99. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  100. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  101. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  102. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  103. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  104. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  105. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  106. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  107. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  108. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  109. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  110. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  111. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  112. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  113. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  114. trueppm_api/apps/integrations/models.py +622 -0
  115. trueppm_api/apps/integrations/notification_channels.py +57 -0
  116. trueppm_api/apps/integrations/opengraph.py +229 -0
  117. trueppm_api/apps/integrations/outgoing.py +201 -0
  118. trueppm_api/apps/integrations/providers.py +519 -0
  119. trueppm_api/apps/integrations/registry.py +345 -0
  120. trueppm_api/apps/integrations/serializers.py +453 -0
  121. trueppm_api/apps/integrations/services.py +171 -0
  122. trueppm_api/apps/integrations/tasks.py +731 -0
  123. trueppm_api/apps/integrations/throttles.py +165 -0
  124. trueppm_api/apps/integrations/urls.py +77 -0
  125. trueppm_api/apps/integrations/views.py +1106 -0
  126. trueppm_api/apps/jiraimport/__init__.py +8 -0
  127. trueppm_api/apps/jiraimport/apps.py +11 -0
  128. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  129. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  130. trueppm_api/apps/jiraimport/models.py +70 -0
  131. trueppm_api/apps/jiraimport/parser.py +350 -0
  132. trueppm_api/apps/jiraimport/services.py +46 -0
  133. trueppm_api/apps/jiraimport/tasks.py +277 -0
  134. trueppm_api/apps/jiraimport/urls.py +15 -0
  135. trueppm_api/apps/jiraimport/views.py +153 -0
  136. trueppm_api/apps/msproject/__init__.py +0 -0
  137. trueppm_api/apps/msproject/apps.py +11 -0
  138. trueppm_api/apps/msproject/dataclasses.py +153 -0
  139. trueppm_api/apps/msproject/exporter.py +500 -0
  140. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  141. trueppm_api/apps/msproject/importer.py +1095 -0
  142. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  143. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  144. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  145. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  146. trueppm_api/apps/msproject/models.py +81 -0
  147. trueppm_api/apps/msproject/parser.py +1231 -0
  148. trueppm_api/apps/msproject/serializers.py +136 -0
  149. trueppm_api/apps/msproject/services.py +58 -0
  150. trueppm_api/apps/msproject/tasks.py +436 -0
  151. trueppm_api/apps/msproject/urls.py +39 -0
  152. trueppm_api/apps/msproject/views.py +531 -0
  153. trueppm_api/apps/notifications/__init__.py +7 -0
  154. trueppm_api/apps/notifications/apps.py +15 -0
  155. trueppm_api/apps/notifications/backfill.py +79 -0
  156. trueppm_api/apps/notifications/categories.py +112 -0
  157. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  158. trueppm_api/apps/notifications/digests.py +447 -0
  159. trueppm_api/apps/notifications/email_backend.py +330 -0
  160. trueppm_api/apps/notifications/email_health.py +128 -0
  161. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  162. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
  163. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  164. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  165. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  166. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  167. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  168. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  169. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  170. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  171. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  172. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  173. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  174. trueppm_api/apps/notifications/models.py +1197 -0
  175. trueppm_api/apps/notifications/receivers.py +155 -0
  176. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  177. trueppm_api/apps/notifications/serializers.py +961 -0
  178. trueppm_api/apps/notifications/services.py +1451 -0
  179. trueppm_api/apps/notifications/tasks.py +711 -0
  180. trueppm_api/apps/notifications/throttles.py +98 -0
  181. trueppm_api/apps/notifications/urls.py +97 -0
  182. trueppm_api/apps/notifications/views.py +904 -0
  183. trueppm_api/apps/observability/__init__.py +0 -0
  184. trueppm_api/apps/observability/apps.py +31 -0
  185. trueppm_api/apps/observability/logging.py +213 -0
  186. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  187. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  188. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  189. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  190. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  191. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  192. trueppm_api/apps/observability/models.py +141 -0
  193. trueppm_api/apps/observability/otel/__init__.py +45 -0
  194. trueppm_api/apps/observability/otel/attributes.py +247 -0
  195. trueppm_api/apps/observability/otel/export_health.py +533 -0
  196. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  197. trueppm_api/apps/observability/otel/metrics.py +551 -0
  198. trueppm_api/apps/observability/otel/provider.py +511 -0
  199. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  200. trueppm_api/apps/observability/purge_registry.py +124 -0
  201. trueppm_api/apps/observability/retention.py +139 -0
  202. trueppm_api/apps/observability/selectors.py +969 -0
  203. trueppm_api/apps/observability/serializers.py +142 -0
  204. trueppm_api/apps/observability/services.py +407 -0
  205. trueppm_api/apps/observability/tasks.py +266 -0
  206. trueppm_api/apps/observability/urls.py +27 -0
  207. trueppm_api/apps/observability/views.py +523 -0
  208. trueppm_api/apps/profiles/__init__.py +0 -0
  209. trueppm_api/apps/profiles/apps.py +15 -0
  210. trueppm_api/apps/profiles/constants.py +54 -0
  211. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  212. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  213. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  214. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  215. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  216. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  217. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  218. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  219. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  220. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  221. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  222. trueppm_api/apps/profiles/models.py +276 -0
  223. trueppm_api/apps/profiles/serializers.py +199 -0
  224. trueppm_api/apps/profiles/services.py +466 -0
  225. trueppm_api/apps/profiles/views.py +124 -0
  226. trueppm_api/apps/projects/__init__.py +0 -0
  227. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  228. trueppm_api/apps/projects/amend.py +133 -0
  229. trueppm_api/apps/projects/apps.py +38 -0
  230. trueppm_api/apps/projects/asset_feed.py +459 -0
  231. trueppm_api/apps/projects/asset_views.py +317 -0
  232. trueppm_api/apps/projects/attachment_policy.py +278 -0
  233. trueppm_api/apps/projects/authentication.py +449 -0
  234. trueppm_api/apps/projects/backfill.py +287 -0
  235. trueppm_api/apps/projects/backlog_services.py +324 -0
  236. trueppm_api/apps/projects/backlog_views.py +302 -0
  237. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  238. trueppm_api/apps/projects/blocker_services.py +510 -0
  239. trueppm_api/apps/projects/board_activity.py +462 -0
  240. trueppm_api/apps/projects/board_activity_views.py +186 -0
  241. trueppm_api/apps/projects/board_lanes.py +134 -0
  242. trueppm_api/apps/projects/bulk_settings.py +194 -0
  243. trueppm_api/apps/projects/bundled_templates.py +215 -0
  244. trueppm_api/apps/projects/calendar_settings.py +180 -0
  245. trueppm_api/apps/projects/ceremony_views.py +244 -0
  246. trueppm_api/apps/projects/commit_moment.py +204 -0
  247. trueppm_api/apps/projects/config_notice.py +1325 -0
  248. trueppm_api/apps/projects/custom_field_values.py +266 -0
  249. trueppm_api/apps/projects/decisions_services.py +93 -0
  250. trueppm_api/apps/projects/decisions_views.py +94 -0
  251. trueppm_api/apps/projects/estimation_scale.py +126 -0
  252. trueppm_api/apps/projects/export_bundle.py +353 -0
  253. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
  254. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
  255. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
  256. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
  257. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
  258. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  259. trueppm_api/apps/projects/inbound_sync.py +555 -0
  260. trueppm_api/apps/projects/iteration_label.py +85 -0
  261. trueppm_api/apps/projects/lifecycle.py +191 -0
  262. trueppm_api/apps/projects/management/__init__.py +0 -0
  263. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  264. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  265. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  266. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  267. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  268. trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
  269. trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
  270. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
  271. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  272. trueppm_api/apps/projects/mcp_settings.py +305 -0
  273. trueppm_api/apps/projects/methodology.py +178 -0
  274. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  275. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
  276. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  277. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  278. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  279. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  280. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  281. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  282. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  283. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  284. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  285. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  286. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  287. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  288. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  289. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  290. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  291. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  292. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  293. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  294. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  295. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  296. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  297. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  298. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  299. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  300. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  301. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  302. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  303. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  304. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  305. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  306. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  307. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  308. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  309. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  310. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  311. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  312. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  313. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  314. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  315. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  316. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  317. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  318. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  319. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  320. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  321. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  322. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  323. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  324. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  325. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  326. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  327. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  328. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  329. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  330. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  331. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  332. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  333. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  334. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  335. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  336. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  337. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  338. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  339. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  340. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  341. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  342. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  343. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  344. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  345. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  346. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  347. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  348. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  349. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  350. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  351. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  352. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  353. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  354. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  355. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  356. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  357. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  358. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  359. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  360. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  361. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  362. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  363. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  364. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  365. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  366. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  367. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  368. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
  369. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  370. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  371. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  372. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  373. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  374. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  375. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  376. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  377. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  378. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  379. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
  380. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  381. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  382. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  383. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  384. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  385. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
  386. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  387. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  388. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  389. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  390. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  391. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  392. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  393. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  394. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  395. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  396. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  397. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  398. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  399. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  400. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  401. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  402. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  403. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  404. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  405. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  406. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  407. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  408. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  409. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  410. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  411. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  412. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  413. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  414. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  415. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  416. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  417. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  418. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  419. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  420. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  421. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  422. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  423. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  424. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  425. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  426. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  427. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  428. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  429. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  430. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  431. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  432. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  433. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  434. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  435. trueppm_api/apps/projects/models.py +8946 -0
  436. trueppm_api/apps/projects/poker_services.py +154 -0
  437. trueppm_api/apps/projects/poker_views.py +398 -0
  438. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  439. trueppm_api/apps/projects/program_label_services.py +94 -0
  440. trueppm_api/apps/projects/program_label_views.py +189 -0
  441. trueppm_api/apps/projects/program_rollup.py +575 -0
  442. trueppm_api/apps/projects/program_schedule.py +745 -0
  443. trueppm_api/apps/projects/program_views.py +3220 -0
  444. trueppm_api/apps/projects/project_templates.py +422 -0
  445. trueppm_api/apps/projects/receivers.py +234 -0
  446. trueppm_api/apps/projects/refusal_codes.py +140 -0
  447. trueppm_api/apps/projects/reorder_services.py +145 -0
  448. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  449. trueppm_api/apps/projects/retro_board_services.py +368 -0
  450. trueppm_api/apps/projects/retro_services.py +285 -0
  451. trueppm_api/apps/projects/risk_import.py +396 -0
  452. trueppm_api/apps/projects/row_identity.py +113 -0
  453. trueppm_api/apps/projects/schema_migrations.py +228 -0
  454. trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
  455. trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
  456. trueppm_api/apps/projects/seed/__init__.py +61 -0
  457. trueppm_api/apps/projects/seed/exporter.py +1644 -0
  458. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  459. trueppm_api/apps/projects/seed/importer.py +2390 -0
  460. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  461. trueppm_api/apps/projects/seed/reldates.py +126 -0
  462. trueppm_api/apps/projects/seed/replace.py +126 -0
  463. trueppm_api/apps/projects/seed/replay.py +1691 -0
  464. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  465. trueppm_api/apps/projects/seed/samples.py +427 -0
  466. trueppm_api/apps/projects/seed/validation.py +1149 -0
  467. trueppm_api/apps/projects/serializers.py +12310 -0
  468. trueppm_api/apps/projects/services.py +6966 -0
  469. trueppm_api/apps/projects/share_serializers.py +132 -0
  470. trueppm_api/apps/projects/share_services.py +325 -0
  471. trueppm_api/apps/projects/share_views.py +374 -0
  472. trueppm_api/apps/projects/sharing_settings.py +187 -0
  473. trueppm_api/apps/projects/signal_privacy_services.py +909 -0
  474. trueppm_api/apps/projects/signal_privacy_views.py +476 -0
  475. trueppm_api/apps/projects/signals.py +132 -0
  476. trueppm_api/apps/projects/slip_conflict.py +266 -0
  477. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  478. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  479. trueppm_api/apps/projects/standup.py +304 -0
  480. trueppm_api/apps/projects/standup_views.py +99 -0
  481. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  482. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  483. trueppm_api/apps/projects/surface_visibility.py +126 -0
  484. trueppm_api/apps/projects/task_batch_services.py +379 -0
  485. trueppm_api/apps/projects/task_bulk.py +1152 -0
  486. trueppm_api/apps/projects/task_classification.py +561 -0
  487. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  488. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  489. trueppm_api/apps/projects/task_grouping.py +754 -0
  490. trueppm_api/apps/projects/tasks.py +1917 -0
  491. trueppm_api/apps/projects/template_divergence.py +168 -0
  492. trueppm_api/apps/projects/template_services.py +160 -0
  493. trueppm_api/apps/projects/template_tasks.py +258 -0
  494. trueppm_api/apps/projects/template_views.py +799 -0
  495. trueppm_api/apps/projects/throttles.py +311 -0
  496. trueppm_api/apps/projects/urls.py +1011 -0
  497. trueppm_api/apps/projects/utilization.py +885 -0
  498. trueppm_api/apps/projects/views.py +20572 -0
  499. trueppm_api/apps/projects/wbs_paths.py +119 -0
  500. trueppm_api/apps/resources/__init__.py +0 -0
  501. trueppm_api/apps/resources/apps.py +8 -0
  502. trueppm_api/apps/resources/capacity.py +121 -0
  503. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  504. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  505. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  506. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  507. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  508. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  509. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  510. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  511. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  512. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  513. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  514. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  515. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  516. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  517. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  518. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  519. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  520. trueppm_api/apps/resources/models.py +298 -0
  521. trueppm_api/apps/resources/serializers.py +426 -0
  522. trueppm_api/apps/resources/services.py +465 -0
  523. trueppm_api/apps/resources/urls.py +28 -0
  524. trueppm_api/apps/resources/views.py +1749 -0
  525. trueppm_api/apps/scheduling/__init__.py +0 -0
  526. trueppm_api/apps/scheduling/apps.py +153 -0
  527. trueppm_api/apps/scheduling/calendars.py +119 -0
  528. trueppm_api/apps/scheduling/deadletter.py +91 -0
  529. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  530. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  531. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  532. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  533. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  534. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  535. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  536. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  537. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  538. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  539. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  540. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  541. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  542. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  543. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  544. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  545. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  546. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  547. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  548. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  549. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  550. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  551. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  552. trueppm_api/apps/scheduling/models.py +516 -0
  553. trueppm_api/apps/scheduling/receivers.py +160 -0
  554. trueppm_api/apps/scheduling/risk_premium.py +283 -0
  555. trueppm_api/apps/scheduling/serializers.py +564 -0
  556. trueppm_api/apps/scheduling/services.py +1182 -0
  557. trueppm_api/apps/scheduling/signals.py +56 -0
  558. trueppm_api/apps/scheduling/tasks.py +1984 -0
  559. trueppm_api/apps/scheduling/telemetry.py +80 -0
  560. trueppm_api/apps/scheduling/units.py +35 -0
  561. trueppm_api/apps/scheduling/urls.py +57 -0
  562. trueppm_api/apps/scheduling/views.py +2564 -0
  563. trueppm_api/apps/sso/__init__.py +0 -0
  564. trueppm_api/apps/sso/apps.py +19 -0
  565. trueppm_api/apps/sso/extensions.py +130 -0
  566. trueppm_api/apps/sso/management/__init__.py +0 -0
  567. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  568. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  569. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  570. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  571. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  572. trueppm_api/apps/sso/models.py +148 -0
  573. trueppm_api/apps/sso/serializers.py +437 -0
  574. trueppm_api/apps/sso/services.py +1385 -0
  575. trueppm_api/apps/sso/urls.py +47 -0
  576. trueppm_api/apps/sso/views.py +935 -0
  577. trueppm_api/apps/sync/__init__.py +0 -0
  578. trueppm_api/apps/sync/apps.py +15 -0
  579. trueppm_api/apps/sync/backfill.py +208 -0
  580. trueppm_api/apps/sync/broadcast.py +320 -0
  581. trueppm_api/apps/sync/conflict.py +262 -0
  582. trueppm_api/apps/sync/consumers.py +471 -0
  583. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  584. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  585. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  586. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  587. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  588. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  589. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  590. trueppm_api/apps/sync/models.py +225 -0
  591. trueppm_api/apps/sync/pagination.py +231 -0
  592. trueppm_api/apps/sync/sequence.py +371 -0
  593. trueppm_api/apps/sync/serializers.py +687 -0
  594. trueppm_api/apps/sync/tasks.py +260 -0
  595. trueppm_api/apps/sync/throttles.py +175 -0
  596. trueppm_api/apps/sync/upload.py +501 -0
  597. trueppm_api/apps/sync/urls.py +17 -0
  598. trueppm_api/apps/sync/views.py +930 -0
  599. trueppm_api/apps/sync/ws_auth.py +193 -0
  600. trueppm_api/apps/taskruns/__init__.py +0 -0
  601. trueppm_api/apps/taskruns/apps.py +11 -0
  602. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  603. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  604. trueppm_api/apps/taskruns/models.py +68 -0
  605. trueppm_api/apps/taskruns/serializers.py +92 -0
  606. trueppm_api/apps/taskruns/tasks.py +60 -0
  607. trueppm_api/apps/taskruns/tracker.py +286 -0
  608. trueppm_api/apps/taskruns/urls.py +46 -0
  609. trueppm_api/apps/taskruns/views.py +195 -0
  610. trueppm_api/apps/teams/__init__.py +8 -0
  611. trueppm_api/apps/teams/apps.py +16 -0
  612. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  613. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
  614. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  615. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  616. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  617. trueppm_api/apps/teams/models.py +145 -0
  618. trueppm_api/apps/teams/permissions.py +149 -0
  619. trueppm_api/apps/teams/serializers.py +95 -0
  620. trueppm_api/apps/teams/services.py +434 -0
  621. trueppm_api/apps/teams/signals.py +61 -0
  622. trueppm_api/apps/teams/urls.py +34 -0
  623. trueppm_api/apps/teams/views.py +201 -0
  624. trueppm_api/apps/timetracking/__init__.py +0 -0
  625. trueppm_api/apps/timetracking/apps.py +9 -0
  626. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  627. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  628. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  629. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  630. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  631. trueppm_api/apps/timetracking/models.py +178 -0
  632. trueppm_api/apps/timetracking/serializers.py +175 -0
  633. trueppm_api/apps/timetracking/services.py +117 -0
  634. trueppm_api/apps/timetracking/urls.py +47 -0
  635. trueppm_api/apps/timetracking/views.py +526 -0
  636. trueppm_api/apps/webhooks/__init__.py +0 -0
  637. trueppm_api/apps/webhooks/apps.py +8 -0
  638. trueppm_api/apps/webhooks/backfill.py +90 -0
  639. trueppm_api/apps/webhooks/dispatch.py +144 -0
  640. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  641. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
  642. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  643. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  644. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  645. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  646. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  647. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  648. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  649. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
  650. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  651. trueppm_api/apps/webhooks/models.py +372 -0
  652. trueppm_api/apps/webhooks/serializers.py +318 -0
  653. trueppm_api/apps/webhooks/tasks.py +663 -0
  654. trueppm_api/apps/webhooks/urls.py +22 -0
  655. trueppm_api/apps/webhooks/views.py +383 -0
  656. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  657. trueppm_api/apps/workflow_engine/apps.py +23 -0
  658. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  659. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  660. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  661. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  662. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  663. trueppm_api/apps/workflow_engine/models.py +265 -0
  664. trueppm_api/apps/workflow_engine/tasks.py +368 -0
  665. trueppm_api/apps/workspace/__init__.py +0 -0
  666. trueppm_api/apps/workspace/apps.py +10 -0
  667. trueppm_api/apps/workspace/backfill.py +111 -0
  668. trueppm_api/apps/workspace/export.py +242 -0
  669. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  670. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  671. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  672. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  673. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  674. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  675. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  676. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  677. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  678. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  679. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  680. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  681. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  682. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  683. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  684. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  685. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  686. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  687. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  688. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  689. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  690. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  691. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  692. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  693. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  694. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  695. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  696. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  697. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  698. trueppm_api/apps/workspace/models.py +857 -0
  699. trueppm_api/apps/workspace/permissions.py +187 -0
  700. trueppm_api/apps/workspace/serializers.py +590 -0
  701. trueppm_api/apps/workspace/services.py +919 -0
  702. trueppm_api/apps/workspace/signals.py +37 -0
  703. trueppm_api/apps/workspace/tasks.py +555 -0
  704. trueppm_api/apps/workspace/urls.py +113 -0
  705. trueppm_api/apps/workspace/views.py +1502 -0
  706. trueppm_api/asgi.py +42 -0
  707. trueppm_api/celery.py +31 -0
  708. trueppm_api/core/__init__.py +0 -0
  709. trueppm_api/core/admin_site.py +206 -0
  710. trueppm_api/core/auth_views.py +916 -0
  711. trueppm_api/core/constant_time.py +51 -0
  712. trueppm_api/core/csp.py +54 -0
  713. trueppm_api/core/db.py +87 -0
  714. trueppm_api/core/db_session.py +37 -0
  715. trueppm_api/core/exception_handlers.py +157 -0
  716. trueppm_api/core/export_downloads.py +56 -0
  717. trueppm_api/core/extension_providers.py +56 -0
  718. trueppm_api/core/extension_signals.py +147 -0
  719. trueppm_api/core/idempotent.py +260 -0
  720. trueppm_api/core/metadata.py +42 -0
  721. trueppm_api/core/middleware.py +51 -0
  722. trueppm_api/core/openapi.py +949 -0
  723. trueppm_api/core/password_reset.py +500 -0
  724. trueppm_api/core/protect_conflict.py +100 -0
  725. trueppm_api/core/ratelimit.py +148 -0
  726. trueppm_api/core/redis_throttle.py +82 -0
  727. trueppm_api/core/refresh_cookie_policy.py +156 -0
  728. trueppm_api/core/request_body.py +86 -0
  729. trueppm_api/core/security_checks.py +884 -0
  730. trueppm_api/core/storage_config.py +105 -0
  731. trueppm_api/core/text_decode.py +168 -0
  732. trueppm_api/core/throttling.py +344 -0
  733. trueppm_api/core/valkey.py +214 -0
  734. trueppm_api/core/valkey_cache.py +66 -0
  735. trueppm_api/core/valkey_checks.py +104 -0
  736. trueppm_api/core/valkey_config.py +39 -0
  737. trueppm_api/core/worker_broker_wait.py +149 -0
  738. trueppm_api/core/worker_heartbeat.py +134 -0
  739. trueppm_api/fields.py +36 -0
  740. trueppm_api/permissions.py +30 -0
  741. trueppm_api/py.typed +0 -0
  742. trueppm_api/routing.py +11 -0
  743. trueppm_api/settings/__init__.py +0 -0
  744. trueppm_api/settings/base.py +2455 -0
  745. trueppm_api/settings/dev.py +273 -0
  746. trueppm_api/settings/prod.py +228 -0
  747. trueppm_api/urls.py +153 -0
  748. trueppm_api/workflows/__init__.py +38 -0
  749. trueppm_api/workflows/backends/__init__.py +6 -0
  750. trueppm_api/workflows/backends/default.py +297 -0
  751. trueppm_api/workflows/consumers/__init__.py +11 -0
  752. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  753. trueppm_api/workflows/interface.py +148 -0
  754. trueppm_api/workflows/registry.py +106 -0
  755. trueppm_api/workflows/services.py +90 -0
  756. trueppm_api/wsgi.py +11 -0
  757. trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
  758. trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
  759. trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
@@ -0,0 +1,1106 @@
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 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
+ instance = serializer.save(task=task, provider=provider)
408
+ link_id = str(instance.pk)
409
+ task_id = str(task.pk)
410
+ project_id = str(task.project_id)
411
+ transaction.on_commit(
412
+ lambda: broadcast_board_event(
413
+ project_id, "task_link_created", {"id": link_id, "task_id": task_id}
414
+ )
415
+ )
416
+
417
+ def perform_update(self, serializer: BaseSerializer[TaskLink]) -> None:
418
+ """Edit a link's user fields (custom_title / labels / url) (#970).
419
+
420
+ If the ``url`` itself changes the provider is re-resolved server-side
421
+ (same rule as create — never trusted from the client); ``status`` /
422
+ ``title`` / ``fetched_at`` are left untouched here and only move on an
423
+ explicit refresh. Broadcasts ``task_link_updated`` on commit so other
424
+ board viewers see the edited title/labels live.
425
+ """
426
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
427
+
428
+ update_kwargs: dict[str, object] = {}
429
+ if "url" in serializer.validated_data:
430
+ update_kwargs["provider"] = providers.resolve_provider_key(
431
+ serializer.validated_data["url"], user=self.request.user
432
+ )
433
+ link = serializer.save(**update_kwargs)
434
+ link_id = str(link.pk)
435
+ task_id = str(link.task_id)
436
+ project_id = str(link.task.project_id)
437
+ transaction.on_commit(
438
+ lambda: broadcast_board_event(
439
+ project_id, "task_link_updated", {"id": link_id, "task_id": task_id}
440
+ )
441
+ )
442
+
443
+ def perform_destroy(self, instance: TaskLink) -> None:
444
+ """Soft-delete so the removal reaches mobile as a sync tombstone."""
445
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
446
+
447
+ instance.soft_delete()
448
+ link_id = str(instance.pk)
449
+ # instance.task is select_related-loaded by get_queryset, so .pk is free
450
+ # and avoids the django-stubs task_id descriptor gap.
451
+ task_id = str(instance.task.pk)
452
+ project_id = str(instance.task.project_id)
453
+ transaction.on_commit(
454
+ lambda: broadcast_board_event(
455
+ project_id, "task_link_deleted", {"id": link_id, "task_id": task_id}
456
+ )
457
+ )
458
+
459
+ @extend_schema(
460
+ request=None,
461
+ responses={200: TaskLinkSerializer, 422: TaskLinkCredentialRequiredSerializer},
462
+ )
463
+ @action(detail=True, methods=["post"])
464
+ def refresh(
465
+ self,
466
+ request: Request,
467
+ project_pk: str | None = None,
468
+ task_pk: str | None = None,
469
+ pk: str | None = None,
470
+ ) -> Response:
471
+ """Refresh the cached status/title from the provider (synchronous, 5s).
472
+
473
+ Read-permission action (a Viewer can refresh). If the provider needs a
474
+ PAT and the caller has not connected one, returns 422 ``credential_required``
475
+ so the UI can prompt a connect rather than silently leaving the link
476
+ ``unknown``. Transport/parse failures degrade the status to ``unknown``
477
+ — ``fetch_metadata`` does not raise for an unreachable provider.
478
+ """
479
+ from trueppm_api.apps.sync.broadcast import broadcast_board_event
480
+
481
+ user = request.user
482
+ if not user.is_authenticated:
483
+ return Response(status=status.HTTP_403_FORBIDDEN)
484
+
485
+ link = self.get_object()
486
+ handler_cls = TASK_LINK_PROVIDERS.get(link.provider)
487
+ if handler_cls is None:
488
+ # Provider un-registered (Enterprise downgraded / plugin removed) —
489
+ # nothing to fetch; return the row unchanged rather than 500ing.
490
+ return Response(TaskLinkSerializer(link).data)
491
+
492
+ requires_credential = getattr(handler_cls, "requires_credential", True)
493
+ credential = IntegrationCredential.objects.filter(user=user, provider=link.provider).first()
494
+ if requires_credential and credential is None:
495
+ return Response(
496
+ {
497
+ "detail": f"Connect your {link.provider} account to refresh this link.",
498
+ "code": "credential_required",
499
+ "provider": link.provider,
500
+ "requires_credential": True,
501
+ },
502
+ status=status.HTTP_422_UNPROCESSABLE_ENTITY,
503
+ )
504
+
505
+ handler = cast("type[TaskLinkProvider]", handler_cls)()
506
+ metadata = handler.fetch_metadata(link.url, credential)
507
+ link.status = metadata.status
508
+ if metadata.title:
509
+ link.title = metadata.title
510
+ # Cloud-file preview cache (#571, ADR-0163). A file provider returns these;
511
+ # git/generic providers leave them None and the row's empty defaults stand.
512
+ # Coalesce None → "" so the column type (CharField/URLField) is satisfied
513
+ # and a provider that returns no preview clears any stale one.
514
+ link.description = metadata.description or ""
515
+ link.thumbnail_url = metadata.thumbnail_url or ""
516
+ link.preview_type = metadata.preview_type or ""
517
+ link.fetched_at = timezone.now()
518
+ # VersionedModel.save() bumps server_version atomically; we pass the
519
+ # changed fields only and let it handle the version bump + sync delta.
520
+ link.save(
521
+ update_fields=[
522
+ "status",
523
+ "title",
524
+ "description",
525
+ "thumbnail_url",
526
+ "preview_type",
527
+ "fetched_at",
528
+ ]
529
+ )
530
+
531
+ if credential is not None:
532
+ credential.last_used_at = timezone.now()
533
+ credential.save(update_fields=["last_used_at"])
534
+
535
+ link_id = str(link.pk)
536
+ task_id = str(link.task_id)
537
+ project_id = str(link.task.project_id)
538
+ transaction.on_commit(
539
+ lambda: broadcast_board_event(
540
+ project_id, "task_link_updated", {"id": link_id, "task_id": task_id}
541
+ )
542
+ )
543
+ return Response(TaskLinkSerializer(link).data)
544
+
545
+
546
+ def _git_webhook_url(request: Request, project_pk: Any) -> str:
547
+ """Absolute URL of a project's inbound Git-webhook receiver (admin pastes this).
548
+
549
+ Prefers the explicit ``TRUEPPM_PUBLIC_API_BASE_URL`` for the same reason
550
+ ``sso.views._derive_redirect_uri`` does (#3515): this value leaves the system
551
+ — an admin copies it into GitHub/GitLab — so it must be the origin the
552
+ operator chose, not the ``Host`` header of whoever happened to ask. Behind a
553
+ proxy that rewrites ``Host`` the request-derived form yields an unreachable
554
+ internal name and fails silently forever, since the wrong URL only shows up
555
+ as webhooks that never arrive. Falls back to the request's absolute URI for
556
+ zero-config single-origin dev, where the two agree.
557
+ """
558
+ from django.conf import settings
559
+ from django.urls import reverse
560
+
561
+ path = reverse("git-webhook", kwargs={"project_pk": str(project_pk)})
562
+ base = (getattr(settings, "TRUEPPM_PUBLIC_API_BASE_URL", "") or "").rstrip("/")
563
+ if base:
564
+ return f"{base}{path}"
565
+ return request.build_absolute_uri(path)
566
+
567
+
568
+ class _WebhookRefusal(Exception):
569
+ """A pre-verification refusal and the reason for it (#2881).
570
+
571
+ Internal control flow, never surfaced: the receiver catches it and answers with
572
+ the one uniform 404 that every refusal shares, after recording ``outcome`` where
573
+ only a project admin can read it.
574
+ """
575
+
576
+ def __init__(
577
+ self, outcome: str, automation_id: Any = None, provider: str | None = None
578
+ ) -> None:
579
+ super().__init__(outcome)
580
+ self.outcome = outcome
581
+ # ``None`` when there is no row to record against (no project, or automation
582
+ # never configured at all) — then the receiver log is the only record.
583
+ self.automation_id = automation_id
584
+ self.provider = provider
585
+
586
+
587
+ def _record_git_delivery(automation_id: Any, provider: str | None, outcome: str) -> None:
588
+ """Stamp the last **verified** delivery on a ``BoardAutomation`` row (#2882).
589
+
590
+ Only reached once the signature has verified, which is the whole point — see
591
+ :func:`_record_git_refusal` for why the two are separate columns.
592
+
593
+ A queryset ``.update()`` rather than ``instance.save()`` on purpose: ``save()``
594
+ would fire ``auto_now`` on ``updated_at``, and that field means "an admin last
595
+ changed this config" — a webhook arriving must not look like a config change on
596
+ the settings card. One UPDATE, no model state reload, no signals.
597
+ """
598
+ BoardAutomation.objects.filter(pk=automation_id).update(
599
+ last_delivery_at=timezone.now(),
600
+ last_delivery_outcome=outcome[:32],
601
+ last_delivery_provider=(provider or "")[:16],
602
+ )
603
+
604
+
605
+ def _record_git_refusal(automation_id: Any, provider: str | None, outcome: str) -> None:
606
+ """Stamp the last **refused** delivery, in its own columns (#2882).
607
+
608
+ Deliberately not the same columns as :func:`_record_git_delivery`, and the reason
609
+ is not tidiness. A refusal is recorded *before* the signature is checked, so
610
+ anyone who knows the project's UUID — which is not a secret; every project Viewer
611
+ reads it out of the SPA URL, and that is #2881's own premise — can force one at
612
+ will with two forged headers. Sharing one column would hand that caller two
613
+ things: the power to erase the genuine ``no_link`` outcome an admin is in the
614
+ middle of diagnosing, and a UI that then tells the admin their secret is wrong and
615
+ to rotate it — taking down a working integration on an unauthenticated party's
616
+ say-so. Split, a hostile caller can only ever write to the row that is already
617
+ labeled "unauthenticated, may not be your provider".
618
+ """
619
+ BoardAutomation.objects.filter(pk=automation_id).update(
620
+ last_refusal_at=timezone.now(),
621
+ last_refusal_outcome=outcome[:32],
622
+ last_refusal_provider=(provider or "")[:16],
623
+ )
624
+
625
+
626
+ def _client_ident(request: Request) -> str:
627
+ """Client identity for the receiver log, resolved the way the throttle resolves it.
628
+
629
+ Reuses ``BaseThrottle.get_ident`` so the logged address is the same
630
+ ``NUM_PROXIES``-aware value the per-IP bucket keys on — logging a raw
631
+ ``REMOTE_ADDR`` would name the ingress on every line, and logging a raw
632
+ ``X-Forwarded-For`` would name whatever the caller put there.
633
+ """
634
+ from .throttles import GitWebhookIpThrottle
635
+
636
+ try:
637
+ return str(GitWebhookIpThrottle().get_ident(request) or "-")
638
+ except Exception: # pragma: no cover - diagnostics must never break the response
639
+ return "-"
640
+
641
+
642
+ def _provider_delivery_id(request: Request) -> str:
643
+ """The provider's own delivery id, so a log line can be matched to their UI.
644
+
645
+ GitHub stamps ``X-GitHub-Delivery``; GitLab stamps ``X-Gitlab-Event-UUID``.
646
+ Truncated because it is attacker-controlled on a refusal — it is a correlation
647
+ hint, not evidence.
648
+ """
649
+ raw = request.headers.get("X-GitHub-Delivery") or request.headers.get("X-Gitlab-Event-UUID")
650
+ return str(raw)[:64] if raw else ""
651
+
652
+
653
+ def _webhook_body_within_cap(request: Request) -> bool:
654
+ """Whether the request body is inside :data:`_WEBHOOK_MAX_BODY_BYTES`.
655
+
656
+ Checks the declared ``Content-Length`` **first**, so an oversized delivery is
657
+ refused before this process copies it anywhere — the old code read ``request.body``
658
+ as its first statement and materialized up to the global 100 MB cap before it even
659
+ knew whether the project had automation.
660
+
661
+ Be precise about what this does and does not buy, because the obvious reading is
662
+ too strong. Under ASGI, Django assembles the request body into a spooled temporary
663
+ file during request setup, before any middleware, throttle, or view code runs — so
664
+ the bytes have already arrived by the time this executes, and no view-level check
665
+ can prevent that. What the cap removes is the *second*, in-memory copy and every
666
+ allocation downstream of it (HMAC over the full body, JSON parse, envelope). The
667
+ wire-level ceiling is the ingress ``proxy-body-size``, and bounding an oversized
668
+ delivery before it reaches the pod is an edge-config job, tracked separately.
669
+
670
+ Both providers always send ``Content-Length``. The ``request.body`` length check
671
+ behind it is the net for a chunked or header-less caller, where measuring the bytes
672
+ that actually arrived is the only option available (same trade-off as the
673
+ seed-import reader) — that path does take the in-memory copy before refusing.
674
+ """
675
+ try:
676
+ declared = int(request.META.get("CONTENT_LENGTH") or 0)
677
+ except (TypeError, ValueError):
678
+ declared = 0
679
+ if declared > _WEBHOOK_MAX_BODY_BYTES:
680
+ return False
681
+ return len(request.body) <= _WEBHOOK_MAX_BODY_BYTES
682
+
683
+
684
+ @extend_schema(
685
+ tags=["integrations"],
686
+ # Not ``None`` (#3364). ``request=None`` published "this endpoint accepts no
687
+ # body" while ``post`` reads ``request.data`` and refuses a non-object — a
688
+ # generated client had no parameter to send the provider payload through at all.
689
+ # See GIT_WEBHOOK_PAYLOAD_SCHEMA for why this is an open object rather than a
690
+ # serializer: the shape belongs to GitHub/GitLab, not to us.
691
+ #
692
+ # Keyed by media type, NOT passed bare. ``responses=`` accepts a raw schema dict
693
+ # (which is why the sibling GIT_WEBHOOK_RESULT_SCHEMA below is written that way),
694
+ # but ``request=`` does not: handed a bare dict it reads the dict's own top-level
695
+ # keys as media types, so ``type``/``additionalProperties``/``description`` became
696
+ # three bogus content entries and the real schema was replaced by drf-spectacular's
697
+ # "Unspecified request body" placeholder. It fails silently — the operation still
698
+ # has a ``requestBody``, so it looks declared from every angle except reading the
699
+ # generated document.
700
+ request={"application/json": GIT_WEBHOOK_PAYLOAD_SCHEMA},
701
+ responses={
702
+ 200: GIT_WEBHOOK_RESULT_SCHEMA,
703
+ 400: OpenApiResponse(description="Malformed webhook payload (signature verified)."),
704
+ 404: OpenApiResponse(
705
+ description=(
706
+ "The uniform refusal for every pre-verification failure — no automation, "
707
+ "an archived project, disabled, no secret set, unrecognized provider, or "
708
+ "an invalid signature. "
709
+ "Deliberately indistinguishable so the endpoint cannot be used to discover "
710
+ "which projects have automation configured."
711
+ )
712
+ ),
713
+ 413: OpenApiResponse(description="Payload above the 1 MB webhook body cap."),
714
+ },
715
+ )
716
+ class GitWebhookIngestView(IdempotencyMixin, APIView):
717
+ """Inbound Git-event receiver — ``POST .../{project_pk}/git-webhook/`` (#329, ADR-0158).
718
+
719
+ The OSS Git-event board-card auto-move receiver. **The signature is the gate** —
720
+ this endpoint is unauthenticated-by-session (``AllowAny``); a GitHub
721
+ ``X-Hub-Signature-256`` HMAC or a GitLab ``X-Gitlab-Token`` over the project's
722
+ secret is what authorizes the call.
723
+
724
+ **Every pre-verification refusal is the same bare 404** — no automation row, a
725
+ disabled toggle, no secret set, an unrecognized provider, an undecryptable
726
+ secret, and a bad or missing signature are byte-for-byte indistinguishable to the
727
+ caller. This is stricter than the original design, which used 404 for
728
+ "not configured" and 401 for "bad signature" and so published exactly the fact
729
+ the 404 existed to hide: with a project UUID (which every project Viewer has,
730
+ straight out of the SPA URL) a 401 meant *automation is enabled and a secret is
731
+ set*, admin-only state that ``GitAutomationConfigView`` gates at Owner/Admin. A
732
+ non-ASCII byte in the signature header made it worse still by raising an
733
+ unhandled ``TypeError``, so a 500 read as the same disclosure (#2881).
734
+
735
+ The refusal *reason* is not thrown away — it goes to the structured receiver log
736
+ and to ``BoardAutomation.last_delivery_*``, which the admin-only config GET
737
+ returns and the settings card renders. Both require the reader to already be an
738
+ Owner/Admin of that project, which is the difference that matters.
739
+
740
+ Two refusals stay distinct on purpose, because neither depends on project state
741
+ and so neither can leak it: **413** for a body over
742
+ :data:`_WEBHOOK_MAX_BODY_BYTES` (checked before the body is buffered at all) and
743
+ **429** from the throttles. Post-verification responses are informative too — the
744
+ caller has already proven it holds the secret, so there is nothing left to hide.
745
+
746
+ OSS boundary (ADR-0097 carve-out, mirroring ADR-0148): a single project-scoped,
747
+ off-by-default, one-way Git→card receiver. No org connector, no OAuth, no
748
+ bidirectional sync, no conflict resolution, no reconciliation loop. The general
749
+ multi-provider bidirectional Integration Hub remains Enterprise.
750
+ """
751
+
752
+ from .throttles import GitWebhookIpThrottle, GitWebhookThrottle
753
+
754
+ # Exempt from the HTTP idempotency model: an inbound webhook carries no JWT user
755
+ # to key the Idempotency-Key store on. Replay safety comes from two purpose-built
756
+ # layers instead — the Redis SET NX EX delivery claim and the forward-only status
757
+ # guard (a redelivered move is a no-op even if Redis is unavailable).
758
+ idempotency_exempt = True
759
+ authentication_classes: list[type] = []
760
+ permission_classes = [AllowAny]
761
+
762
+ # There is no principal on this route to hold a membership, so the role x route
763
+ # matrix (#3441) cannot judge it the way it judges every other project-scoped URL.
764
+ # The constant-time HMAC comparison over the raw body against the project's own
765
+ # stored secret is what stands in for membership: possession of that secret is the
766
+ # authorization, and it is scoped to exactly the one project the URL names.
767
+ role_gate_exempt = (
768
+ "Unauthenticated by design: the per-project HMAC signature over the raw body "
769
+ "is the gate, and it authorizes exactly the project the URL names. No "
770
+ "principal exists on this request to hold a membership (ADR-0158, #2881)."
771
+ )
772
+ # STACKED, and both are needed. The per-project bucket keys on a ``project_pk``
773
+ # the caller picks out of the URL, so rotating it defeated the limit entirely;
774
+ # the per-IP bucket closes that. Declaring ``throttle_classes`` here also
775
+ # replaces ``DEFAULT_THROTTLE_CLASSES``, which is why the global anon throttle
776
+ # never applied and the per-IP class has to be named explicitly (#2881).
777
+ throttle_classes = [GitWebhookThrottle, GitWebhookIpThrottle]
778
+
779
+ def _authorize(
780
+ self, request: Request, project_pk: str, raw_body: bytes
781
+ ) -> tuple[BoardAutomation, str, str]:
782
+ """Resolve the automation row, provider, and secret, or raise ``_WebhookRefusal``.
783
+
784
+ Every failure funnels through one exception type carrying its own outcome
785
+ token, which is what makes the indistinguishability property hold by
786
+ construction: there is a single ``Response`` literal for all six refusals
787
+ instead of six that have to stay in sync with each other.
788
+ """
789
+ # Fetch the row regardless of ``enabled`` so a disabled toggle and a missing
790
+ # secret can be *recorded* (they are the two most common "I followed every
791
+ # step and nothing moves" causes) even though both still answer 404. Scope
792
+ # out soft-deleted projects.
793
+ automation = (
794
+ BoardAutomation.objects.filter(
795
+ project_id=project_pk,
796
+ project__is_deleted=False,
797
+ )
798
+ # The archived flag rides the join the ``project__is_deleted`` filter
799
+ # already forces, so this costs one extra column and no extra query — it
800
+ # deliberately does NOT ``select_related("project")``, for the 51-unused-
801
+ # columns reason recorded just below (#3414).
802
+ .annotate(project_is_archived=F("project__is_archived"))
803
+ # ``configured_by`` — not ``project``. ``project`` is never dereferenced
804
+ # on this path (the filter needs the join, not the columns) and pulling it
805
+ # cost 51 unused columns on every anonymous POST, including the ones that
806
+ # go on to 404. ``configured_by`` IS dereferenced, on every successful
807
+ # move, and was the lazy one.
808
+ .select_related("configured_by")
809
+ .first()
810
+ )
811
+ provider = git_webhook_auth.detect_provider(request.headers)
812
+
813
+ if automation is None:
814
+ # Nothing to record against — no project, or automation never touched.
815
+ raise _WebhookRefusal("no_automation", provider=provider)
816
+ if automation.project_is_archived:
817
+ # Archived projects are hard read-only (#530), and moving a board card is a
818
+ # write. The receiver cannot be gated with ``IsProjectNotArchived``: this
819
+ # endpoint is ``AllowAny`` and a permission-class 403 would publish archived
820
+ # state — admin-only state — to anyone holding the project UUID, re-opening
821
+ # the disclosure #2881 closed. Refusing through the same funnel keeps the
822
+ # response byte-identical to every other refusal while still recording the
823
+ # reason where an Owner/Admin can read it.
824
+ raise _WebhookRefusal(GIT_DELIVERY_PROJECT_ARCHIVED, automation.pk, provider)
825
+ if not automation.enabled:
826
+ raise _WebhookRefusal(GIT_DELIVERY_DISABLED, automation.pk, provider)
827
+ if not automation.has_secret:
828
+ raise _WebhookRefusal(GIT_DELIVERY_NO_SECRET, automation.pk, provider)
829
+ if provider is None:
830
+ raise _WebhookRefusal(GIT_DELIVERY_UNKNOWN_PROVIDER, automation.pk, provider)
831
+
832
+ try:
833
+ secret = decrypt_secret(automation.secret_ciphertext)
834
+ except (CredentialEncryptionError, ImproperlyConfigured, ValueError) as exc:
835
+ # Every way the stored secret can fail to come back, not just one. The
836
+ # obvious arm is CredentialEncryptionError (key rotated without
837
+ # re-encrypt), but ``decrypt_secret`` also raises ImproperlyConfigured
838
+ # when INTEGRATION_ENCRYPTION_KEY is unset and ValueError when it is
839
+ # malformed — the *same* operator scenario, and both would have escaped
840
+ # this funnel as an unhandled 500. That matters beyond the crash: a 500 is
841
+ # only reachable on a project that has automation with a secret set, which
842
+ # is precisely the disclosure #2881 exists to remove. Leaving two
843
+ # exception types out would have re-opened it one type over.
844
+ raise _WebhookRefusal(GIT_DELIVERY_SECRET_UNREADABLE, automation.pk, provider) from exc
845
+
846
+ try:
847
+ git_webhook_auth.verify_signature(provider, secret, raw_body, request.headers)
848
+ except git_webhook_auth.WebhookSignatureError as exc:
849
+ raise _WebhookRefusal(GIT_DELIVERY_BAD_SIGNATURE, automation.pk, provider) from exc
850
+
851
+ return automation, provider, secret
852
+
853
+ def post(self, request: Request, project_pk: str) -> Response:
854
+ from .throttles import claim_webhook_delivery
855
+
856
+ # Bound the body BEFORE anything reads or allocates it.
857
+ if not _webhook_body_within_cap(request):
858
+ logger.warning("git webhook refused: project=%s reason=payload_too_large", project_pk)
859
+ return Response(
860
+ {"detail": "Webhook payload too large."},
861
+ status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
862
+ )
863
+
864
+ # Read the raw body BEFORE touching request.data — the HMAC must be checked
865
+ # against the exact bytes the provider signed, and accessing request.data
866
+ # consumes the stream.
867
+ raw_body = request.body
868
+
869
+ try:
870
+ automation, provider, _secret = self._authorize(request, project_pk, raw_body)
871
+ except _WebhookRefusal as refusal:
872
+ if refusal.automation_id is not None:
873
+ _record_git_refusal(refusal.automation_id, refusal.provider, refusal.outcome)
874
+ # Carry the client identity and the provider's own delivery id. Without
875
+ # them neither surface answers "who sent this?", so an admin staring at
876
+ # "Signature rejected" cannot tell a misconfigured provider from someone
877
+ # poking a public endpoint — and the two call for opposite actions.
878
+ logger.warning(
879
+ "git webhook refused: project=%s provider=%s reason=%s client=%s delivery=%s",
880
+ project_pk,
881
+ refusal.provider or "unknown",
882
+ refusal.outcome,
883
+ _client_ident(request),
884
+ _provider_delivery_id(request) or "-",
885
+ )
886
+ # Returned, NOT ``raise Http404``. DRF's exception handler calls
887
+ # ``set_rollback()`` for every APIException, and ``ATOMIC_REQUESTS`` is on
888
+ # — so raising here would roll the delivery record back out of existence
889
+ # and the whole diagnostic surface would silently record nothing. The body
890
+ # is byte-identical to what DRF's ``NotFound`` renders.
891
+ return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
892
+
893
+ # --- Past the gate: the caller holds the secret, so be informative. -------
894
+ payload = request.data
895
+ if not isinstance(payload, dict):
896
+ _record_git_delivery(automation.pk, provider, GIT_DELIVERY_MALFORMED)
897
+ logger.warning(
898
+ "git webhook malformed payload: project=%s provider=%s", project_pk, provider
899
+ )
900
+ return Response(
901
+ {"detail": "Malformed webhook payload."},
902
+ status=status.HTTP_400_BAD_REQUEST,
903
+ )
904
+
905
+ envelope = git_webhook_auth.parse_envelope(provider, request.headers, payload)
906
+ if envelope.event is None:
907
+ # A configured-but-irrelevant event (push, comment, close) or a draft/WIP
908
+ # open, which deliberately does not promote the card. Not an error —
909
+ # return 2xx so the provider does not retry. ``reason`` distinguishes the
910
+ # two, because "I opened a draft and nothing happened" is a question
911
+ # someone will ask and the answer should not require reading Python.
912
+ outcome = envelope.ignored_reason or GIT_DELIVERY_IGNORED
913
+ _record_git_delivery(automation.pk, provider, outcome)
914
+ body: dict[str, Any] = {
915
+ "matched": False,
916
+ "moved": False,
917
+ "ignored": envelope.raw_event_name,
918
+ }
919
+ if envelope.ignored_reason:
920
+ body["reason"] = envelope.ignored_reason
921
+ return Response(body, status=status.HTTP_200_OK)
922
+
923
+ # Idempotency: a provider redelivery of the same event is a no-op. The
924
+ # forward-only guard in the service is a second, Redis-independent layer.
925
+ if not claim_webhook_delivery(project_pk, envelope.delivery_key):
926
+ _record_git_delivery(automation.pk, provider, GIT_DELIVERY_DUPLICATE)
927
+ return Response(
928
+ {"matched": False, "moved": False, "reason": "duplicate"},
929
+ status=status.HTTP_200_OK,
930
+ )
931
+
932
+ result = apply_git_event_to_card(automation, provider, envelope.event, envelope.pr_url)
933
+ _record_git_delivery(automation.pk, provider, result.reason)
934
+ if not result.matched:
935
+ # ``no_url`` / ``no_link``: the delivery verified but matched nothing.
936
+ # This is THE silent failure of the feature — the provider shows a green
937
+ # check and no card moves, because matching needs a TaskLink on the task
938
+ # pointing at the exact PR/MR URL and nobody created one.
939
+ logger.warning(
940
+ "git webhook matched no task: project=%s provider=%s event=%s reason=%s",
941
+ project_pk,
942
+ provider,
943
+ envelope.event,
944
+ result.reason,
945
+ )
946
+ return Response(
947
+ {
948
+ "matched": result.matched,
949
+ "moved": result.moved,
950
+ "task": result.task_id,
951
+ "from": result.from_status,
952
+ "to": result.to_status,
953
+ "reason": result.reason,
954
+ },
955
+ status=status.HTTP_200_OK,
956
+ )
957
+
958
+
959
+ @extend_schema(tags=["integrations"], responses={200: GitAutomationConfigSerializer})
960
+ class GitAutomationConfigView(IdempotencyMixin, APIView):
961
+ """``GET|PUT /api/v1/integrations/projects/{project_pk}/git-automation/`` — config (#329).
962
+
963
+ Project-admin only (Owner/Admin). Reads/sets the off-by-default toggle and
964
+ surfaces the webhook URL and whether a secret is set. The secret itself is
965
+ minted/rotated through :class:`GitAutomationRotateSecretView` and returned once.
966
+ """
967
+
968
+ # The PUT sets ``enabled`` to an explicit value on the per-project singleton, so a
969
+ # replay converges to the same state (naturally idempotent — no replayable resource).
970
+ idempotency_exempt = True
971
+ # IsProjectNotArchived gates the PUT only — the class returns True for GET/HEAD, so
972
+ # an admin can still read an archived project's automation config. It fires in
973
+ # `has_permission` because the route names `project_pk` (#3414).
974
+ permission_classes = [IsAuthenticated, IsProjectAdmin, IsProjectNotArchived]
975
+ # Reveals whether a webhook secret is set / lets an admin flip the toggle; scope it
976
+ # under the shared credential bucket (#1551) so config-probing is rate-bounded.
977
+ throttle_classes = [ScopedRateThrottle]
978
+ throttle_scope = "credential_rotate"
979
+
980
+ def _config_payload(self, request: Request, automation: BoardAutomation) -> dict[str, Any]:
981
+ return {
982
+ "enabled": automation.enabled,
983
+ "secret_set": automation.has_secret,
984
+ "webhook_url": _git_webhook_url(request, automation.project_id),
985
+ "configured_by": automation.configured_by_id,
986
+ "secret_set_at": automation.secret_set_at,
987
+ "updated_at": automation.updated_at,
988
+ "last_delivery_at": automation.last_delivery_at,
989
+ "last_delivery_outcome": automation.last_delivery_outcome,
990
+ "last_delivery_provider": automation.last_delivery_provider,
991
+ "last_refusal_at": automation.last_refusal_at,
992
+ "last_refusal_outcome": automation.last_refusal_outcome,
993
+ "last_refusal_provider": automation.last_refusal_provider,
994
+ }
995
+
996
+ def _get_or_init(self, project_pk: str) -> BoardAutomation:
997
+ project = get_object_or_404(Project, pk=project_pk, is_deleted=False)
998
+ automation, _ = BoardAutomation.objects.get_or_create(project=project)
999
+ return automation
1000
+
1001
+ def get(self, request: Request, project_pk: str) -> Response:
1002
+ automation = self._get_or_init(project_pk)
1003
+ data = GitAutomationConfigSerializer(self._config_payload(request, automation)).data
1004
+ return Response(data)
1005
+
1006
+ # The class-level ``@extend_schema`` covers GET and PUT alike and names no
1007
+ # request, so PUT published no ``requestBody`` while requiring ``enabled``
1008
+ # (#3364). Declared on the method, not the class, because GET must keep taking
1009
+ # no body. This is a plain ``APIView``: there is no ``serializer_class`` for
1010
+ # drf-spectacular to infer a writable shape from, so nothing supplies this but
1011
+ # the annotation.
1012
+ @extend_schema(
1013
+ request=GitAutomationUpdateSerializer,
1014
+ responses={
1015
+ 200: GitAutomationConfigSerializer,
1016
+ 403: OpenApiResponse(
1017
+ description=(
1018
+ "Caller is not a project Admin, or the project is archived (#3414). "
1019
+ "Declared on the method, not the class: the class-level annotation "
1020
+ "covers GET too, and GET stays available while archived."
1021
+ )
1022
+ ),
1023
+ },
1024
+ )
1025
+ def put(self, request: Request, project_pk: str) -> Response:
1026
+ serializer = GitAutomationUpdateSerializer(data=request.data)
1027
+ serializer.is_valid(raise_exception=True)
1028
+ automation = self._get_or_init(project_pk)
1029
+ automation.enabled = serializer.validated_data["enabled"]
1030
+ # request.user is a real User here (IsAuthenticated gates this view); the
1031
+ # cast narrows away the AnonymousUser arm the field FK does not accept.
1032
+ automation.configured_by = cast("Any", request.user)
1033
+ automation.save(update_fields=["enabled", "configured_by", "updated_at"])
1034
+ data = GitAutomationConfigSerializer(self._config_payload(request, automation)).data
1035
+ return Response(data)
1036
+
1037
+
1038
+ @extend_schema(
1039
+ tags=["integrations"],
1040
+ request=None,
1041
+ responses={
1042
+ 201: GitAutomationSecretSerializer,
1043
+ 403: OpenApiResponse(
1044
+ description=(
1045
+ "Caller is not a project Admin, or the project is archived (#3414) — "
1046
+ "minting a credential is a write. Nothing is stranded by the refusal: "
1047
+ "the receiver refuses an archived project's deliveries outright."
1048
+ )
1049
+ ),
1050
+ },
1051
+ )
1052
+ class GitAutomationRotateSecretView(IdempotencyMixin, APIView):
1053
+ """``POST .../git-automation/rotate-secret/`` — mint a new webhook secret (#329).
1054
+
1055
+ Project-admin only. Generates a fresh URL-safe secret, stores it Fernet-encrypted,
1056
+ and returns the plaintext **once** in the response — it is never retrievable again
1057
+ (the GET endpoint only reports whether a secret is set).
1058
+ """
1059
+
1060
+ # Exempt: the secret lives in a single column on the per-project singleton, so a
1061
+ # double rotation is last-write-wins — only one secret is ever active and a replay
1062
+ # cannot mint a parallel credential. This is a deliberate admin action, not a
1063
+ # retry-prone client mutation.
1064
+ idempotency_exempt = True
1065
+ # Minting a credential on an archived project is a write (#3414). Nothing is
1066
+ # stranded by refusing it: the receiver now refuses an archived project's
1067
+ # deliveries outright, so there is no live webhook left to rotate away from.
1068
+ # IsNotTokenAuthenticated (#2939): this mints a durable, plaintext-once
1069
+ # credential a leaked PAT should not be able to extend itself with — the
1070
+ # same rule #2878 applied to /me/api-tokens/.
1071
+ permission_classes = [
1072
+ IsAuthenticated,
1073
+ IsProjectAdmin,
1074
+ IsProjectNotArchived,
1075
+ IsNotTokenAuthenticated,
1076
+ ]
1077
+ # Mints and returns a fresh plaintext webhook secret; scope it under the shared
1078
+ # credential bucket (#1551) so secret rotation cannot be hammered.
1079
+ throttle_classes = [ScopedRateThrottle]
1080
+ throttle_scope = "credential_rotate"
1081
+
1082
+ def post(self, request: Request, project_pk: str) -> Response:
1083
+ project = get_object_or_404(Project, pk=project_pk, is_deleted=False)
1084
+ # 256 bits of entropy, URL-safe so it pastes cleanly into a provider's
1085
+ # webhook secret field.
1086
+ plaintext = secrets.token_urlsafe(32)
1087
+ with transaction.atomic():
1088
+ automation, _ = BoardAutomation.objects.get_or_create(project=project)
1089
+ automation.set_secret(plaintext)
1090
+ automation.configured_by = cast("Any", request.user)
1091
+ automation.save(
1092
+ update_fields=[
1093
+ "secret_ciphertext",
1094
+ "secret_set_at",
1095
+ "configured_by",
1096
+ "updated_at",
1097
+ ]
1098
+ )
1099
+ return Response(
1100
+ {
1101
+ "secret": plaintext,
1102
+ "webhook_url": _git_webhook_url(request, project.pk),
1103
+ "secret_set_at": automation.secret_set_at,
1104
+ },
1105
+ status=status.HTTP_201_CREATED,
1106
+ )