trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,934 @@
1
+ """Cookie-based JWT auth views (#897).
2
+
3
+ These views replace the stock simplejwt token endpoints so the *refresh* token
4
+ is delivered to the browser in an ``httpOnly`` cookie scoped to the refresh path
5
+ instead of in the JSON response body. The access token is still returned in the
6
+ JSON body and held in memory by the SPA.
7
+
8
+ Why httpOnly for the refresh token:
9
+ A refresh token is long-lived and exchangeable for access tokens, so it is
10
+ the high-value credential. Persisting it where JavaScript can read it
11
+ (localStorage / a JS-visible cookie) means any XSS that runs in the SPA can
12
+ exfiltrate it and impersonate the user past the lifetime of any single
13
+ access token. An ``httpOnly`` cookie is unreadable from JavaScript, so an
14
+ XSS payload can ride the current session but cannot steal the long-lived
15
+ credential. The short-lived access token stays in memory (not localStorage)
16
+ so it dies with the tab.
17
+
18
+ CSRF posture:
19
+ The refresh cookie uses ``SameSite=Strict`` (by default — see
20
+ ``core/refresh_cookie_policy.py``) and is ``Path``-scoped to the refresh
21
+ endpoint only. ``SameSite=Strict`` means the browser never attaches the
22
+ cookie to a cross-site request, so a forged request from an attacker's
23
+ origin cannot trigger a refresh on the victim's behalf. The refresh endpoint
24
+ is otherwise unauthenticated-by-cookie (it reads the refresh token solely
25
+ from this cookie), and a successful refresh only mints a new short-lived
26
+ access token returned in the response body — which a cross-site attacker
27
+ cannot read (CORS). There is therefore no additional CSRF *token* required
28
+ on the refresh path. Login and logout are likewise safe against a *missing*
29
+ CSRF token: login carries no ambient credential, and logout is idempotent
30
+ (clearing a cookie + best-effort blacklist) with no cross-site state-change
31
+ value beyond revoking the caller's own session.
32
+
33
+ SameSite is nonetheless a **single, operator-configurable** control (#3556):
34
+ ``TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE`` can be relaxed to ``Lax`` or
35
+ ``None`` for a split-origin/framed deploy, and at ``None`` the cookie rides
36
+ on every cross-site request. :func:`_is_cross_site_auth_request` is defense
37
+ in depth for exactly that case: it independently refuses a refresh/logout
38
+ request whose ``Sec-Fetch-Site`` is ``cross-site``, or whose ``Origin`` is
39
+ present and neither same-origin nor in ``CSRF_TRUSTED_ORIGINS`` — so a
40
+ relaxed SameSite policy is not the *only* thing standing between a forged
41
+ cross-site page and these endpoints. A request that sends neither header
42
+ (the mobile client, a script, curl) is not treated as cross-site; see that
43
+ function's docstring for why refusing them would be the wrong trade.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import contextlib
49
+ import hashlib
50
+ import ipaddress
51
+ import logging
52
+ from collections.abc import Sequence
53
+ from datetime import timedelta
54
+ from typing import Any, TypeGuard, cast
55
+ from urllib.parse import urlsplit
56
+
57
+ from django.conf import settings
58
+ from django.contrib.auth import get_user_model
59
+ from django.core.exceptions import ValidationError as DjangoValidationError
60
+ from django.http import HttpRequest
61
+ from django.utils import timezone
62
+ from drf_spectacular.utils import OpenApiResponse, extend_schema
63
+ from rest_framework import serializers, status
64
+ from rest_framework.exceptions import AuthenticationFailed, Throttled
65
+ from rest_framework.request import Request
66
+ from rest_framework.response import Response
67
+ from rest_framework.throttling import ScopedRateThrottle
68
+ from rest_framework.views import APIView
69
+ from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
70
+ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
71
+ from rest_framework_simplejwt.tokens import RefreshToken
72
+ from rest_framework_simplejwt.views import TokenObtainPairView
73
+
74
+ from trueppm_api.core.throttling import LoginAccountRateThrottle, resolve_client_ident
75
+
76
+ # Auth-domain logger. Failed-login attempts are emitted here as a structured
77
+ # WARNING so self-hosting operators can alarm on brute-force / credential-stuffing
78
+ # spikes (e.g. ship trueppm.auth to a SIEM and alert on a burst). Reuses the same
79
+ # named logger as the password-reset flow so all auth events land on one channel.
80
+ logger = logging.getLogger("trueppm.auth")
81
+
82
+ #: The active user model. Bound once at module scope so the login path's
83
+ #: email resolution and its timing-equalizing dummy hash both go through the
84
+ #: same class the authentication backend uses.
85
+ User = get_user_model()
86
+
87
+
88
+ def _client_ip(request: HttpRequest | Request) -> str:
89
+ """Best-effort client IP for the audit event.
90
+
91
+ The address the per-IP throttles key on (``core.throttling.resolve_client_ident``),
92
+ not the left-most ``X-Forwarded-For`` entry, which the client writes (#4023). So
93
+ ``auth.login_failed`` names the address the login throttle actually charged, and
94
+ a brute-forcer cannot pick what the forensic record says.
95
+
96
+ **The result is parsed as an IP address and replaced with ``"invalid"`` if it is
97
+ not one** (#3552). At ``NUM_PROXIES = 1`` with a single-entry header the resolved
98
+ value is still whatever that one entry says, and this field sits before another
99
+ in a space-delimited ``key=value`` line, so an unvalidated value containing spaces and ``=
100
+ lets a caller inject extra pairs into the record:
101
+
102
+ X-Forwarded-For: 1.2.3.4 user_id=1 method=password
103
+
104
+ A last-wins logfmt/Splunk-kv extractor then attributes the session to a different
105
+ account. Raw CR/LF cannot reach here (the HTTP parser rejects them) so a wholly
106
+ forged line was never possible, but intra-line field forgery was. Validating here
107
+ fixes ``auth.login_failed`` — reachable *without* credentials, and so the worse of
108
+ the two — at the same time.
109
+ """
110
+ candidate = resolve_client_ident(request)
111
+ if not candidate:
112
+ return "unknown"
113
+ try:
114
+ return str(ipaddress.ip_address(candidate))
115
+ except ValueError:
116
+ return "invalid"
117
+
118
+
119
+ def _origin_matches_trusted(origin: str, trusted_origins: Sequence[str]) -> bool:
120
+ """Whether ``origin`` matches an entry in ``CSRF_TRUSTED_ORIGINS`` (#3556).
121
+
122
+ Mirrors Django's own matching rules for that setting: an exact
123
+ ``scheme://host[:port]`` match, or a ``https://*.example.com``-style entry
124
+ (a leading ``*.`` wildcard segment) matching any subdomain of
125
+ ``example.com`` on that scheme. Case-sensitive on the host is intentional —
126
+ hostnames arrive lower-cased from both the browser and ``CSRF_TRUSTED_ORIGINS``
127
+ in practice, and DNS names are case-insensitive but this list is operator-typed
128
+ configuration, not attacker input, so normalizing case here would hide a typo
129
+ rather than tolerate one.
130
+ """
131
+ for allowed in trusted_origins:
132
+ if origin == allowed:
133
+ return True
134
+ scheme, _, host_pattern = allowed.partition("://")
135
+ if not host_pattern.startswith("*."):
136
+ continue
137
+ suffix = host_pattern[1:] # "*.example.com" -> ".example.com"
138
+ origin_scheme, _, origin_host = origin.partition("://")
139
+ if origin_scheme == scheme and origin_host.endswith(suffix) and origin_host != suffix:
140
+ return True
141
+ return False
142
+
143
+
144
+ def _is_same_origin(request: Request, origin: str) -> bool:
145
+ """Whether an ``Origin`` header value names this request's own origin (#3556)."""
146
+ parsed = urlsplit(origin)
147
+ if not parsed.scheme or not parsed.netloc:
148
+ # Not a well-formed "scheme://host" origin — never treat it as a match.
149
+ return False
150
+ return parsed.scheme == request.scheme and parsed.netloc == request.get_host()
151
+
152
+
153
+ def _is_cross_site_auth_request(request: Request) -> bool:
154
+ """Whether a refresh/logout POST carries a browser-supplied cross-site signal.
155
+
156
+ Defense in depth for #3556: ``SameSite`` on the refresh cookie is the
157
+ unconditional CSRF control, but it is operator-configurable
158
+ (``TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE``) and can be relaxed to ``Lax`` or
159
+ ``None`` for a split-origin/framed deploy. This check is independent of that
160
+ setting — it refuses on either of two signals, checked in order:
161
+
162
+ 1. ``Sec-Fetch-Site: cross-site`` — the Fetch Metadata header every current
163
+ mainstream browser sends, and unambiguous: the browser itself is
164
+ reporting that the request's initiator is a different site. Values other
165
+ than exactly ``"cross-site"`` (``same-origin``, ``same-site``, ``none``,
166
+ or the header absent) are not refused here.
167
+ 2. An ``Origin`` header that is present and names neither this request's own
168
+ origin nor an entry in ``CSRF_TRUSTED_ORIGINS`` (for a reverse proxy that
169
+ rewrites ``Origin``/``Referer``, same as the CSRF middleware's own use of
170
+ that setting).
171
+
172
+ **Absence of both headers is deliberately NOT treated as cross-site.** A
173
+ non-browser caller — the mobile app, a server-to-server integration, a
174
+ script using curl/httpx — sends neither Fetch Metadata nor (usually) an
175
+ ``Origin`` header, and these two endpoints are unauthenticated-by-anything-
176
+ but-the-cookie by design (the access token may already have expired). Failing
177
+ such a request closed would lock out every legitimate non-browser client
178
+ while adding no protection — an attacker forging a cross-site *browser*
179
+ request cannot suppress the headers a real cross-site fetch/form-post
180
+ triggers, but a same-origin, header-free tool is simply not a CSRF actor.
181
+ This function is therefore a browser-specific backstop layered on top of
182
+ SameSite, never the sole gate.
183
+ """
184
+ if request.META.get("HTTP_SEC_FETCH_SITE") == "cross-site":
185
+ return True
186
+
187
+ origin = request.META.get("HTTP_ORIGIN")
188
+ if not origin:
189
+ return False
190
+ if _is_same_origin(request, origin):
191
+ return False
192
+ trusted_origins = getattr(settings, "CSRF_TRUSTED_ORIGINS", None) or []
193
+ return not _origin_matches_trusted(origin, trusted_origins)
194
+
195
+
196
+ def _login_body(request_data: Any) -> dict[str, Any]:
197
+ """Return the login body as a dict, or an empty one.
198
+
199
+ A non-object JSON body (list/str/scalar) leaves ``request.data`` without a
200
+ ``.get``; every helper here goes through this guard so no path can turn a
201
+ malformed login into a 500 (#2126). Returning ``{}`` rather than ``None`` keeps
202
+ each caller's own logic single-branch.
203
+ """
204
+ return request_data if isinstance(request_data, dict) else {}
205
+
206
+
207
+ def _submitted_identifier(request_data: Any) -> str | None:
208
+ """Return the trimmed ``username`` field from a login body, or None.
209
+
210
+ A non-string value is refused rather than coerced — ``str(...)`` on a dict or
211
+ list would mint an identifier nobody typed.
212
+ """
213
+ raw = _login_body(request_data).get("username")
214
+ return raw.strip() if isinstance(raw, str) else None
215
+
216
+
217
+ def _looks_like_email(identifier: str | None) -> TypeGuard[str]:
218
+ """True when ``identifier`` is worth retrying as an email address.
219
+
220
+ Deliberately just an ``@`` test rather than validation. Two things it buys, and
221
+ the second is the load-bearing one:
222
+
223
+ * the ordinary username path never pays for the extra query, because a
224
+ username without an ``@`` never reaches the fallback at all; and
225
+ * the empty string can never reach ``email__iexact``. ``User.email`` is
226
+ ``blank=True`` on Django's default model — SSO- and admin-created accounts
227
+ routinely have ``""`` — so an ``email__iexact=""`` lookup would match every
228
+ account that has no email at all, and on an install with exactly one such
229
+ account it would hand an attacker a working identifier for it.
230
+ """
231
+ return identifier is not None and "@" in identifier
232
+
233
+
234
+ def _resolve_email_identifier(identifier: str) -> str | None:
235
+ """Resolve an email address to the username of the one account holding it.
236
+
237
+ Returns ``None`` whenever the caller must NOT be retried under a different
238
+ identifier. Django's ``User.email`` carries no uniqueness constraint, so the
239
+ ambiguous case is the dangerous one and it fails closed — mirroring the
240
+ ``email__iexact`` branch in ``apps.sso.services.resolve_user``, which refuses
241
+ an ambiguous match for the same reason: silently picking one of two accounts
242
+ signs the caller in as an account they did not name.
243
+ """
244
+ matches = list(
245
+ User.objects.filter(email__iexact=identifier)
246
+ .order_by("pk")
247
+ .values_list("username", flat=True)[:2]
248
+ )
249
+ if len(matches) != 1:
250
+ # 0 → nothing to retry. 2+ → ambiguous, fail closed (see docstring).
251
+ return None
252
+ username = str(matches[0])
253
+ if username.lower() == identifier.lower():
254
+ # The account's username IS this string, so the first attempt already tried
255
+ # it against this exact account and it failed. Retrying would spend a second
256
+ # password comparison to reach the identical answer.
257
+ return None
258
+ return username
259
+
260
+
261
+ def _burn_equivalent_password_hash(request_data: Any) -> None:
262
+ """Spend one password-hash's worth of work on the email-fallback miss path.
263
+
264
+ Without this the endpoint is an email-existence oracle. ``ModelBackend`` already
265
+ equalizes its own username miss (it runs ``UserModel().set_password(password)``
266
+ so a nonexistent user costs the same as a wrong password), but the email
267
+ fallback adds a *second* real comparison on the hit path only — so "this email
268
+ has an account" would measure as roughly twice the hash time of "it does not",
269
+ and the dominant term in a login response is the hasher, not the query.
270
+
271
+ Called on every ``@``-shaped identifier whose fallback did not run, so all
272
+ ``@``-shaped failures cost two hashes and are indistinguishable from each other.
273
+ This equalizes the dominant term, not every nanosecond — the extra ``email__iexact``
274
+ query on some paths is ~1ms against ~100ms of PBKDF2, well under the noise floor
275
+ of a network round trip. The cost is that a failed ``@``-shaped login burns two
276
+ hashes instead of one; both login throttles bound that, and it is the same trade
277
+ Django itself already makes for the username miss.
278
+ """
279
+ password = _login_body(request_data).get("password")
280
+ # The User() here is disposable — never saved, discarded on return — so there is
281
+ # no unvalidated credential being persisted; this mirrors ModelBackend's own
282
+ # UserModel().set_password(password) timing-equalization decoy.
283
+ # nosemgrep: unvalidated-password
284
+ User().set_password(password if isinstance(password, str) else "")
285
+
286
+
287
+ def _emit_login_failure_event(request: Request, canonical_identifier: str | None = None) -> None:
288
+ """Emit an auth-failure audit event for a rejected login (#1717).
289
+
290
+ The attempted username is hashed (never logged in the clear) so the event is
291
+ correlatable across attempts — an operator can see that one account is being
292
+ hammered from many IPs — without writing raw credentials/emails into logs.
293
+
294
+ ``canonical_identifier`` is the account's own username, passed when the attempt
295
+ arrived as an email and the view resolved it (#3468). Hashing the *resolved*
296
+ identifier is what keeps that correlation working: without it one account
297
+ produces two distinct hashes depending on which of its identifiers was typed,
298
+ and an operator alarming on "one account, many IPs" undercounts by exactly the
299
+ split.
300
+ """
301
+ # A non-object JSON body (list/str/scalar) leaves ``request.data`` without a
302
+ # ``.get``; guard on the dict shape so audit emission can never turn a rejected
303
+ # login into a 500 (#2126). A non-object body simply has no username → "unknown".
304
+ emit_login_failure(
305
+ request, identifier=canonical_identifier or _submitted_identifier(request.data)
306
+ )
307
+
308
+
309
+ def emit_login_failure(request: HttpRequest | Request, *, identifier: str | None) -> None:
310
+ """Emit ``auth.login_failed`` for a rejected login on any door.
311
+
312
+ The counterpart to :func:`emit_login_success`, and public for the same reason
313
+ (#3557): the Django admin is a second password door, and an operator alarming
314
+ on credential stuffing must see its refusals on the same channel, in the same
315
+ shape, as the API's. A door that emitted its own differently-spelled line would
316
+ be invisible to the rule ``auth.login_failed`` exists to feed.
317
+
318
+ ``identifier`` is the submitted (or view-resolved) username; it is hashed here
319
+ and never logged in the clear. ``request`` may be a plain Django ``HttpRequest``
320
+ — only ``META`` is read, for the best-effort client IP.
321
+ """
322
+ username_hash = (
323
+ hashlib.sha256(str(identifier).strip().lower().encode("utf-8")).hexdigest()
324
+ if identifier
325
+ else "unknown"
326
+ )
327
+ logger.warning(
328
+ "auth.login_failed username_hash=%s client_ip=%s",
329
+ username_hash,
330
+ _client_ip(request),
331
+ )
332
+
333
+
334
+ def emit_login_success(
335
+ request: HttpRequest | Request, *, user: Any, method: str, remember: bool
336
+ ) -> None:
337
+ """Emit the structured login-success line for a session that was actually minted.
338
+
339
+ The counterpart to :func:`_emit_login_failure_event` (#3552, ADR-1120). Both land on
340
+ ``trueppm.auth`` — including the SSO callback's success, which imports this rather
341
+ than logging to ``trueppm.sso`` beside the SSO *refusals*. Correlating "who got in"
342
+ must not require knowing which door they used.
343
+
344
+ **Call this last, once the session exists — not when credentials validate.** The two
345
+ are different moments: :class:`CookieTokenObtainPairView` runs the enterprise
346
+ ``local_login_allowed`` seam *after* validation, and that seam can still return a 403
347
+ with no cookie. A line emitted at the validation point therefore reports a success
348
+ for a request that was refused, which inverts exactly the signal an operator alarms
349
+ on. The same applies to the SSO callback: after the refresh cookie is set, not after
350
+ ``resolve_user`` returns.
351
+
352
+ ``INFO``, not ``WARNING`` — a successful login is not an anomaly, and putting it at
353
+ ``WARNING`` beside ``auth.login_failed`` would poison the alerting rule that line
354
+ exists to feed. ``DJANGO_LOG_LEVEL`` defaults to ``INFO``, so the line is visible in a
355
+ default production deploy; ``settings/dev.py`` replaces ``LOGGING`` with a root
356
+ handler at ``WARNING``, so it is not visible under dev settings (which is why tests
357
+ raise the level explicitly).
358
+
359
+ Fields go in the message rather than in ``extra=`` because the dev console formatter
360
+ renders only ``%(message)s``: an ``extra``-only field would exist in the production
361
+ JSON handler and nowhere else, and a record whose content depends on the deployment is
362
+ worse than one that is uniformly greppable.
363
+
364
+ Args:
365
+ request: The request that established the session (read only for the client IP).
366
+ user: The authenticated user. Only ``pk`` is logged — never the email or
367
+ username, matching ``_emit_login_failure_event``, which hashes the submitted
368
+ identifier rather than writing it in the clear.
369
+ method: ``"password"``, ``"sso:<provider-slug>"``, or ``"admin"`` (the
370
+ hardened Django admin login, #3557).
371
+ remember: Whether the session opted into browser-persistent "remember me". SSO
372
+ logins are always ``False`` (an IdP redirect carries no such choice).
373
+ """
374
+ logger.info(
375
+ "auth.login_succeeded user_id=%s method=%s client_ip=%s remember=%s",
376
+ getattr(user, "pk", None),
377
+ method,
378
+ _client_ip(request),
379
+ remember,
380
+ )
381
+
382
+
383
+ def _set_refresh_cookie(
384
+ response: Response, refresh_token: str, *, persistent_seconds: int | None
385
+ ) -> None:
386
+ """Attach the refresh token to ``response`` as a hardened httpOnly cookie.
387
+
388
+ Cookie attributes are driven by settings so a non-HTTPS local dev server can
389
+ still complete the flow (``AUTH_REFRESH_COOKIE_SECURE=False``) while
390
+ production defaults to ``Secure``. The cookie is ``Path``-scoped to
391
+ ``/api/v1/auth/`` so it is never sent on ordinary API calls — only the auth
392
+ endpoints that must read it (refresh and logout) receive it. Scoping it to the
393
+ refresh endpoint alone silently broke logout revocation (#2999).
394
+
395
+ ``persistent_seconds`` controls browser *persistence* (#2246): an int sets a
396
+ ``Max-Age`` so the cookie survives browser close ("remember me"); ``None``
397
+ emits no ``Max-Age``/``Expires`` → a **session cookie** that the browser drops
398
+ on close (the not-remembered / shared-machine default). This is independent of
399
+ the token's own ``exp`` (the real credential bound), which the caller sets via
400
+ :func:`_apply_remember`.
401
+ """
402
+ response.set_cookie(
403
+ key=settings.AUTH_REFRESH_COOKIE_NAME,
404
+ value=refresh_token,
405
+ max_age=persistent_seconds,
406
+ httponly=True,
407
+ secure=settings.AUTH_REFRESH_COOKIE_SECURE,
408
+ samesite=settings.AUTH_REFRESH_COOKIE_SAMESITE,
409
+ path=settings.AUTH_REFRESH_COOKIE_PATH,
410
+ )
411
+
412
+
413
+ def _cookie_seconds(remember: bool) -> int | None:
414
+ """Cookie ``Max-Age`` for a remember choice: persistent 30d, or session (None)."""
415
+ if remember:
416
+ return int(settings.REFRESH_TOKEN_REMEMBER_LIFETIME.total_seconds())
417
+ return None
418
+
419
+
420
+ def _record_outstanding(refresh: RefreshToken) -> None:
421
+ """Upsert the ``OutstandingToken`` bookkeeping row for ``refresh``.
422
+
423
+ This row is what makes a refresh token *revocable*. ``revoke_all_refresh_tokens``
424
+ (the "sign out every device" primitive behind password reset and off-boarding)
425
+ iterates ``OutstandingToken`` and blacklists each row, so a live token with no
426
+ row cannot be revoked by any server-side action.
427
+
428
+ Two distinct cases, which is why this is an upsert rather than an update (#2999):
429
+
430
+ * **Login** — ``RefreshToken.for_user`` already wrote a row, but its
431
+ ``expires_at`` was copied from the token's ``exp`` *at creation*: the 7-day
432
+ class default, before :func:`_apply_remember` overrides it. ``expires_at``
433
+ drives the nightly ``flushexpiredtokens`` cleanup and ``BlacklistedToken``
434
+ cascades on the ``OutstandingToken``, so a row left at 7 days for a 30-day
435
+ token would have its revocation record flushed on day 7 while the JWT stayed
436
+ valid to day 30 → **replayable between day 7 and day 30**.
437
+ * **Rotation** — ``refresh.set_jti()`` mints a new jti and simplejwt records
438
+ nothing for it (``outstand()`` is called nowhere in simplejwt's rotation
439
+ path). Before #2999 the row was therefore missing for precisely the token
440
+ that was live, so ``revoke_all_refresh_tokens`` walked a set of
441
+ already-blacklisted rows, returned a plausible non-zero count, and left the
442
+ real session usable. A password reset did not evict an attacker.
443
+
444
+ ``created_at`` is only written on insert, so a re-issued row keeps its original
445
+ mint time. No-op when the blacklist app is absent (lean deploy degrades to
446
+ TTL-only expiry) or when the token carries no jti/user claim.
447
+ """
448
+ if "rest_framework_simplejwt.token_blacklist" not in settings.INSTALLED_APPS:
449
+ return
450
+ from rest_framework_simplejwt.settings import api_settings
451
+ from rest_framework_simplejwt.token_blacklist.models import OutstandingToken
452
+ from rest_framework_simplejwt.utils import datetime_from_epoch
453
+
454
+ jti = refresh.payload.get(api_settings.JTI_CLAIM)
455
+ user_id = refresh.payload.get(api_settings.USER_ID_CLAIM)
456
+ if jti is None or user_id is None:
457
+ return
458
+
459
+ OutstandingToken.objects.update_or_create(
460
+ jti=jti,
461
+ create_defaults={
462
+ "user_id": user_id,
463
+ "token": str(refresh),
464
+ "created_at": timezone.now(),
465
+ "expires_at": datetime_from_epoch(refresh["exp"]),
466
+ },
467
+ defaults={
468
+ "user_id": user_id,
469
+ "token": str(refresh),
470
+ "expires_at": datetime_from_epoch(refresh["exp"]),
471
+ },
472
+ )
473
+
474
+
475
+ def _token_subject_is_active(refresh: RefreshToken) -> bool:
476
+ """Whether the account a refresh token was minted for is still usable.
477
+
478
+ Returns ``False`` for a missing claim, an account that no longer exists, and a
479
+ deactivated one. The wording of the caller's rejection is deliberately the same
480
+ "invalid or expired" string simplejwt uses for a bad token: a distinct message
481
+ would tell an attacker holding a stolen token that the account exists and was
482
+ disabled, which is an enumeration signal on the one endpoint that is reachable
483
+ without authentication.
484
+ """
485
+ from rest_framework_simplejwt.settings import api_settings
486
+
487
+ user_id = refresh.payload.get(api_settings.USER_ID_CLAIM)
488
+ if user_id is None:
489
+ return False
490
+
491
+ user_model = get_user_model()
492
+ try:
493
+ return user_model.objects.filter(
494
+ **{api_settings.USER_ID_FIELD: user_id, "is_active": True}
495
+ ).exists()
496
+ except (DjangoValidationError, ValueError, TypeError):
497
+ # A claim of the wrong type for the pk column (a string where an int is
498
+ # expected) makes Django raise at query build. DRF does not convert
499
+ # Django's ValidationError, so letting it escape turns a bad token into a
500
+ # 500 on an unauthenticated endpoint. Treat an unusable claim as "not a
501
+ # valid subject" — the same answer as a claim that matches nothing.
502
+ return False
503
+
504
+
505
+ def _apply_remember(refresh: RefreshToken, *, remember: bool) -> None:
506
+ """Stamp the ``remember`` claim + matching ``exp`` on a refresh token (#2246).
507
+
508
+ The choice is carried as a boolean claim *on the token itself* so it survives
509
+ encode/decode and is inherited automatically across rotation (the stateless
510
+ refresh endpoint has no ``remember_me`` in its request). ``set_exp(lifetime=)``
511
+ overrides simplejwt's class default, baking the real credential lifetime — 30
512
+ days for remember, 12 hours (sliding via rotation) for a session login.
513
+ """
514
+ lifetime = (
515
+ settings.REFRESH_TOKEN_REMEMBER_LIFETIME
516
+ if remember
517
+ else settings.REFRESH_TOKEN_SESSION_LIFETIME
518
+ )
519
+ refresh["remember"] = remember
520
+ refresh.set_exp(lifetime=lifetime)
521
+
522
+
523
+ def _clear_refresh_cookie(response: Response) -> None:
524
+ """Delete the refresh cookie, matching the path/samesite it was set with.
525
+
526
+ ``delete_cookie`` must be called with the same ``path`` (and ``samesite``)
527
+ the cookie was set with, or the browser keeps the original cookie.
528
+ """
529
+ response.delete_cookie(
530
+ key=settings.AUTH_REFRESH_COOKIE_NAME,
531
+ path=settings.AUTH_REFRESH_COOKIE_PATH,
532
+ samesite=settings.AUTH_REFRESH_COOKIE_SAMESITE,
533
+ )
534
+
535
+
536
+ class _LoginRequestSerializer(serializers.Serializer): # type: ignore[type-arg]
537
+ """Login request body: credentials + the optional ``remember_me`` flag (#2246).
538
+
539
+ ``username`` accepts either identifier (#3468) — the field keeps its name for
540
+ wire compatibility with every existing client and with simplejwt's serializer.
541
+
542
+ Declared for the OpenAPI schema only — the view validates credentials via
543
+ simplejwt's ``TokenObtainPairSerializer`` and reads ``remember_me`` from the
544
+ body separately, so this serializer documents the request shape (so API-first
545
+ consumers see ``remember_me``) without being used to validate.
546
+ """
547
+
548
+ username = serializers.CharField(
549
+ help_text=(
550
+ "The account's username, or the email address on the account (#3468). "
551
+ "The username is matched first; the email is tried only if that fails, "
552
+ "and only when exactly one account carries it — an email shared by two "
553
+ "accounts is refused rather than resolved to either."
554
+ )
555
+ )
556
+ password = serializers.CharField(write_only=True, style={"input_type": "password"})
557
+ remember_me = serializers.BooleanField(
558
+ required=False,
559
+ default=False,
560
+ help_text=(
561
+ "Keep the session across browser close (~30 days). Omitted or false → a "
562
+ "session-only login: the refresh cookie is dropped on browser close, with "
563
+ "a ~12h idle lifetime. Must be a JSON boolean; non-boolean values are "
564
+ "treated as false."
565
+ ),
566
+ )
567
+
568
+
569
+ class _LoginResponseSerializer(serializers.Serializer): # type: ignore[type-arg]
570
+ """Login response shape: the access token only.
571
+
572
+ The refresh token is intentionally absent from the body — it is delivered as
573
+ an httpOnly cookie (#897). Declared explicitly so drf-spectacular does not
574
+ emit simplejwt's default ``TokenObtainPair`` schema, which still claims a
575
+ required ``refresh`` field this view never returns (#997).
576
+ """
577
+
578
+ access = serializers.CharField()
579
+
580
+
581
+ def _resolve_login_email_fallback(body: Any) -> str | None:
582
+ """Resolve a failed username login's identifier as an email, or ``None``.
583
+
584
+ When the identifier looks like an email but matches no account, burns an
585
+ equivalent password hash so the refusal costs the same as a wrong password and
586
+ does not reveal whether an account carries that email.
587
+ """
588
+ identifier = _submitted_identifier(body)
589
+ if not _looks_like_email(identifier):
590
+ return None
591
+ resolved = _resolve_email_identifier(identifier)
592
+ if resolved is None:
593
+ _burn_equivalent_password_hash(body)
594
+ return resolved
595
+
596
+
597
+ class AuthErrorDetailSerializer(serializers.Serializer): # type: ignore[type-arg]
598
+ """The DRF detail error envelope returned with a 401 or 403 from login."""
599
+
600
+ detail = serializers.CharField()
601
+
602
+
603
+ class CookieTokenObtainPairView(TokenObtainPairView):
604
+ """JWT login: return the access token in the body, refresh in an httpOnly cookie.
605
+
606
+ Throttled with the scoped ``login`` rate (#770) to bound password guessing.
607
+ The refresh token is *removed* from the JSON response body and set as a
608
+ hardened cookie instead, so a successful login response no longer carries the
609
+ long-lived credential anywhere JavaScript can read it (#897).
610
+
611
+ Two throttles are STACKED (#1717): the IP-keyed ``login`` scope bounds guesses
612
+ per source address, and ``LoginAccountRateThrottle`` bounds guesses per
613
+ *account* (hashed username) across all source IPs — closing the distributed
614
+ credential-stuffing gap where a rotating IP pool gets the full per-IP allowance
615
+ from every fresh IP. Both must pass for a request to reach authentication.
616
+ """
617
+
618
+ # RUF012: throttle_classes is inherited from the simplejwt base; ruff reads
619
+ # this as a fresh mutable default it can't resolve.
620
+ throttle_classes = [ScopedRateThrottle, LoginAccountRateThrottle] # noqa: RUF012
621
+ throttle_scope = "login"
622
+
623
+ @extend_schema(
624
+ summary="Log in and obtain a JWT access token",
625
+ description=(
626
+ "Returns the short-lived **access** token in the JSON body. The "
627
+ "long-lived **refresh** token is set as an httpOnly, Secure, "
628
+ "SameSite=Strict cookie scoped to the refresh endpoint; it is never "
629
+ "present in the response body (#897)."
630
+ ),
631
+ request=_LoginRequestSerializer,
632
+ responses={
633
+ 200: _LoginResponseSerializer,
634
+ 401: OpenApiResponse(
635
+ response=AuthErrorDetailSerializer,
636
+ description=(
637
+ "Invalid credentials. The body is identical for an unknown "
638
+ "user and a wrong password, so it never reveals which "
639
+ "accounts exist."
640
+ ),
641
+ ),
642
+ 403: OpenApiResponse(
643
+ response=AuthErrorDetailSerializer,
644
+ description=(
645
+ "Password sign-in is disabled for this account by policy "
646
+ "(e.g. enforced SSO); use single sign-on instead."
647
+ ),
648
+ ),
649
+ },
650
+ )
651
+ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response:
652
+ serializer = TokenObtainPairSerializer(data=request.data)
653
+ try:
654
+ serializer.is_valid(raise_exception=True)
655
+ except TokenError as exc:
656
+ raise InvalidToken(exc.args[0]) from exc
657
+ except AuthenticationFailed:
658
+ # Bad credentials (simplejwt raises AuthenticationFailed with code
659
+ # "no_active_account"). Before giving up, retry ONCE with the submitted
660
+ # identifier resolved as an email address (#3468): the sign-in form asks
661
+ # for an email, invited users choose a *username* at accept time, and
662
+ # ModelBackend matches on username only — so a user typing exactly what
663
+ # the label asked for was refused.
664
+ #
665
+ # Ordering is the security property, not a convenience. The username
666
+ # attempt runs FIRST and unchanged, so this can only ever ADD a way in:
667
+ # an existing username keeps working, and an account whose *username* is
668
+ # email-shaped is never shadowed by a different account that happens to
669
+ # carry that string as its *email*.
670
+ #
671
+ # This lives in the view rather than in an AUTHENTICATION_BACKENDS entry
672
+ # on purpose. A backend would widen every ``authenticate()`` caller in the
673
+ # process — the Django admin login among them — and would bypass this
674
+ # view's post-authentication policy seam below.
675
+ resolved = _resolve_login_email_fallback(request.data)
676
+ if resolved is None:
677
+ _emit_login_failure_event(request)
678
+ raise
679
+
680
+ # Charge AND enforce the resolved account's own throttle bucket. The
681
+ # per-account throttle keys on the identifier as submitted (it runs before
682
+ # this view), so an attacker alternating username and email would otherwise
683
+ # get two independent buckets against one account and double the guess
684
+ # allowance #1717 exists to bound.
685
+ #
686
+ # Recording alone is not enough, and is the shape that looks finished while
687
+ # being half-broken: the canonical bucket already accumulates every attempt
688
+ # in either form, but if nothing CHECKS it here, an attacker who spends the
689
+ # username budget first arrives at an untouched email bucket and gets a
690
+ # second full allowance. Only the email-first order would be capped. So the
691
+ # refusal happens here, before the second password comparison is spent.
692
+ wait = LoginAccountRateThrottle.consume(resolved)
693
+ if wait is not None:
694
+ _emit_login_failure_event(request, canonical_identifier=resolved)
695
+ raise Throttled(wait=wait) from None
696
+
697
+ # Re-validate through the SAME serializer so every downstream step —
698
+ # the enterprise password-login policy seam, remember_me, the refresh
699
+ # cookie, the OutstandingToken row — runs exactly as it does for a
700
+ # username login. A resolution that short-circuited any of them would
701
+ # be a second, weaker login path wearing the first one's name.
702
+ # Build the retry body from the guarded dict, not from ``request.data``:
703
+ # every other field the caller sent (``password``, ``remember_me``) rides
704
+ # through untouched, and only the identifier is rewritten.
705
+ serializer = TokenObtainPairSerializer(
706
+ data={**_login_body(request.data), "username": resolved}
707
+ )
708
+ try:
709
+ serializer.is_valid(raise_exception=True)
710
+ except TokenError as exc:
711
+ raise InvalidToken(exc.args[0]) from exc
712
+ except AuthenticationFailed:
713
+ # Right email, wrong password. Identical 401, identical body, and the
714
+ # same audit line as every other refusal — the caller must not be able
715
+ # to tell "no account with this email" from "wrong password".
716
+ _emit_login_failure_event(request, canonical_identifier=resolved)
717
+ raise
718
+
719
+ # Password-login policy seam (ADR-0187 §4). OSS always allows password
720
+ # login (the default returns True); trueppm-enterprise registers a policy
721
+ # that can block local login when an admin enforces org-wide SSO. Imported
722
+ # lazily so this auth path carries no hard dependency on the sso app at
723
+ # module load. The check runs only after a *successful* credential
724
+ # validation, so it never leaks whether an account exists.
725
+ from trueppm_api.apps.sso.extensions import local_login_allowed
726
+
727
+ if not local_login_allowed(serializer.user):
728
+ return Response(
729
+ {"detail": "Password sign-in is disabled for this account. Use single sign-on."},
730
+ status=status.HTTP_403_FORBIDDEN,
731
+ )
732
+
733
+ # "Remember me" (#2246): a remember_me of JSON `true` opts into a long-lived,
734
+ # browser-persistent session; anything else (unchecked / omitted / non-object
735
+ # body / a non-boolean like the string "false") falls back to the safe session
736
+ # default. We require an actual boolean `is True` rather than bool()-coercing,
737
+ # so a client that sends the *string* "false"/"0" is not silently upgraded to a
738
+ # 30-day persistent credential — the coercion must err toward session-only. The
739
+ # dict guard mirrors _emit_login_failure_event (a non-object body has no .get).
740
+ remember = isinstance(request.data, dict) and request.data.get("remember_me") is True
741
+
742
+ data = dict(serializer.validated_data)
743
+ refresh_token = data.pop("refresh", None)
744
+
745
+ response = Response(data, status=status.HTTP_200_OK)
746
+ if refresh_token:
747
+ # Reuse the token the serializer already minted (same jti / OutstandingToken
748
+ # row) rather than issuing a second one, then stamp the remember lifetime.
749
+ refresh = RefreshToken(refresh_token)
750
+ _apply_remember(refresh, remember=remember)
751
+ _record_outstanding(refresh)
752
+ _set_refresh_cookie(
753
+ response, str(refresh), persistent_seconds=_cookie_seconds(remember)
754
+ )
755
+ # LAST, deliberately (#3552, ADR-1120): the session now exists. Emitting this
756
+ # where the credentials validated would report a success for a login the
757
+ # ``local_login_allowed`` seam above still refuses with a 403 and no cookie.
758
+ emit_login_success(request, user=serializer.user, method="password", remember=remember)
759
+ return response
760
+
761
+
762
+ class _CookieRefreshResponseSerializer(serializers.Serializer): # type: ignore[type-arg]
763
+ """Response shape for the cookie-based refresh endpoint (access token only)."""
764
+
765
+ access = serializers.CharField()
766
+
767
+
768
+ class CookieTokenRefreshView(APIView):
769
+ """JWT refresh that reads the refresh token from the httpOnly cookie.
770
+
771
+ The refresh token is never accepted from the request body — it is read only
772
+ from the cookie set at login — so the SPA does not need JavaScript access to
773
+ it. The token is rotated (a new refresh token is minted and re-cookied) when
774
+ ``ROTATE_REFRESH_TOKENS`` is enabled, and the previous token is blacklisted
775
+ when the blacklist app is installed (``BLACKLIST_AFTER_ROTATION``). The new
776
+ access token is returned in the body.
777
+ """
778
+
779
+ # The default permission class is IsAuthenticated, but refresh must be
780
+ # callable by a client whose access token has already expired. Authentication
781
+ # here is the possession of a valid refresh cookie, validated below.
782
+ permission_classes: list[Any] = [] # noqa: RUF012
783
+ authentication_classes: list[Any] = [] # noqa: RUF012
784
+ throttle_classes = [ScopedRateThrottle] # noqa: RUF012
785
+ throttle_scope = "refresh"
786
+
787
+ @extend_schema(
788
+ request=None,
789
+ responses={
790
+ 200: _CookieRefreshResponseSerializer,
791
+ 401: OpenApiResponse(description="Missing or invalid refresh cookie."),
792
+ 403: OpenApiResponse(
793
+ description=(
794
+ "Refused: the request carries a cross-site Sec-Fetch-Site or "
795
+ "Origin signal (#3556). The refresh cookie is left untouched."
796
+ )
797
+ ),
798
+ },
799
+ summary="Refresh the access token using the httpOnly refresh cookie",
800
+ )
801
+ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response:
802
+ # Checked FIRST, before the cookie is even read (#3556): a refused
803
+ # request must leave the refresh cookie completely untouched, and the
804
+ # simplest way to guarantee that is to never reach the code that could
805
+ # set or clear it.
806
+ if _is_cross_site_auth_request(request):
807
+ logger.warning("auth.refresh_cross_site_rejected client_ip=%s", _client_ip(request))
808
+ return Response(
809
+ {"detail": "Cross-site request refused."}, status=status.HTTP_403_FORBIDDEN
810
+ )
811
+
812
+ raw_token = request.COOKIES.get(settings.AUTH_REFRESH_COOKIE_NAME)
813
+ if not raw_token:
814
+ return Response(
815
+ {"detail": "No refresh token cookie present."},
816
+ status=status.HTTP_401_UNAUTHORIZED,
817
+ )
818
+
819
+ try:
820
+ # RefreshToken accepts an encoded token string at runtime; the stub
821
+ # only types the Token object form.
822
+ refresh = RefreshToken(raw_token) # type: ignore[arg-type]
823
+ except TokenError as exc:
824
+ # False positive: simplejwt's TokenError carries a curated, client-safe
825
+ # reason ("Token is invalid or expired"), never a trace/path/internal.
826
+ return Response(
827
+ {"detail": str(exc)}, # codeql[py/stack-trace-exposure]
828
+ status=status.HTTP_401_UNAUTHORIZED,
829
+ )
830
+
831
+ # Defense in depth (#2999): blacklisting is the primary revocation channel,
832
+ # but it only reaches tokens we have a row for. A deactivated account must
833
+ # not be able to mint access tokens regardless — off-boarding sets
834
+ # ``is_active=False`` and every credential derived from the account has to
835
+ # stop working at that moment, not at the refresh token's TTL. The view is
836
+ # deliberately unauthenticated (the access token may already have expired),
837
+ # so this is the only place the account's live state is consulted.
838
+ if not _token_subject_is_active(refresh):
839
+ return Response(
840
+ {"detail": "Token is invalid or expired."},
841
+ status=status.HTTP_401_UNAUTHORIZED,
842
+ )
843
+
844
+ access_token = str(refresh.access_token)
845
+
846
+ rotate = settings.SIMPLE_JWT.get("ROTATE_REFRESH_TOKENS", False)
847
+ response = Response({"access": access_token}, status=status.HTTP_200_OK)
848
+
849
+ if rotate:
850
+ # Preserve the "remember me" choice across rotation (#2246): the incoming
851
+ # token's ``remember`` claim decides the rotated token's lifetime and
852
+ # cookie persistence. A missing claim marks a pre-#2246 token — keep the
853
+ # legacy 7-day persistent behavior so those live sessions are never
854
+ # disrupted (they adopt the new model at their next fresh login).
855
+ remember_claim = refresh.payload.get("remember")
856
+
857
+ # Blacklist the just-used refresh token before issuing a new one, so a
858
+ # leaked token cannot be replayed once the legitimate client rotates.
859
+ # The token_blacklist app is installed by default (#910); the
860
+ # AttributeError suppression is belt-and-braces for a lean deploy that
861
+ # removes it, in which case rotation degrades to TTL-only expiry.
862
+ if settings.SIMPLE_JWT.get("BLACKLIST_AFTER_ROTATION", False):
863
+ with contextlib.suppress(AttributeError):
864
+ refresh.blacklist()
865
+ refresh.set_jti()
866
+ if remember_claim is None:
867
+ legacy_lifetime = cast(timedelta, settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"])
868
+ refresh.set_exp(lifetime=legacy_lifetime)
869
+ persistent_seconds: int | None = int(legacy_lifetime.total_seconds())
870
+ else:
871
+ remember = bool(remember_claim)
872
+ _apply_remember(refresh, remember=remember)
873
+ persistent_seconds = _cookie_seconds(remember)
874
+ refresh.set_iat()
875
+ # Record the rotated jti so the new token is revocable (#2999). simplejwt
876
+ # writes an OutstandingToken only in ``for_user`` (login); without this the
877
+ # live token is the one row-less token, and every server-side revocation
878
+ # path silently misses it.
879
+ _record_outstanding(refresh)
880
+ _set_refresh_cookie(response, str(refresh), persistent_seconds=persistent_seconds)
881
+
882
+ return response
883
+
884
+
885
+ class CookieTokenLogoutView(APIView):
886
+ """Log out: clear the refresh cookie and best-effort blacklist the token.
887
+
888
+ Always returns 205 (reset content) — logout is idempotent. If the
889
+ ``token_blacklist`` app is installed, the presented refresh token is
890
+ blacklisted so it cannot be reused after the cookie is cleared; otherwise the
891
+ refresh token remains valid only until its (short) TTL expires.
892
+ """
893
+
894
+ permission_classes: list[Any] = [] # noqa: RUF012
895
+ authentication_classes: list[Any] = [] # noqa: RUF012
896
+
897
+ @extend_schema(
898
+ request=None,
899
+ responses={
900
+ 205: OpenApiResponse(description="Logged out; refresh cookie cleared."),
901
+ 403: OpenApiResponse(
902
+ description=(
903
+ "Refused: the request carries a cross-site Sec-Fetch-Site or "
904
+ "Origin signal (#3556). The refresh cookie is left untouched."
905
+ )
906
+ ),
907
+ },
908
+ summary="Log out — clear the refresh cookie and revoke the token",
909
+ )
910
+ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response:
911
+ # Same defense-in-depth check as CookieTokenRefreshView, checked first
912
+ # for the same reason: a refused request must leave the refresh cookie
913
+ # untouched, which is only guaranteed by never reaching the blacklist /
914
+ # delete_cookie calls below (#3556).
915
+ if _is_cross_site_auth_request(request):
916
+ logger.warning("auth.logout_cross_site_rejected client_ip=%s", _client_ip(request))
917
+ return Response(
918
+ {"detail": "Cross-site request refused."}, status=status.HTTP_403_FORBIDDEN
919
+ )
920
+
921
+ raw_token = request.COOKIES.get(settings.AUTH_REFRESH_COOKIE_NAME)
922
+ response = Response(status=status.HTTP_205_RESET_CONTENT)
923
+
924
+ if raw_token:
925
+ try:
926
+ token = RefreshToken(raw_token) # type: ignore[arg-type]
927
+ token.blacklist()
928
+ except (TokenError, AttributeError):
929
+ # TokenError: already-expired/invalid token — nothing to revoke.
930
+ # AttributeError: token_blacklist app not installed.
931
+ pass
932
+
933
+ _clear_refresh_cookie(response)
934
+ return response