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,2605 @@
1
+ """Base Django settings shared across all environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from datetime import timedelta
8
+ from pathlib import Path
9
+
10
+ import environ
11
+ from django.core.exceptions import ImproperlyConfigured
12
+
13
+ from trueppm_api.core.demo_read_only import (
14
+ parse_demo_access_gate,
15
+ parse_demo_login_hint,
16
+ parse_demo_read_only,
17
+ parse_demo_reset_schedule,
18
+ )
19
+ from trueppm_api.core.ratelimit import apply_rate_limit_disable, resolve_rate_limit_enabled
20
+ from trueppm_api.core.refresh_cookie_policy import resolve_refresh_cookie_samesite
21
+ from trueppm_api.core.storage_config import S3_STORAGE_BACKENDS, build_s3_storage_options
22
+ from trueppm_api.core.valkey_config import parse_sentinels
23
+
24
+ BASE_DIR = Path(__file__).resolve().parent.parent.parent
25
+
26
+ env = environ.Env()
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Core
30
+ # ---------------------------------------------------------------------------
31
+
32
+ SECRET_KEY = env("SECRET_KEY", default="django-insecure-change-me-in-prod")
33
+
34
+ # Dedicated JWT signing key (#2247). Defaults to SECRET_KEY so a default install
35
+ # stays single-knob, but can be set independently via JWT_SIGNING_KEY to:
36
+ # (a) limit the blast radius of a SECRET_KEY leak — a leaked SECRET_KEY alone
37
+ # can no longer forge access/refresh tokens for any user; and
38
+ # (b) give operators a deliberate "log everyone out now" lever: rotating
39
+ # JWT_SIGNING_KEY invalidates every outstanding token without also
40
+ # rotating the general Django secret (which would churn CSRF/session
41
+ # signing too).
42
+ # When set explicitly it is strength-validated in prod exactly like SECRET_KEY
43
+ # (see core.security_checks.validate_signing_key); when it defaults to SECRET_KEY
44
+ # the SECRET_KEY validation already covers it. Rotation runbook:
45
+ # packages/website/src/content/docs/administration/secret-rotation.md.
46
+ JWT_SIGNING_KEY = env("JWT_SIGNING_KEY", default=SECRET_KEY)
47
+
48
+ DEBUG = False
49
+
50
+ ALLOWED_HOSTS: list[str] = []
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Applications
54
+ # ---------------------------------------------------------------------------
55
+
56
+ DJANGO_APPS = [
57
+ # django.contrib.admin, but with HardenedAdminSite as the default site
58
+ # (#3557) -- off unless TRUEPPM_DJANGO_ADMIN_ENABLED, and when on, its login
59
+ # carries the API login's throttles, audit lines and policy seam. Django's
60
+ # documented hook for swapping the default site is an AdminConfig subclass in
61
+ # INSTALLED_APPS; the app itself (label "admin", its migrations) is unchanged.
62
+ "trueppm_api.core.admin_site.HardenedAdminConfig",
63
+ "django.contrib.auth",
64
+ "django.contrib.contenttypes",
65
+ "django.contrib.sessions",
66
+ "django.contrib.messages",
67
+ "django.contrib.staticfiles",
68
+ "django.contrib.postgres",
69
+ # SITE_ID = 1 (below) is already set; allauth.socialaccount's SocialApp is an
70
+ # M2M to Site, so the sites framework must be installed (ADR-0517 §1).
71
+ "django.contrib.sites",
72
+ ]
73
+
74
+ THIRD_PARTY_APPS = [
75
+ "rest_framework",
76
+ "rest_framework_simplejwt",
77
+ # Refresh-token revocation (#910): provides OutstandingToken + BlacklistedToken
78
+ # so refresh-token rotation (BLACKLIST_AFTER_ROTATION) and logout actually
79
+ # revoke the old token instead of letting it live out its full TTL. The
80
+ # auth_views blacklist() calls are no-ops without this app installed.
81
+ "rest_framework_simplejwt.token_blacklist",
82
+ "allauth",
83
+ "allauth.account",
84
+ # ADR-0517: adopt allauth.socialaccount as the provider *registry* only — our
85
+ # own hardened views in apps/sso drive the flow (egress, alg allow-list, Fernet
86
+ # secret, JWT bridge). We do NOT mount allauth.urls. openid_connect covers every
87
+ # OIDC IdP (as named APPS keyed by our registry slug); github is the one non-OIDC
88
+ # OAuth2 IdP that motivated the re-platform (#2108).
89
+ "allauth.socialaccount",
90
+ "allauth.socialaccount.providers.openid_connect",
91
+ "allauth.socialaccount.providers.github",
92
+ "channels",
93
+ "drf_spectacular",
94
+ # Serves the Swagger UI / ReDoc static bundles from Django static ('self')
95
+ # so the /api/docs/ and /api/schema/swagger-ui/ pages render under the strict
96
+ # CSP without a CDN. Must precede any app that would shadow its static files.
97
+ "drf_spectacular_sidecar",
98
+ "simple_history",
99
+ ]
100
+
101
+ LOCAL_APPS = [
102
+ "trueppm_api.apps.access",
103
+ "trueppm_api.apps.projects",
104
+ "trueppm_api.apps.resources",
105
+ "trueppm_api.apps.scheduling",
106
+ "trueppm_api.apps.sync",
107
+ "trueppm_api.apps.history",
108
+ "trueppm_api.apps.msproject",
109
+ "trueppm_api.apps.jiraimport",
110
+ "trueppm_api.apps.csvimport",
111
+ "trueppm_api.apps.webhooks",
112
+ "trueppm_api.apps.taskruns",
113
+ "trueppm_api.apps.notifications",
114
+ "trueppm_api.apps.integrations",
115
+ "trueppm_api.apps.observability",
116
+ "trueppm_api.apps.workflow_engine",
117
+ "trueppm_api.apps.idempotency",
118
+ "trueppm_api.apps.workspace",
119
+ "trueppm_api.apps.teams",
120
+ "trueppm_api.apps.profiles",
121
+ "trueppm_api.apps.timetracking",
122
+ "trueppm_api.apps.sso",
123
+ "trueppm_api.apps.agents",
124
+ ]
125
+
126
+ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Middleware
130
+ # ---------------------------------------------------------------------------
131
+
132
+ MIDDLEWARE = [
133
+ "django.middleware.security.SecurityMiddleware",
134
+ # Serve collected static files from the ASGI app itself (#1603). We run under
135
+ # `uvicorn ...asgi:application` in dev and prod — not `manage.py runserver` —
136
+ # so Django's dev static handler never engages and there is no nginx/CDN in the
137
+ # request path. Without this, every /static/ request 404s: Django admin CSS and
138
+ # the Swagger UI / ReDoc bundles (served same-origin under our strict CSP) both
139
+ # fail. WhiteNoise must sit immediately after SecurityMiddleware (its documented
140
+ # position) so it can short-circuit static requests before the rest of the stack.
141
+ "whitenoise.middleware.WhiteNoiseMiddleware",
142
+ # Assign each request a correlation id and bind it to the logging contextvar
143
+ # (#1899) so every log record emitted while handling the request carries it,
144
+ # and echo it back on the response. Placed high — but after WhiteNoise, which
145
+ # short-circuits static requests that need no id — so the id is set before any
146
+ # downstream middleware or view can log.
147
+ "trueppm_api.apps.observability.logging.RequestIDMiddleware",
148
+ # CSP header on every response (#897). Placed high so the header is attached
149
+ # even for early short-circuit responses (redirects, errors).
150
+ "trueppm_api.core.csp.ContentSecurityPolicyMiddleware",
151
+ # Reject NUL bytes in /api/ query strings before the view opens its
152
+ # ATOMIC_REQUESTS transaction (#2229) — a NUL in a text DB filter otherwise
153
+ # raises an uncaught psycopg DataError (500). Placed inside RequestID/CSP so
154
+ # the 400 still carries the correlation id and CSP header, but before Session
155
+ # so a garbage request skips session/auth work.
156
+ "trueppm_api.core.middleware.RejectNullBytesMiddleware",
157
+ # Demo read-only mode (interactive-demo design #3912, #3924): refuse every unsafe /api/ request
158
+ # unless it is one of the three auth writes. Sits BEFORE Session/Auth so anonymous
159
+ # POSTs (password-reset request, SSO, inbound integrations) are refused too, and
160
+ # before the view's ATOMIC_REQUESTS transaction so a refusal costs no DB work. It
161
+ # must not move below AuthenticationMiddleware: a role-aware position is exactly
162
+ # the "authority derived from the account" design this mode exists to avoid. A
163
+ # no-op unless DEMO_READ_ONLY is set.
164
+ "trueppm_api.core.demo_read_only.DemoReadOnlyMiddleware",
165
+ "django.contrib.sessions.middleware.SessionMiddleware",
166
+ "django.middleware.common.CommonMiddleware",
167
+ "django.middleware.csrf.CsrfViewMiddleware",
168
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
169
+ "allauth.account.middleware.AccountMiddleware",
170
+ "simple_history.middleware.HistoryRequestMiddleware",
171
+ "django.contrib.messages.middleware.MessageMiddleware",
172
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
173
+ # Write queued agent-action audit rows after the view's ATOMIC_REQUESTS
174
+ # transaction has closed (#3017, ADR-0902). A refusal audit written inside that
175
+ # transaction is discarded by the set_rollback() DRF performs for every
176
+ # APIException, which is why the log contained only successes. Placed LAST so its
177
+ # response half runs FIRST on the way out — as close to the view as a middleware
178
+ # can be, while still being outside the transaction.
179
+ "trueppm_api.apps.agents.middleware.AgentActionAuditMiddleware",
180
+ ]
181
+
182
+ ROOT_URLCONF = "trueppm_api.urls"
183
+
184
+ TEMPLATES = [
185
+ {
186
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
187
+ "DIRS": [],
188
+ "APP_DIRS": True,
189
+ "OPTIONS": {
190
+ "context_processors": [
191
+ "django.template.context_processors.debug",
192
+ "django.template.context_processors.request",
193
+ "django.contrib.auth.context_processors.auth",
194
+ "django.contrib.messages.context_processors.messages",
195
+ ],
196
+ },
197
+ },
198
+ ]
199
+
200
+ WSGI_APPLICATION = "trueppm_api.wsgi.application"
201
+ ASGI_APPLICATION = "trueppm_api.asgi.application"
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Database
205
+ # ---------------------------------------------------------------------------
206
+
207
+ DATABASES = {
208
+ "default": env.db("DATABASE_URL", default="postgres://trueppm:trueppm@db:5432/trueppm")
209
+ }
210
+ DATABASES["default"]["CONN_MAX_AGE"] = 60
211
+ # Wrap every request in a transaction so transaction.on_commit() defers
212
+ # callbacks to after commit rather than firing immediately in autocommit mode.
213
+ DATABASES["default"]["ATOMIC_REQUESTS"] = True
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # File / object storage (#775)
217
+ # ---------------------------------------------------------------------------
218
+
219
+ # Default file-storage backend. TaskAttachment.file is stored here. The
220
+ # FileSystemStorage default is fine for local dev, but a containerized prod
221
+ # deploy must point this at a remote object-storage backend (S3/MinIO via
222
+ # django-storages) or attachment uploads are lost on every pod restart. prod.py
223
+ # refuses to boot on the local default unless the operator explicitly opts in.
224
+ STORAGES = {
225
+ "default": {
226
+ "BACKEND": env(
227
+ "TRUEPPM_DEFAULT_FILE_STORAGE",
228
+ default="django.core.files.storage.FileSystemStorage",
229
+ ),
230
+ },
231
+ "staticfiles": {
232
+ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
233
+ },
234
+ }
235
+
236
+ # S3 / MinIO options, attached ONLY for an S3-family backend — a FileSystemStorage
237
+ # deploy must not be handed S3 kwargs it would reject. See core.storage_config for
238
+ # why this plumbing is required rather than cosmetic (#2559): the backend the docs
239
+ # recommend reads its bucket from Django settings, not the environment, so setting
240
+ # TRUEPPM_DEFAULT_FILE_STORAGE alone booted and then failed on the first upload.
241
+ if STORAGES["default"]["BACKEND"] in S3_STORAGE_BACKENDS:
242
+ STORAGES["default"]["OPTIONS"] = build_s3_storage_options(os.environ)
243
+
244
+ # Where FileSystemStorage writes task attachments. Django's own default is "",
245
+ # which resolves upload paths against the process CWD — /app in the container
246
+ # image, whose root filesystem is read-only on both supported production paths
247
+ # (Helm readOnlyRootFilesystem, compose read_only). Every upload therefore failed
248
+ # at write time with EROFS while the boot guard reported the deployment healthy
249
+ # (#3184). TRUEPPM_MEDIA_ROOT was already read by scripts/backup.sh and
250
+ # scripts/restore.sh but by nothing in Django, so the variable the backup tooling
251
+ # documents now actually decides where the files it archives live.
252
+ #
253
+ # The default is the path the chart's persistence.media PVC and the compose
254
+ # `media` volume both mount. It is deliberately outside BASE_DIR: BASE_DIR
255
+ # resolves inside the read-only venv when the app is pip-installed, exactly as
256
+ # STATIC_ROOT's comment below notes. settings/dev.py overrides it to a
257
+ # repo-local directory so a workstation and pytest never need /var/lib.
258
+ MEDIA_ROOT = Path(env("TRUEPPM_MEDIA_ROOT", default="/var/lib/trueppm/media"))
259
+
260
+ # Operator opt-in to run prod on local-disk attachment storage (e.g. when it is
261
+ # backed by a persistent volume). Consumed by validate_attachment_storage.
262
+ ALLOW_LOCAL_ATTACHMENT_STORAGE = env.bool("TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE", default=False)
263
+
264
+ # Operator opt-in confirming the configured STORAGES['default']['BACKEND'] signs
265
+ # its .url() output with a real time-limited URL, for a backend not on
266
+ # security_checks.storage_backend_supports_signed_urls's allow-list (#573,
267
+ # MED-2). Default False: the TaskAttachmentViewSet.signed_url action refuses
268
+ # with 501 rather than hand out a stable indefinite-lifetime URL labeled as
269
+ # time-limited for any backend it can't positively identify as signing-capable.
270
+ ATTACHMENT_STORAGE_SIGNS_URLS = env.bool("TRUEPPM_ATTACHMENT_STORAGE_SIGNS_URLS", default=False)
271
+
272
+ # Operator opt-in to run prod against a DATABASE_URL without sslmode=require (e.g.
273
+ # when TLS to the database is enforced at the network layer). Consumed by the
274
+ # unencrypted-DB boot guard in settings/prod.py.
275
+ ALLOW_UNENCRYPTED_DB = env.bool("TRUEPPM_ALLOW_UNENCRYPTED_DB", default=False)
276
+
277
+ # Operator acknowledgment that ALLOWED_HOSTS is a bare "*" in prod (#3515).
278
+ # Consumed by the wildcard-host boot guard in settings/prod.py, which otherwise
279
+ # refuses to start: "*" is the one value that disables host validation outright,
280
+ # and host validation is the only bound on the absolute URLs the API builds.
281
+ ALLOW_WILDCARD_ALLOWED_HOSTS = env.bool("TRUEPPM_ALLOW_WILDCARD_HOSTS", default=False)
282
+
283
+ # Whether /admin/ answers at all (#3557). Default OFF everywhere except the dev
284
+ # settings module, which flips it back on for a developer workstation.
285
+ #
286
+ # Django admin is a plain Django view, so none of the API's login defenses reach
287
+ # it by inheritance: DRF throttles apply to DRF views only, the auth audit lines
288
+ # are emitted by our own login view, and the enterprise local_login_allowed seam
289
+ # is consulted there too. Left unhardened it is a second password door onto a
290
+ # known-present superuser (the create_admin bootstrap makes one on first deploy)
291
+ # with none of the first door's controls. The chart's nginx denies /admin/ by
292
+ # default, but Docker Compose and bare-metal deploys route to the API directly
293
+ # and have no such edge.
294
+ #
295
+ # Off-by-default is the primary control, because the strongest statement about a
296
+ # surface almost nobody uses is that it is not there. HardenedAdminSite raises
297
+ # Http404 for every /admin/ path when this is False -- a 404, not a 403, so the
298
+ # surface is not even advertised. An operator who genuinely needs the admin sets
299
+ # this to true and gets the hardened door rather than the bare one: the same
300
+ # "login"/"login_account" throttle buckets as the API login, the same
301
+ # auth.login_failed / auth.login_succeeded lines, and the same policy seam.
302
+ DJANGO_ADMIN_ENABLED = env.bool("TRUEPPM_DJANGO_ADMIN_ENABLED", default=False)
303
+
304
+ # Interactive-demo read-only mode (#3912, #3924). When true,
305
+ # DemoReadOnlyMiddleware refuses every unsafe HTTP method under /api/ except login,
306
+ # token refresh and logout, regardless of role or permission class. Off by default; a
307
+ # normal install is unaffected. Parsed strictly rather than with env.bool(): env.bool
308
+ # reads any unrecognized string (a typo like "ture") as False, which would silently
309
+ # disable the one control the demo's safety rests on. An unrecognized value here
310
+ # refuses to boot instead.
311
+ DEMO_READ_ONLY: bool = parse_demo_read_only(os.environ.get("TRUEPPM_DEMO_READ_ONLY"))
312
+
313
+ # External access gate declared in front of a demo host (#3969, ADR-1197 D8 resolution
314
+ # of 2026-09-21). A gate such as Cloudflare Access collects a visitor's email address
315
+ # before any TruePPM code runs; this is how the deployment says so, and it is surfaced
316
+ # only while DEMO_READ_ONLY is on (see the edition view). IT IS AN OPERATOR ASSERTION
317
+ # THAT NOTHING VERIFIES -- the gate is configured in a third-party dashboard outside
318
+ # the cluster, so neither setting it nor clearing it proves anything about what is
319
+ # actually in front of this pod. Unset on every normal install, which is the point:
320
+ # the disclosure is conditional, never a blanket claim.
321
+ DEMO_ACCESS_GATE: dict[str, str | None] | None = parse_demo_access_gate(
322
+ os.environ.get("TRUEPPM_DEMO_ACCESS_GATE_PROVIDER"),
323
+ os.environ.get("TRUEPPM_DEMO_ACCESS_GATE_PRIVACY_URL"),
324
+ )
325
+
326
+ # Global rate-limiting kill switch (ADR-0604, extends ADR-0208). Operator-only
327
+ # escape hatch to disable ALL DRF throttling — used by the k6 perf:load job to
328
+ # measure raw throughput, and by an operator load-testing / debugging a real
329
+ # deployment. Disabling requires BOTH TRUEPPM_RATE_LIMIT_ENABLED=false AND the
330
+ # acknowledgment sentinel, in every environment; an unacknowledged disable is
331
+ # refused (limits stay ON) and logged at CRITICAL, so a stray env var can never
332
+ # silently open a DoS path. See trueppm_api.core.ratelimit for the full rationale.
333
+ # The effective throttle neutralization runs after REST_FRAMEWORK is defined
334
+ # (search "apply_rate_limit_disable" below). Surfaced read-only to admins via
335
+ # /health/system/ and a banner; never a UI/API toggle (ADR-0497).
336
+ RATE_LIMIT_ENABLED, _rate_limit_switch_message = resolve_rate_limit_enabled(
337
+ requested_enabled=env.bool("TRUEPPM_RATE_LIMIT_ENABLED", default=True),
338
+ ack=env("TRUEPPM_RATE_LIMIT_DISABLE_ACK", default=""),
339
+ )
340
+ _settings_log = logging.getLogger("trueppm.settings")
341
+ if _rate_limit_switch_message:
342
+ _settings_log.critical(_rate_limit_switch_message)
343
+
344
+ # The shared credential the interactive demo's login page prints (#3926, ADR-1197 D3).
345
+ # "username:password"; unset on every normal install. Read here rather than derived from
346
+ # any real password variable so nothing can promote a live secret to published copy by
347
+ # accident. /api/v1/edition/ emits it ONLY while DEMO_READ_ONLY is true.
348
+ #
349
+ # Parsed strictly only where it MATTERS. Refusing to boot on a malformed value is right
350
+ # for a demo — the one credential it advertises being a typo makes the demo unusable —
351
+ # and wrong for a production install, where a leftover variable is inert and crashing
352
+ # the process over it is an outage caused by something that does nothing. What the
353
+ # `else` branch must not be is silent: hint-set-while-mode-off is exactly the state
354
+ # "a real account's password is sitting in a writable install's environment", and
355
+ # nothing else anywhere would say so.
356
+ _demo_login_hint_raw = os.environ.get("TRUEPPM_DEMO_LOGIN_HINT")
357
+ DEMO_LOGIN_HINT: dict[str, str] | None = (
358
+ parse_demo_login_hint(_demo_login_hint_raw) if DEMO_READ_ONLY else None
359
+ )
360
+ if not DEMO_READ_ONLY and _demo_login_hint_raw and _demo_login_hint_raw.strip():
361
+ _settings_log.warning(
362
+ "TRUEPPM_DEMO_LOGIN_HINT is set but TRUEPPM_DEMO_READ_ONLY is not true. The "
363
+ "value is IGNORED and never served, but a published credential is sitting in "
364
+ "this install's environment. Unset it unless this really is a demo."
365
+ )
366
+
367
+ # The reset CronJob's own cadence, restated so the demo bar and login page can say
368
+ # "resets daily at HH:MM" from the value that actually drives the CronJob rather than
369
+ # a hardcoded number (#4152, ADR-1197 D9). The chart renders this empty whenever
370
+ # demo.reset is disabled, so `None` here already means "no schedule to state" without
371
+ # a second demo-mode gate — but /api/v1/edition/ still gates emission on DEMO_READ_ONLY,
372
+ # like the hint and the access gate, so the field says nothing on a normal install even
373
+ # if the variable were somehow left set.
374
+ DEMO_RESET_SCHEDULE: str | None = parse_demo_reset_schedule(
375
+ os.environ.get("TRUEPPM_DEMO_RESET_SCHEDULE")
376
+ )
377
+
378
+ # Origins trusted for cross-origin POST / CSRF — required when the web app is
379
+ # served from a different origin than the API (split dev setup or subdomain
380
+ # split). Empty by default (same-origin reverse-proxy deploy).
381
+ CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
382
+
383
+ # Public origin the web app is served from, e.g. "https://app.example.com". Used
384
+ # to build absolute deep-links into emails/notifications (ADR-0165, #1158) — the
385
+ # API otherwise has no notion of where the frontend lives. Empty by default
386
+ # (zero-config): when unset, email bodies render without a link rather than emit a
387
+ # broken relative URL. Trailing slash is stripped at use; do not include a path.
388
+ # Env var standardized on the TRUEPPM_ prefix pre-0.3 (#1325, #1355); the legacy
389
+ # bare name remains a fallback so existing deploys keep working. ``or`` (not a
390
+ # nested default) so an *empty* TRUEPPM_ value — e.g. the chart's documented
391
+ # ``TRUEPPM_FRONTEND_BASE_URL: ""`` default — falls through to a legacy override
392
+ # rather than shadowing it. Empty means "no deep-links", so the fallthrough is
393
+ # also semantically correct.
394
+ FRONTEND_BASE_URL = env("TRUEPPM_FRONTEND_BASE_URL", default="") or env(
395
+ "FRONTEND_BASE_URL", default=""
396
+ )
397
+
398
+ # Public origin of the API itself, used only to derive the OIDC ``redirect_uri``
399
+ # (ADR-0187): ``{TRUEPPM_PUBLIC_API_BASE_URL}/api/v1/auth/oidc/callback/``. The
400
+ # operator copies this exact value into their IdP's allowed-redirect list. Empty
401
+ # by default (zero-config): the SSO views fall back to the request's absolute URI,
402
+ # correct for a single-origin dev setup; set it explicitly behind a reverse proxy
403
+ # so the allow-listed value is deterministic. Trailing slash is stripped at use.
404
+ TRUEPPM_PUBLIC_API_BASE_URL = env("TRUEPPM_PUBLIC_API_BASE_URL", default="")
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Cache / Channels / Celery (all backed by Redis)
408
+ # ---------------------------------------------------------------------------
409
+
410
+ REDIS_URL = env("REDIS_URL", default="redis://redis:6379")
411
+
412
+ # --- Valkey topology (ADR-0716, #2554) ------------------------------------
413
+ # Sentinel mode is EXPERIMENTAL as of 0.4: the wiring below is unit-tested, but no
414
+ # live-quorum failover has been exercised (the bundled dev stack is single-node).
415
+ # Setting TRUEPPM_VALKEY_SENTINELS switches every consumer to Sentinel mode; left
416
+ # empty (the default) everything below derives from REDIS_URL exactly as before,
417
+ # so an existing deploy needs no config change. Sentinel is a *block* rather than
418
+ # per-role URL overrides because the blocking constraint is that neither
419
+ # channels-redis nor the Django cache can express Sentinel as a URL at all — see
420
+ # ADR-0716 for the rejected alternatives. Cluster mode is unsupported: a clustered
421
+ # endpoint exposes only db 0 and we use four (0 Celery, 1 Channels, 2 cache, 3
422
+ # notification throttles).
423
+ VALKEY_SENTINELS_RAW = env("TRUEPPM_VALKEY_SENTINELS", default="")
424
+ VALKEY_MASTER_NAME = env("TRUEPPM_VALKEY_MASTER_NAME", default="")
425
+ VALKEY_PASSWORD = env("TRUEPPM_VALKEY_PASSWORD", default="")
426
+ # Sentinel nodes commonly carry a different password from the data nodes (often
427
+ # none at all), so this is deliberately separate rather than reusing the above.
428
+ VALKEY_SENTINEL_PASSWORD = env("TRUEPPM_VALKEY_SENTINEL_PASSWORD", default="")
429
+ VALKEY_USE_TLS = env.bool("TRUEPPM_VALKEY_USE_TLS", default=False)
430
+
431
+ try:
432
+ VALKEY_SENTINELS: list[tuple[str, int]] = parse_sentinels(VALKEY_SENTINELS_RAW)
433
+ except ValueError:
434
+ # A malformed entry must not raise out of settings import — that surfaces as an
435
+ # unreadable traceback before Django can report anything. Fall back to
436
+ # single-endpoint mode and let core.valkey_checks emit trueppm.valkey.E002,
437
+ # which names the bad entry and refuses the boot cleanly.
438
+ VALKEY_SENTINELS = []
439
+
440
+ VALKEY_SENTINEL_MODE = bool(VALKEY_SENTINELS)
441
+
442
+ # Shared cache backend (Valkey/Redis db 2 — db 1 is the channel layer, db 0 is
443
+ # Celery). Backs the short-lived, single-use OIDC login state / PKCE verifier /
444
+ # nonce (ADR-0187 §Durable Execution) and the DRF scoped throttles, both of which
445
+ # must be consistent across worker processes in a multi-worker deploy. ``dev.py``
446
+ # overrides this to LocMemCache so a local run / pytest needs no separate cache
447
+ # service (single process, so per-process memory is sufficient there).
448
+ # In Sentinel mode the backend swaps to our own RedisCache subclass (ADR-0716) —
449
+ # Django's built-in has no Sentinel support, and subclassing it is ~30 lines
450
+ # against adding django-redis, which ADR-0065/0068 declined.
451
+ CACHES = {
452
+ "default": (
453
+ {
454
+ "BACKEND": "trueppm_api.core.valkey_cache.SentinelRedisCache",
455
+ # LOCATION is ignored by that backend (topology comes from the
456
+ # TRUEPPM_VALKEY_* settings) but Django requires the key present.
457
+ "LOCATION": "",
458
+ }
459
+ if VALKEY_SENTINEL_MODE
460
+ else {
461
+ "BACKEND": "django.core.cache.backends.redis.RedisCache",
462
+ "LOCATION": f"{REDIS_URL}/2",
463
+ }
464
+ ),
465
+ }
466
+
467
+ # channels-redis accepts Sentinel ONLY as a dict host — it can never be carried in
468
+ # a URL, which is the single fact that made Sentinel unreachable before ADR-0716.
469
+ _CHANNEL_HOST: dict[str, object] | str = (
470
+ {
471
+ "sentinels": VALKEY_SENTINELS,
472
+ "master_name": VALKEY_MASTER_NAME,
473
+ "db": 1,
474
+ **({"password": VALKEY_PASSWORD} if VALKEY_PASSWORD else {}),
475
+ **(
476
+ {"sentinel_kwargs": {"password": VALKEY_SENTINEL_PASSWORD}}
477
+ if VALKEY_SENTINEL_PASSWORD
478
+ else {}
479
+ ),
480
+ }
481
+ if VALKEY_SENTINEL_MODE
482
+ else f"{REDIS_URL}/1"
483
+ )
484
+
485
+ CHANNEL_LAYERS = {
486
+ "default": {
487
+ "BACKEND": "channels_redis.core.RedisChannelLayer",
488
+ "CONFIG": {
489
+ "hosts": [_CHANNEL_HOST],
490
+ "capacity": 1500,
491
+ "expiry": 10,
492
+ },
493
+ },
494
+ }
495
+
496
+ # Legacy WebSocket ?token=<jwt> handshake (ADR-0141, #1723). The single-use
497
+ # ?ticket= scheme replaced it: a raw JWT in the URL leaks verbatim into
498
+ # reverse-proxy / ingress / Daphne access logs (the credential-in-URL class of
499
+ # #818). The fallback shipped deprecated-but-live for one release; it is now
500
+ # OFF by default and must be explicitly opted into by an operator who still has
501
+ # clients on the old path. It is removed entirely next release.
502
+ TRUEPPM_WS_LEGACY_TOKEN_AUTH_ENABLED: bool = env.bool(
503
+ "TRUEPPM_WS_LEGACY_TOKEN_AUTH_ENABLED", default=False
504
+ )
505
+
506
+ # Celery accepts a sentinel:// URL, but ONLY together with a master_name in the
507
+ # transport options — the URL alone silently connects to whichever sentinel
508
+ # answers rather than to the primary. Broker and result backend take separate
509
+ # option dicts; setting only broker_transport_options is the common half-fix and
510
+ # leaves the result backend pointed at a sentinel port (ADR-0716).
511
+ if VALKEY_SENTINEL_MODE:
512
+ _sentinel_authority = ";".join(
513
+ f"sentinel://{f':{VALKEY_PASSWORD}@' if VALKEY_PASSWORD else ''}{host}:{port}"
514
+ for host, port in VALKEY_SENTINELS
515
+ )
516
+ CELERY_BROKER_URL = _sentinel_authority
517
+ CELERY_RESULT_BACKEND = _sentinel_authority
518
+ _celery_transport_options: dict[str, object] = {
519
+ "master_name": VALKEY_MASTER_NAME,
520
+ "db": 0,
521
+ }
522
+ if VALKEY_SENTINEL_PASSWORD:
523
+ _celery_transport_options["sentinel_kwargs"] = {"password": VALKEY_SENTINEL_PASSWORD}
524
+ CELERY_BROKER_TRANSPORT_OPTIONS = _celery_transport_options
525
+ CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = _celery_transport_options
526
+ else:
527
+ CELERY_BROKER_URL = f"{REDIS_URL}/0"
528
+ CELERY_RESULT_BACKEND = f"{REDIS_URL}/0"
529
+ # Bound the redelivery window on the non-sentinel path (#3188). Tasks carry
530
+ # acks_late=True + reject_on_worker_lost=True in 60+ places, so a worker
531
+ # killed mid-task does not LOSE the work — the broker redelivers it. But
532
+ # Kombu's Redis transport hides an unacked message for `visibility_timeout`
533
+ # seconds first, and its default is 3600. A worker SIGKILLed during a
534
+ # rolling upgrade therefore left that task invisible for up to an hour, with
535
+ # nothing in the queue depth or the task list to say why. This block set
536
+ # transport options only on the sentinel path, so the default applied to
537
+ # every ordinary deploy.
538
+ #
539
+ # 900s is chosen against the longest task we actually run rather than a
540
+ # round number: MS Project import and workspace export are the multi-minute
541
+ # ones. It MUST exceed the longest task's runtime — a visibility timeout
542
+ # shorter than the work redelivers a task that is still running, which turns
543
+ # one slow import into two concurrent ones. Raise it if you raise
544
+ # terminationGracePeriodSeconds on the worker past this.
545
+ CELERY_BROKER_TRANSPORT_OPTIONS = {"visibility_timeout": 900}
546
+
547
+ CELERY_ACCEPT_CONTENT = ["json"]
548
+ CELERY_TASK_SERIALIZER = "json"
549
+ CELERY_RESULT_SERIALIZER = "json"
550
+ CELERY_TIMEZONE = "UTC"
551
+ from celery.schedules import crontab # noqa: E402 — must follow REDIS_URL
552
+
553
+ CELERY_BEAT_SCHEDULE = {
554
+ # Retention purge coordinator (ADR-0173): one self-gating task that purges the
555
+ # six operational tables (event history, task runs, webhook deliveries, import
556
+ # requests, sync batches, soft-deleted projects) as a single unified run,
557
+ # recorded in PurgeRun. Fires every 30 min and no-ops outside the
558
+ # operator-configured window (Settings → System health → Retention & purge).
559
+ # Replaces the former per-table nightly purge entries — the per-table tasks
560
+ # remain dispatchable but are no longer independently scheduled.
561
+ "retention-purge-coordinator": {
562
+ "task": "retention.run_purge",
563
+ "schedule": crontab(minute="*/30"),
564
+ },
565
+ # Outbox drain: dispatches pending ScheduleRequest rows every 30 seconds.
566
+ # Also recovers orphaned dispatched rows (worker died before completing).
567
+ "drain-schedule-queue": {
568
+ "task": "scheduling.drain_schedule_queue",
569
+ "schedule": 30.0,
570
+ },
571
+ # Nightly cleanup: deletes done/dead ScheduleRequest rows older than 7 days.
572
+ "schedule-requests-purge-nightly": {
573
+ "task": "scheduling.purge_old_schedule_requests",
574
+ # 02:15 UTC — between the two existing purge jobs.
575
+ "schedule": crontab(hour=2, minute=15),
576
+ },
577
+ # Config-change notice outbox drain (#3009, ADR-1174): recovers rows whose
578
+ # worker was lost and dispatches notices whose on-commit dispatch stranded.
579
+ "drain-config-notice-requests": {
580
+ "task": "projects.drain_config_notice_requests",
581
+ "schedule": 30.0,
582
+ },
583
+ # Nightly cleanup: deletes done/dead ConfigNoticeRequest rows older than 7 days.
584
+ "config-notice-requests-purge-nightly": {
585
+ "task": "projects.purge_old_config_notice_requests",
586
+ "schedule": crontab(hour=2, minute=55),
587
+ },
588
+ # Nightly cleanup: deletes acknowledged / auto-resolved CrossProjectSlipConflict
589
+ # rows 90 days past their resolution (ADR-0120 D4). Unresolved + unacknowledged
590
+ # conflicts are kept indefinitely — they are still live.
591
+ "purge-resolved-slip-conflicts-nightly": {
592
+ "task": "scheduling.purge_resolved_slip_conflicts",
593
+ # 02:25 UTC — after the Monte Carlo purge.
594
+ "schedule": crontab(hour=2, minute=25),
595
+ },
596
+ # Nightly cleanup: reaps WebSocket replay-buffer rows (BoardEvent) older than
597
+ # TRUEPPM_BOARD_EVENT_RETENTION_HOURS so the buffer stays bounded (ADR-0236,
598
+ # #321). Standalone rather than in the ADR-0173 operator coordinator: the
599
+ # buffer is internal transport plumbing, deliberately not surfaced as an
600
+ # operator-tunable retention row.
601
+ "purge-board-events-nightly": {
602
+ "task": "sync.purge_board_events",
603
+ # 02:35 UTC — after the slip-conflict purge.
604
+ "schedule": crontab(hour=2, minute=35),
605
+ },
606
+ # Nightly cleanup: trims project Monte Carlo run history to the newest
607
+ # MC_HISTORY_CAP rows per project (ADR-0175, #961). No-ops when the cap is
608
+ # None (Enterprise unlimited).
609
+ "purge-monte-carlo-runs-nightly": {
610
+ "task": "scheduling.purge_old_monte_carlo_runs",
611
+ # 02:20 UTC — right after the schedule-requests purge.
612
+ "schedule": crontab(hour=2, minute=20),
613
+ },
614
+ # Daily forecast-snapshot floor: guarantees ≥1 ProjectForecastSnapshot per
615
+ # active project per day and backfills any recompute capture missed by a broker
616
+ # blip / worker death (ADR-0154, #388). The durability backstop for capture.
617
+ "capture-daily-forecast-floor": {
618
+ "task": "scheduling.capture_daily_forecast_floor",
619
+ # 00:30 UTC — early, before the nightly purges, so every project has a row.
620
+ "schedule": crontab(hour=0, minute=30),
621
+ },
622
+ # Nightly cleanup: applies the tiered retention curve to project forecast
623
+ # snapshots — all <90 d, weekly to 1 y, monthly forever (ADR-0154, #388).
624
+ "prune-forecast-snapshots-nightly": {
625
+ "task": "scheduling.prune_forecast_snapshots",
626
+ # 04:15 UTC — after the other nightly purge jobs.
627
+ "schedule": crontab(hour=4, minute=15),
628
+ },
629
+ # Webhook delivery drain: re-enqueues stranded PENDING deliveries whose
630
+ # initial .delay() call was lost (e.g. broker down at creation time).
631
+ "drain-webhook-queue": {
632
+ "task": "webhooks.drain_webhook_queue",
633
+ "schedule": 30.0,
634
+ },
635
+ # MS Project import drain: dispatches pending ImportRequest rows every 30 s.
636
+ # Also recovers orphaned dispatched rows (worker died mid-import).
637
+ "drain-import-queue": {
638
+ "task": "msproject.drain_import_queue",
639
+ "schedule": 30.0,
640
+ },
641
+ # Jira import drain: dispatches pending JiraImportRequest rows every 30 s and
642
+ # recovers orphaned dispatched rows (worker died mid-import).
643
+ "drain-jira-import-queue": {
644
+ "task": "jira.drain_import_queue",
645
+ "schedule": 30.0,
646
+ },
647
+ # CSV/Excel import drain: dispatches pending CsvImportRequest rows every 30 s
648
+ # and recovers orphaned dispatched rows (worker died mid-import). Kept
649
+ # separate from the MSP and Jira drains per ADR-0259/ADR-0632 so no drain has
650
+ # to branch on file type.
651
+ "drain-csv-import-queue": {
652
+ "task": "csv.drain_import_queue",
653
+ "schedule": 30.0,
654
+ },
655
+ # Template-apply drain (ADR-0789, #2729): dispatches pending
656
+ # TemplateApplication rows every 30 s and recovers ones whose worker died
657
+ # mid-apply. This is what makes enqueue_template_apply's best-effort
658
+ # .delay() durable — without it, a broker outage at dispatch time would
659
+ # leave an application pending forever and the Start sheet polling nothing.
660
+ "drain-template-apply-queue": {
661
+ "task": "templates.drain_apply_queue",
662
+ "schedule": 30.0,
663
+ },
664
+ # Sprint close drain: dispatches pending SprintCloseRequest rows every 30 s.
665
+ # Also recovers IN_FLIGHT rows orphaned past the 5-minute window.
666
+ "drain-sprint-close-requests": {
667
+ "task": "projects.drain_sprint_close_requests",
668
+ "schedule": 30.0,
669
+ },
670
+ # Daily burndown snapshot: writes yesterday's row for every ACTIVE sprint
671
+ # — covers days with no task status changes (signal handler covers today).
672
+ "update-sprint-burndown-snapshots": {
673
+ "task": "projects.update_sprint_burndown_snapshots",
674
+ "schedule": crontab(hour=1, minute=0),
675
+ },
676
+ # Nightly cleanup: deletes COMPLETED/FAILED SprintCloseRequest rows >7 days.
677
+ "purge-sprint-close-requests": {
678
+ "task": "projects.purge_sprint_close_requests",
679
+ # 03:00 UTC — after other nightly purge jobs.
680
+ "schedule": crontab(hour=3, minute=0),
681
+ },
682
+ # Notification email outbox drain: send queued notification emails every 30 s.
683
+ # Fan-outs also nudge it on commit, so this tick is the durability backstop, not
684
+ # the latency path (ADR-0087 §Amendment #4191).
685
+ "drain-notification-emails": {
686
+ "task": "notifications.drain_notification_emails",
687
+ "schedule": 30.0,
688
+ },
689
+ # Nightly archive: notifications older than 90 days with is_read=True become
690
+ # is_archived=True. Keeps the unread-bell query path on a shallow index.
691
+ "archive-old-notifications": {
692
+ "task": "notifications.archive_old_notifications",
693
+ # 03:15 UTC — after other nightly purge/archive jobs.
694
+ "schedule": crontab(hour=3, minute=15),
695
+ },
696
+ # Daily stale-task detection (ADR-0200): nudge assignees of non-terminal tasks
697
+ # that have sat in their status past the project's stale_task_threshold_days
698
+ # (default 7). Dedupes against existing unread task.stale notifications.
699
+ "detect-stale-tasks": {
700
+ "task": "notifications.detect_stale_tasks",
701
+ # 05:30 UTC — after the nightly purges/archive, so the scan reads settled
702
+ # state and lands a fresh nudge before the working day starts.
703
+ "schedule": crontab(hour=5, minute=30),
704
+ },
705
+ # Scheduled digests (ADR-0663, #2407): hourly sweep that sends each opted-in
706
+ # user's weekly program-health / overallocation digest when the current hour
707
+ # matches their own slot in their own timezone. Hourly (not weekly) because
708
+ # the schedule is a per-user property — one cluster-wide weekly entry would be
709
+ # 3 a.m. for half the recipients. Runs at minute 10 so it does not contend
710
+ # with generate-recurring-occurrences at minute 0.
711
+ "send-scheduled-digests": {
712
+ "task": "notifications.send_scheduled_digests",
713
+ "schedule": crontab(minute=10),
714
+ },
715
+ # Nightly cleanup: drop digest ledger rows past the 90-day retention window
716
+ # (ADR-0663 §Durable Execution item 6). Longer than the 7-day convention
717
+ # because the ledger's job is to remember a weekly send.
718
+ "purge-old-digest-runs": {
719
+ "task": "notifications.purge_old_digest_runs",
720
+ # 03:20 UTC — between archive-old-notifications (03:15) and the 04:00
721
+ # workflow purge.
722
+ "schedule": crontab(hour=3, minute=20),
723
+ },
724
+ # Beat liveness heartbeat: a single worker writes BeatHeartbeat.last_heartbeat
725
+ # every 30 s. GET /api/v1/health/beat/ reads it to detect a dead Beat (ADR-0081).
726
+ "beat-heartbeat": {
727
+ "task": "beat.heartbeat",
728
+ "schedule": 30.0,
729
+ },
730
+ # Secondary in-cluster stale-heartbeat detector: logs WARNING when the heartbeat
731
+ # is older than TRUEPPM_BEAT_STALE_SECONDS. The /health/beat/ endpoint is the
732
+ # primary (external) detector; this serves adopters with no external monitoring.
733
+ "beat-check-stale-heartbeat": {
734
+ "task": "beat.check_stale_heartbeat",
735
+ "schedule": 60.0,
736
+ },
737
+ # Workflow step-outbox drain: re-dispatch stranded WorkflowOutboxRow rows
738
+ # every 30 s and recover rows orphaned by a dead worker (ADR-0080 §D).
739
+ "drain-workflow-outbox": {
740
+ "task": "workflows.outbox_drain",
741
+ "schedule": 30.0,
742
+ },
743
+ # Workflow sleep-timer drain: fire due WorkflowTimer rows and wake their
744
+ # sleeping workflows. 60 s cadence — sleep durations are minute-grained.
745
+ "drain-workflow-timers": {
746
+ "task": "workflows.timer_drain",
747
+ "schedule": 60.0,
748
+ },
749
+ # Nightly cleanup: terminal workflow outbox rows >7 days and history past
750
+ # the configurable retention window.
751
+ "purge-workflow-records": {
752
+ "task": "workflows.purge_old_records",
753
+ # 04:00 UTC — after the other nightly purge jobs.
754
+ "schedule": crontab(hour=4, minute=0),
755
+ },
756
+ # Hourly cleanup: deletes stored Idempotency-Key rows older than
757
+ # IDEMPOTENCY_RETENTION_HOURS. Hourly (not nightly) so the 24h contract holds —
758
+ # a nightly job would let rows live up to ~48h (ADR-0170).
759
+ "idempotency-keys-purge-hourly": {
760
+ "task": "idempotency.purge_old_keys",
761
+ "schedule": crontab(minute=5),
762
+ },
763
+ # Workspace invite email outbox drain: send queued invite emails every 30 s.
764
+ # create_invite also nudges it on commit, so this tick is the durability
765
+ # backstop, not the latency path (ADR-0087 §Amendment #4191).
766
+ "drain-invite-emails": {
767
+ "task": "workspace.drain_invite_emails",
768
+ "schedule": 30.0,
769
+ },
770
+ # Nightly cleanup: expire overdue pending invites and delete terminal invites
771
+ # older than INVITE_RETENTION_DAYS (ADR-0087 §Durable Execution item 6).
772
+ "purge-stale-invites": {
773
+ "task": "workspace.purge_stale_invites",
774
+ # 04:15 UTC — after the other nightly purge jobs.
775
+ "schedule": crontab(hour=4, minute=15),
776
+ },
777
+ # Re-dispatch workspace export jobs orphaned by a broker outage at on_commit
778
+ # (ADR-0174 §Durable Execution item 2; 5-min orphan window inside the task).
779
+ "drain-workspace-exports": {
780
+ "task": "workspace.drain_workspace_exports",
781
+ "schedule": 30.0,
782
+ },
783
+ # Nightly: delete export jobs past their download-link expiry and their files
784
+ # (ADR-0174 §Durable Execution item 6).
785
+ "purge-expired-exports": {
786
+ "task": "workspace.purge_expired_exports",
787
+ # 04:20 UTC — after purge-stale-invites.
788
+ "schedule": crontab(hour=4, minute=20),
789
+ },
790
+ # Re-dispatch project export bundle jobs orphaned by a broker outage at on_commit
791
+ # (ADR-0219 §Durable Execution item 2; 5-min orphan window inside the task).
792
+ "drain-project-exports": {
793
+ "task": "projects.drain_project_exports",
794
+ "schedule": 30.0,
795
+ },
796
+ # Nightly: delete project export jobs past their download-link expiry and their
797
+ # files (ADR-0219 §Durable Execution item 6; shares TRUEPPM_EXPORT_RETENTION_DAYS).
798
+ "purge-expired-project-exports": {
799
+ "task": "projects.purge_expired_project_exports",
800
+ # 04:25 UTC — after the workspace export purge.
801
+ "schedule": crontab(hour=4, minute=25),
802
+ },
803
+ # Re-dispatch program export bundle jobs orphaned by a broker outage at on_commit
804
+ # (ADR-0219 §Durable Execution item 2, program grain #1958).
805
+ "drain-program-exports": {
806
+ "task": "projects.drain_program_exports",
807
+ "schedule": 30.0,
808
+ },
809
+ # Nightly: delete program export jobs past their download-link expiry and their
810
+ # files (ADR-0219 §Durable Execution item 6; shares TRUEPPM_EXPORT_RETENTION_DAYS).
811
+ "purge-expired-program-exports": {
812
+ "task": "projects.purge_expired_program_exports",
813
+ # 04:30 UTC — after the project export purge.
814
+ "schedule": crontab(hour=4, minute=30),
815
+ },
816
+ # Re-dispatch program seed IMPORT jobs orphaned by a broker outage at on_commit
817
+ # (ADR-0726 §Durable Execution item 2). Deliberately a separate drain from the
818
+ # export one: same shape, different table and different terminal semantics.
819
+ "drain-program-imports": {
820
+ "task": "projects.drain_program_imports",
821
+ "schedule": 30.0,
822
+ },
823
+ # Nightly: delete terminal program seed import jobs past retention and their
824
+ # stored payloads (ADR-0726 §Durable Execution item 6; shares
825
+ # TRUEPPM_IMPORT_RETENTION_DAYS with the MS Project import outbox).
826
+ "purge-expired-program-imports": {
827
+ "task": "projects.purge_expired_program_imports",
828
+ # 04:35 UTC — after the program export purge.
829
+ "schedule": crontab(hour=4, minute=35),
830
+ },
831
+ # Nightly: delete paste-many/cascade ⌘Z undo ledger rows past
832
+ # TRUEPPM_BATCH_OPERATION_RETENTION_DAYS, undone or not (ADR-0810, #2756).
833
+ "purge-expired-batch-operations": {
834
+ "task": "projects.purge_expired_batch_operations",
835
+ # 04:40 UTC — after the program import purge.
836
+ "schedule": crontab(hour=4, minute=40),
837
+ },
838
+ # Lazily materialize upcoming recurring-task occurrences within the
839
+ # TRUEPPM_RECURRENCE_HORIZON_DAYS look-ahead. Hourly: occurrences are date-grained,
840
+ # and a missed tick self-heals on the next one (idempotent). See ADR-0090 / #736.
841
+ "generate-recurring-occurrences": {
842
+ "task": "projects.generate_recurring_occurrences",
843
+ "schedule": crontab(minute=0),
844
+ },
845
+ # Nightly: flush expired OutstandingToken/BlacklistedToken rows so the JWT
846
+ # blacklist tables stay bounded to roughly the active-session window (#910).
847
+ # No-ops when the token_blacklist app is not installed.
848
+ "flush-expired-blacklisted-tokens": {
849
+ "task": "access.flush_expired_blacklisted_tokens",
850
+ # 04:30 UTC — after the other nightly purge jobs.
851
+ "schedule": crontab(hour=4, minute=30),
852
+ },
853
+ # Nightly: hard-delete per-row soft-deleted tombstones (is_deleted=True) from
854
+ # live projects older than TRUEPPM_TOMBSTONE_RETENTION_DAYS (default 90 days).
855
+ # Tombstones are kept so mobile sync clients receive deletion signals; once the
856
+ # retention window passes there is no further sync value in the row (#1321).
857
+ "reap-domain-tombstones-nightly": {
858
+ "task": "sync.reap_domain_tombstones",
859
+ # 03:30 UTC — between the sprint-close and workflow purge windows.
860
+ "schedule": crontab(hour=3, minute=30),
861
+ },
862
+ # External task sync (ADR-0097 §4, #1419). Outbox drain for user-scoped
863
+ # read-only Jira pulls: dispatches PENDING ExternalSyncRequest rows and
864
+ # recovers orphaned DISPATCHED ones. 300 s cadence — a personal read-only
865
+ # pull is not latency-critical (the POST .../sync/ trigger dispatches
866
+ # immediately via on_commit; this is the broker-outage backstop).
867
+ "drain-external-sync": {
868
+ "task": "integrations.drain_external_sync",
869
+ "schedule": 300.0,
870
+ },
871
+ # Opt-in low-frequency poll (ADR-0097 §4). Default-off per connection
872
+ # (config["poll_enabled"], set from the Connected Accounts toggle); fans out
873
+ # zero pulls until a user turns it on. 15 minutes is also the per-connection
874
+ # rate ceiling once one does: the outbox's one-PENDING-row-per-(user, source)
875
+ # constraint coalesces, so an opted-in connection can never exceed one pull
876
+ # per tick no matter how many sources or ticks are in flight.
877
+ "poll-external-sources": {
878
+ "task": "integrations.poll_external_sources",
879
+ "schedule": crontab(minute="*/15"),
880
+ },
881
+ # Nightly: hard-delete terminal (done/dead) ExternalSyncRequest outbox rows
882
+ # older than 7 days and long-stale ExternalWorkItem cache rows (ADR-0097
883
+ # §Durable Execution #6).
884
+ "purge-external-sync-nightly": {
885
+ "task": "integrations.purge_external_sync",
886
+ # 03:45 UTC — after the domain-tombstone reap.
887
+ "schedule": crontab(hour=3, minute=45),
888
+ },
889
+ }
890
+
891
+ # ---------------------------------------------------------------------------
892
+ # Auth / Passwords
893
+ # ---------------------------------------------------------------------------
894
+
895
+ AUTH_PASSWORD_VALIDATORS = [
896
+ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
897
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
898
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
899
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
900
+ ]
901
+
902
+ # Self-service password reset (ADR-0209). Django's stateless
903
+ # ``PasswordResetTokenGenerator`` (default_token_generator) rejects tokens older
904
+ # than this window in ``check_token``. 30 minutes is the value surfaced in the
905
+ # reset-email copy and on the "sent" / "expired" screens — short enough to bound a
906
+ # leaked-link window, long enough for a user to act on the email.
907
+ PASSWORD_RESET_TIMEOUT = env.int("TRUEPPM_PASSWORD_RESET_TIMEOUT", default=1800)
908
+
909
+ # ---------------------------------------------------------------------------
910
+ # SimpleJWT token lifetimes + httpOnly refresh-cookie migration (#897)
911
+ # ---------------------------------------------------------------------------
912
+
913
+ # Short access-token TTL bounds the blast radius of a leaked in-memory access
914
+ # token; the SPA transparently refreshes via the httpOnly cookie on 401. A
915
+ # 7-day refresh TTL is the session length — long enough to avoid daily re-login,
916
+ # short enough that a stolen refresh token (cookie) self-expires within a week.
917
+ # Rotation + (optional) blacklist further limit replay of a leaked refresh token.
918
+ SIMPLE_JWT = {
919
+ # Sign tokens with the dedicated JWT key (#2247). Defaults to SECRET_KEY, so
920
+ # existing deploys are unaffected; set JWT_SIGNING_KEY to separate the two.
921
+ "SIGNING_KEY": JWT_SIGNING_KEY,
922
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
923
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
924
+ "ROTATE_REFRESH_TOKENS": True,
925
+ # Blacklisting requires the token_blacklist app + its migrations, now shipped
926
+ # in INSTALLED_APPS by default (#910), so rotation actually revokes the prior
927
+ # refresh token. The refresh/logout views still tolerate the app's absence so
928
+ # an operator who removes it (lean OSS deploy) degrades gracefully to
929
+ # TTL-only expiry rather than erroring.
930
+ "BLACKLIST_AFTER_ROTATION": True,
931
+ }
932
+
933
+ # httpOnly refresh-cookie attributes (#897). The refresh token rides in this
934
+ # cookie instead of the JSON body so JavaScript (and therefore XSS) cannot read
935
+ # it. Attributes are env-overridable so a non-HTTPS local dev server can still
936
+ # complete the flow.
937
+ #
938
+ # SameSite=Strict is safe here: the refresh request is a same-origin XHR from the
939
+ # SPA, which Strict permits, while it blocks the cookie on any cross-site request
940
+ # — the CSRF mitigation for this path (see core/auth_views.py).
941
+ #
942
+ # Env vars standardized on the TRUEPPM_ prefix pre-0.3 (#1325, #1355); the legacy
943
+ # bare names remain fallbacks so existing deploys keep working.
944
+ AUTH_REFRESH_COOKIE_NAME = env(
945
+ "TRUEPPM_AUTH_REFRESH_COOKIE_NAME",
946
+ default=env("AUTH_REFRESH_COOKIE_NAME", default="trueppm_refresh"),
947
+ )
948
+ # Path-scoped so the cookie is never sent on ordinary API calls. It must cover
949
+ # *every* endpoint that needs to read it, which is both the refresh endpoint and
950
+ # ``/api/v1/auth/logout/`` — scoping it to the refresh endpoint alone (the
951
+ # pre-#2999 default) meant the browser never sent it to logout, so
952
+ # ``CookieTokenLogoutView`` read ``None`` and revoked nothing (RFC 6265 §5.1.4).
953
+ # ``/api/v1/auth/`` is the narrowest prefix covering both.
954
+ AUTH_REFRESH_COOKIE_PATH = env(
955
+ "TRUEPPM_AUTH_REFRESH_COOKIE_PATH",
956
+ default=env("AUTH_REFRESH_COOKIE_PATH", default="/api/v1/auth/"),
957
+ )
958
+ #
959
+ # The resolved value is validated, not passed through verbatim (#3556): refresh
960
+ # and logout are authenticated by this cookie alone (no CSRF token, no
961
+ # double-submit), so SameSite is the only unconditional CSRF control on them.
962
+ # ``None`` requires the acknowledgment sentinel below or it is refused and
963
+ # forced back to ``Strict``; ``Lax`` is accepted but logs a WARNING naming what
964
+ # it gives up. See ``core/refresh_cookie_policy.py`` for the full resolution.
965
+ _refresh_cookie_samesite_resolution = resolve_refresh_cookie_samesite(
966
+ requested=env(
967
+ "TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE",
968
+ default=env("AUTH_REFRESH_COOKIE_SAMESITE", default="Strict"),
969
+ ),
970
+ ack=env("TRUEPPM_AUTH_REFRESH_COOKIE_SAMESITE_NONE_ACK", default=""),
971
+ )
972
+ AUTH_REFRESH_COOKIE_SAMESITE = _refresh_cookie_samesite_resolution.value
973
+ if _refresh_cookie_samesite_resolution.warning:
974
+ _settings_log.warning(_refresh_cookie_samesite_resolution.warning)
975
+ if _refresh_cookie_samesite_resolution.critical:
976
+ _settings_log.critical(_refresh_cookie_samesite_resolution.critical)
977
+ # Default Secure=True; dev settings flip this to False for plain-HTTP localhost.
978
+ AUTH_REFRESH_COOKIE_SECURE = env.bool(
979
+ "TRUEPPM_AUTH_REFRESH_COOKIE_SECURE",
980
+ default=env.bool("AUTH_REFRESH_COOKIE_SECURE", default=True),
981
+ )
982
+
983
+ # Refresh-session lifetimes for the "remember me" choice (#2246, ADR-0544).
984
+ # The login checkbox now drives which of these two the refresh token gets:
985
+ # - checked → REFRESH_TOKEN_REMEMBER_LIFETIME: a long-lived, *persistent* cookie
986
+ # (survives browser close) for a trusted device.
987
+ # - unchecked (default) → REFRESH_TOKEN_SESSION_LIFETIME: a *session* cookie that
988
+ # dies on browser close, with a short sliding idle bound for a machine left open.
989
+ # SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"] (7d) stays as the LEGACY default: it is used
990
+ # only when rotating a pre-#2246 token that carries no "remember" claim, so existing
991
+ # sessions are never forcibly logged out (they adopt the new model at next login).
992
+ REFRESH_TOKEN_REMEMBER_LIFETIME = timedelta(
993
+ days=env.int("TRUEPPM_REFRESH_TOKEN_REMEMBER_DAYS", default=30)
994
+ )
995
+ REFRESH_TOKEN_SESSION_LIFETIME = timedelta(
996
+ hours=env.int("TRUEPPM_REFRESH_TOKEN_SESSION_HOURS", default=12)
997
+ )
998
+
999
+ # ---------------------------------------------------------------------------
1000
+ # Django session + CSRF cookie hardening (#2248)
1001
+ # ---------------------------------------------------------------------------
1002
+ # These cookies are effectively exercised only by the Django admin site — the SPA
1003
+ # authenticates with the JWT bearer/refresh-cookie scheme above and the DRF
1004
+ # browsable API is disabled (JSON-only renderer). Django's implicit defaults for
1005
+ # these three already match what we want, but pin them explicitly so the posture
1006
+ # is auditable and cannot silently change across a Django upgrade. `*_SECURE` is
1007
+ # intentionally NOT pinned here: it stays False in dev (plain-HTTP localhost) and
1008
+ # prod.py sets both to True.
1009
+ SESSION_COOKIE_HTTPONLY = True
1010
+ SESSION_COOKIE_SAMESITE = "Lax"
1011
+ CSRF_COOKIE_SAMESITE = "Lax"
1012
+ # CSRF_COOKIE_HTTPONLY stays False (Django default): the token must be readable by
1013
+ # JavaScript to be echoed in the X-CSRFToken header. This is the standard,
1014
+ # correct posture and is not a leak — the CSRF cookie is not a credential.
1015
+
1016
+ # ---------------------------------------------------------------------------
1017
+ # Content-Security-Policy (#897)
1018
+ # ---------------------------------------------------------------------------
1019
+
1020
+ # Strict CSP, assembled into a header by core.csp.ContentSecurityPolicyMiddleware.
1021
+ # script-src 'self' (no inline / no hash) is possible because the theme-init
1022
+ # script was moved to an external file (web/public/theme-init.js). connect-src
1023
+ # carries wss: for the WebSocket collaboration channel; the host is overridable
1024
+ # per deploy. style-src keeps 'unsafe-inline' because the SPA emits inline style
1025
+ # attributes (Tailwind/JS-driven) — tightening this is tracked separately.
1026
+ _CSP_SELF = "'self'"
1027
+ CSP_CONNECT_SRC = env.list("CSP_CONNECT_SRC", default=[_CSP_SELF, "wss:"])
1028
+ CSP_DIRECTIVES: dict[str, list[str]] = {
1029
+ "default-src": [_CSP_SELF],
1030
+ "script-src": [_CSP_SELF],
1031
+ "style-src": [_CSP_SELF, "'unsafe-inline'", "fonts.googleapis.com"],
1032
+ "font-src": ["fonts.gstatic.com"],
1033
+ "img-src": [_CSP_SELF, "data:"],
1034
+ "connect-src": CSP_CONNECT_SRC,
1035
+ "frame-ancestors": ["'none'"],
1036
+ "base-uri": ["'none'"],
1037
+ "form-action": [_CSP_SELF],
1038
+ }
1039
+
1040
+ # ---------------------------------------------------------------------------
1041
+ # Internationalization
1042
+ # ---------------------------------------------------------------------------
1043
+
1044
+ LANGUAGE_CODE = "en-us"
1045
+ TIME_ZONE = "UTC"
1046
+ USE_I18N = True
1047
+ USE_TZ = True
1048
+
1049
+ # ---------------------------------------------------------------------------
1050
+ # Static files
1051
+ # ---------------------------------------------------------------------------
1052
+
1053
+ STATIC_URL = "static/"
1054
+ # Allow an explicit env override so the app can write static files to a
1055
+ # writable path when installed as a pip package (where BASE_DIR resolves
1056
+ # inside the read-only venv).
1057
+ STATIC_ROOT = Path(env("STATIC_ROOT", default=str(BASE_DIR / "staticfiles")))
1058
+
1059
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
1060
+
1061
+
1062
+ def _package_version() -> str:
1063
+ """The installed distribution version, or a dev placeholder off-package.
1064
+
1065
+ Read from installed metadata rather than hard-coded so it cannot drift from
1066
+ `pyproject.toml`; a source checkout that was never `pip install -e`'d has no
1067
+ metadata, and "dev" is a more honest answer there than a stale literal.
1068
+
1069
+ Normalized to PEP 440 here because the metadata spelling belongs to the build
1070
+ backend: hatchling <=1.32.0 wrote "0.4.0b1", 1.32.1 writes pyproject's
1071
+ "0.4.0-beta.1" verbatim, and `pip install -e` builds with whichever is newest.
1072
+ Without this the published schema version flips on an upstream release.
1073
+ """
1074
+ try:
1075
+ from importlib.metadata import version
1076
+
1077
+ from packaging.version import Version
1078
+
1079
+ return str(Version(version("trueppm-api")))
1080
+ except Exception:
1081
+ return "dev"
1082
+
1083
+
1084
+ # ---------------------------------------------------------------------------
1085
+ # Edition detection
1086
+ # Set TRUEPPM_EDITION=enterprise in the enterprise Helm chart to activate
1087
+ # enterprise landing logic in the frontend. The /api/v1/edition/ endpoint
1088
+ # reads this setting and returns it to unauthenticated clients so the React
1089
+ # shell can make the post-login redirect decision without importing enterprise
1090
+ # code (ADR-0029).
1091
+ # ---------------------------------------------------------------------------
1092
+
1093
+ TRUEPPM_EDITION: str = env("TRUEPPM_EDITION", default="community")
1094
+
1095
+ # Build identity surfaced on /api/v1/edition/ so a bug report can name the exact
1096
+ # build it came from (#2392). The version tracks the package version; the SHA is
1097
+ # injected by the image build (Helm sets TRUEPPM_BUILD_SHA from the CI commit)
1098
+ # and is empty on a source checkout, where the version alone is enough.
1099
+ TRUEPPM_VERSION: str = env("TRUEPPM_VERSION", default=_package_version())
1100
+ TRUEPPM_BUILD_SHA: str = env("TRUEPPM_BUILD_SHA", default="")
1101
+
1102
+ # ---------------------------------------------------------------------------
1103
+ # OpenTelemetry observability foundation (ADR-0223, #708)
1104
+ #
1105
+ # OPT-IN, no default endpoint. Telemetry is enabled only when an OTLP endpoint
1106
+ # is configured AND the master switch is on; with no endpoint the provider is a
1107
+ # strict no-op (no SDK objects, no export threads, zero per-request cost). We
1108
+ # read the well-known upstream OTEL_* env vars so operators reuse the names they
1109
+ # already know from every other OTel-emitting service in their cluster, and add
1110
+ # a few TRUEPPM_OTEL_* switches for behaviour the standard vars don't express.
1111
+ # The bootstrap in trueppm_api.apps.observability.otel consumes these settings.
1112
+ # ---------------------------------------------------------------------------
1113
+
1114
+ # The gate: an empty endpoint means telemetry is off. No default is provided on
1115
+ # purpose — export must be a deliberate operator choice, never a silent egress.
1116
+ OTEL_EXPORTER_OTLP_ENDPOINT: str = env("OTEL_EXPORTER_OTLP_ENDPOINT", default="")
1117
+ # "grpc" (default, port 4317) or "http/protobuf" (port 4318).
1118
+ OTEL_EXPORTER_OTLP_PROTOCOL: str = env("OTEL_EXPORTER_OTLP_PROTOCOL", default="grpc")
1119
+ # Passed through verbatim to the exporter, e.g. "authorization=Bearer <token>"
1120
+ # for a SaaS OTLP backend. Comma-separated key=value pairs per the OTel spec.
1121
+ OTEL_EXPORTER_OTLP_HEADERS: str = env("OTEL_EXPORTER_OTLP_HEADERS", default="")
1122
+ # Resource service.name. Defaults to the service, not the pod.
1123
+ OTEL_SERVICE_NAME: str = env("OTEL_SERVICE_NAME", default="trueppm-api")
1124
+ # Master kill switch, AND-ed with endpoint presence. Lets an operator leave the
1125
+ # endpoint configured (e.g. in a shared ConfigMap) while disabling export here.
1126
+ TRUEPPM_OTEL_ENABLED: bool = env.bool("TRUEPPM_OTEL_ENABLED", default=True)
1127
+ # Independent signal toggles (only consulted when telemetry is enabled overall).
1128
+ TRUEPPM_OTEL_TRACES_ENABLED: bool = env.bool("TRUEPPM_OTEL_TRACES_ENABLED", default=True)
1129
+ TRUEPPM_OTEL_METRICS_ENABLED: bool = env.bool("TRUEPPM_OTEL_METRICS_ENABLED", default=True)
1130
+ # Trace sampler selection, using the standard upstream env var names. Because the
1131
+ # TracerProvider is built by hand (not via the SDK auto-config path), these would
1132
+ # otherwise be silently ignored; the bootstrap reads them to build a Sampler so
1133
+ # operators can throttle DB-span-dominated trace volume without a code change.
1134
+ # Recognized samplers: always_on (default), always_off, traceidratio,
1135
+ # parentbased_always_on, parentbased_always_off, parentbased_traceidratio.
1136
+ # The arg carries the ratio (0.0–1.0) for the ratio-based samplers.
1137
+ OTEL_TRACES_SAMPLER: str = env("OTEL_TRACES_SAMPLER", default="parentbased_always_on")
1138
+ OTEL_TRACES_SAMPLER_ARG: str = env("OTEL_TRACES_SAMPLER_ARG", default="")
1139
+ # Wall-clock bound (seconds) for the admin telemetry test-export probe (#2110):
1140
+ # caps the one-off canary export and the TCP reachability probe so a dead collector
1141
+ # can never hang the request thread. Well under the gunicorn worker timeout.
1142
+ TELEMETRY_TEST_EXPORT_TIMEOUT_SECONDS: int = env.int(
1143
+ "TRUEPPM_TELEMETRY_TEST_EXPORT_TIMEOUT_SECONDS", default=5
1144
+ )
1145
+ # Live export-health recording (ADR-0601, #2109). When on (default), each pod that
1146
+ # runs an OSS OTLP exporter wraps it in a best-effort recorder that publishes
1147
+ # per-signal success/error/count into Valkey /2, so the System Health Telemetry
1148
+ # card can show a cluster-wide live strip ("last export 8s ago · 1,204 spans /
1149
+ # 60s") aggregated across the worker/beat pods that actually export. Purely
1150
+ # additive and non-fatal: a recorder or Valkey failure never affects export. Set
1151
+ # false to skip installing the wrappers entirely (the card falls back to the
1152
+ # config-only posture). Requires the Valkey /2 DB to run maxmemory-policy
1153
+ # noeviction — the same requirement the throttle counters already impose.
1154
+ TRUEPPM_OTEL_EXPORT_HEALTH_ENABLED: bool = env.bool(
1155
+ "TRUEPPM_OTEL_EXPORT_HEALTH_ENABLED", default=True
1156
+ )
1157
+ # Optional stable per-pod identity for the export-health record. On Kubernetes the
1158
+ # hostname already IS the pod name, so the default (socket.gethostname():pid) is a
1159
+ # no-config stable identity; the Helm chart injects this via the downward API
1160
+ # (metadata.name) for a stable, human-readable label.
1161
+ TRUEPPM_POD_NAME: str = env("TRUEPPM_POD_NAME", default="")
1162
+ # Live export-health thresholds, in seconds. Defaults suit the standard 60s metric
1163
+ # export interval; tune only for unusual cadences. INVARIANT: STALENESS must exceed
1164
+ # HEALTHY_WITHIN — a record must stay live long enough to be seen as idle/stalled
1165
+ # before it expires, or an overdue signal is masked as a premature `never` (the app
1166
+ # logs a warning if this ordering is violated; it does not clamp).
1167
+ # STALENESS: per-pod record TTL / how long a pod counts as live after its last
1168
+ # export attempt. Beyond it (no export at all) the signal reads `never`.
1169
+ # HEALTHY_WITHIN: a success within this window ⇒ healthy; beyond it (still live) ⇒
1170
+ # stalled (metrics) / idle (traces).
1171
+ # WINDOW: the rolling window the exported-item counts cover; the card labels the
1172
+ # strip from this value ("last 60s"), so changing it re-labels the UI.
1173
+ TRUEPPM_OTEL_EXPORT_HEALTH_STALENESS_SECONDS: int = env.int(
1174
+ "TRUEPPM_OTEL_EXPORT_HEALTH_STALENESS_SECONDS", default=600
1175
+ )
1176
+ TRUEPPM_OTEL_EXPORT_HEALTH_HEALTHY_WITHIN_SECONDS: int = env.int(
1177
+ "TRUEPPM_OTEL_EXPORT_HEALTH_HEALTHY_WITHIN_SECONDS", default=150
1178
+ )
1179
+ TRUEPPM_OTEL_EXPORT_HEALTH_WINDOW_SECONDS: int = env.int(
1180
+ "TRUEPPM_OTEL_EXPORT_HEALTH_WINDOW_SECONDS", default=60
1181
+ )
1182
+ # Actor attributes on the HTTP server span (#2880). When on (default), each request
1183
+ # span carries trueppm.user.id (the acting account's opaque UUID) and
1184
+ # trueppm.user.role (the symbolic project role the request was authorized under), so
1185
+ # a trace can be attributed to a who and a permission level without opening the
1186
+ # payload. NOTHING else about the person is ever exported under trueppm.user.* — no
1187
+ # email, username, display name, or client IP. Set false where a per-user identifier
1188
+ # must not leave the instance even to a self-hosted collector; resource identity
1189
+ # (trueppm.project.id / .program.id / .task.id) is unaffected either way.
1190
+ TRUEPPM_OTEL_ACTOR_ATTRIBUTES_ENABLED: bool = env.bool(
1191
+ "TRUEPPM_OTEL_ACTOR_ATTRIBUTES_ENABLED", default=True
1192
+ )
1193
+
1194
+ # ---------------------------------------------------------------------------
1195
+ # Structured logging + trace correlation (ADR-0223, #1899)
1196
+ #
1197
+ # The base config emits human-readable console lines that still carry the OTel
1198
+ # trace_id/span_id/request_id inline, so a developer sees the same correlation
1199
+ # context locally. prod.py flips this to single-line JSON (TRUEPPM_LOG_JSON) for a
1200
+ # collector to index. The level honours DJANGO_LOG_LEVEL (default INFO) — the same
1201
+ # well-known name operators set on every other Django service. Trace correlation
1202
+ # (not OTLP log export, which is #711) means a log line can be pivoted straight to
1203
+ # the trace exported for the same request.
1204
+ # ---------------------------------------------------------------------------
1205
+
1206
+ DJANGO_LOG_LEVEL: str = env("DJANGO_LOG_LEVEL", default="INFO")
1207
+ # JSON is opt-in in the base config so dev stays readable; prod.py sets this True.
1208
+ TRUEPPM_LOG_JSON: bool = env.bool("TRUEPPM_LOG_JSON", default=False)
1209
+
1210
+ from trueppm_api.apps.observability.logging import ( # noqa: E402
1211
+ build_logging_config,
1212
+ )
1213
+
1214
+ LOGGING = build_logging_config(level=DJANGO_LOG_LEVEL, json_output=TRUEPPM_LOG_JSON)
1215
+
1216
+ # ---------------------------------------------------------------------------
1217
+ # Integration credential encryption key (ADR-0049 §3)
1218
+ #
1219
+ # Fernet key used to encrypt the PAT secrets stored in
1220
+ # trueppm_api.apps.integrations.IntegrationCredential.secret_ciphertext.
1221
+ # Production deployments source this from a Kubernetes Secret via the Helm
1222
+ # chart; the empty default fails loud (ImproperlyConfigured) the first time
1223
+ # the helper is called, which is what we want — better than silently storing
1224
+ # unencrypted PATs in dev because someone forgot to set the env var.
1225
+ #
1226
+ # Generate one with:
1227
+ # python -c "from cryptography.fernet import Fernet; \
1228
+ # print(Fernet.generate_key().decode())"
1229
+ # ---------------------------------------------------------------------------
1230
+
1231
+ INTEGRATION_ENCRYPTION_KEY: str = env(
1232
+ "INTEGRATION_ENCRYPTION_KEY",
1233
+ default="",
1234
+ )
1235
+
1236
+ # Hostnames exempted from the SSRF egress deny-list (apps/integrations/http). An
1237
+ # operator escape hatch for a trusted internal host the operator runs themselves —
1238
+ # chiefly an in-cluster identity provider whose OIDC issuer resolves to a private
1239
+ # ClusterIP (e.g. Keycloak at ``keycloak.sso.svc``), which the deny-list would
1240
+ # otherwise block, making in-cluster SSO impossible. Empty by default keeps the
1241
+ # standard "every resolved address must be globally routable" posture. Exact,
1242
+ # case-insensitive hostname match — no wildcards. Config-only, never
1243
+ # request-derived. NOTE: the allow-list is consulted by *every* egress caller (PAT
1244
+ # verification, git-link refresh, webhooks, SMTP relay, SSO), so an allow-listed
1245
+ # host is reachable through the user-influenceable URL surfaces too — only list a
1246
+ # host at least as trusted as the SSO issuer. See ADR-0590 and the "Running the
1247
+ # identity provider inside your cluster" section of
1248
+ # packages/website/src/content/docs/administration/single-sign-on.md.
1249
+ EGRESS_ALLOWLISTED_HOSTS: list[str] = env.list("TRUEPPM_EGRESS_ALLOWLISTED_HOSTS", default=[])
1250
+
1251
+ # Per-provider base-URL allow-list for user-connected external task sources
1252
+ # (ADR-0097 / ADR-0589). Read at ``apps/integrations/providers.py:85`` and named in
1253
+ # the rejection message an operator sees when their self-hosted Jira/GitLab host is
1254
+ # refused — but bound by NO settings module until #2860, so setting the documented
1255
+ # env var did nothing and the escape hatch the message points at did not exist.
1256
+ #
1257
+ # The allow-list fails CLOSED, so the bug was the inverse of a security hole: an
1258
+ # operator with a legitimately self-hosted instance could not connect it at all, and
1259
+ # the error told them to set a variable that was never read. Documented in four
1260
+ # places (configuration.md, connected-accounts.md, ADR-0589, changelog.d/2270)
1261
+ # throughout.
1262
+ #
1263
+ # Distinct from EGRESS_ALLOWLISTED_HOSTS above, which is the SSRF deny-list escape
1264
+ # hatch consulted by *every* egress caller. This one only widens which base URLs a
1265
+ # user may attach a personal credential to; a host listed here still passes through
1266
+ # the SSRF-guarded http helper on every fetch.
1267
+ INTEGRATION_ALLOWED_HOSTS: list[str] = env.list("TRUEPPM_INTEGRATION_ALLOWED_HOSTS", default=[])
1268
+
1269
+ # Age in seconds past which a connected external-source connection's cache is
1270
+ # "stale" for the My Work on-open refresh (ADR-0097 §4 "on-open refresh-if-
1271
+ # stale"; #1921). Reuses the same order of magnitude as the manual-refresh
1272
+ # cooldown (ADR-0097 §Resolution #5, `services.MANUAL_SYNC_COOLDOWN_SECONDS`)
1273
+ # rather than inventing an unrelated number — a connection synced within this
1274
+ # window is fresh enough that another My Work load should not re-trigger a
1275
+ # pull. Configurable per-operator: a slower Jira instance or a rate-sensitive
1276
+ # token may want a wider floor.
1277
+ TRUEPPM_EXTERNAL_SYNC_ON_OPEN_STALE_SECONDS: int = env.int(
1278
+ "TRUEPPM_EXTERNAL_SYNC_ON_OPEN_STALE_SECONDS", default=300
1279
+ )
1280
+
1281
+ # ---------------------------------------------------------------------------
1282
+ # Monte Carlo simulation caps (OSS tier)
1283
+ # Set to None for unlimited (Enterprise overrides these in enterprise settings).
1284
+ # ---------------------------------------------------------------------------
1285
+
1286
+ MC_SIMULATION_CAP: int | None = 1_000
1287
+ # Raised 500 -> 5000 (#823): the OSS scaling story is "10k tasks", but the MC
1288
+ # endpoint returned HTTP 402 for any project over 500 tasks, so Monte Carlo was
1289
+ # silently unavailable for realistic large projects. The vectorised numpy path
1290
+ # (scheduler/engine.py) is O(runs x tasks x edges) and handles a 5000-task x
1291
+ # 1000-run simulation in a few seconds. Operators on constrained hardware can
1292
+ # lower this; Enterprise overrides it in enterprise settings.
1293
+ MC_TASK_CAP: int | None = 5_000
1294
+
1295
+ # Request-path ceiling on the Monte Carlo lag-delta table (#4129):
1296
+ # (distinct dependency type/lag/calendar-pair keys) x (working-day span) cells, each a
1297
+ # 4-byte int32, so 5M cells is ~20 MB per simulation (the what-if endpoint runs two
1298
+ # in sequence, never concurrently). The scheduler library default is 50M cells
1299
+ # (~200 MB) because a batch caller owns the machine; a synchronous endpoint shares a
1300
+ # pod with concurrent requests and MC_TASK_CAP / MC_SIMULATION_CAP do not bound this
1301
+ # product, so a project that passed every other cap could peak near 630 MB. Above the
1302
+ # cap the run is refused as a 400 naming the lag variety. None = the library default.
1303
+ MC_LAG_DELTA_CELL_CAP: int | None = 5_000_000
1304
+
1305
+ # Request-path ceiling on the PRODUCT runs x n_tasks (#2839). MC_SIMULATION_CAP and
1306
+ # MC_TASK_CAP bound each dimension, but the simulation keeps three (runs, n_tasks)
1307
+ # float64 matrices live at once plus temporaries, so its memory and its (vectorized,
1308
+ # ~linear) wall time follow the product. At 5,000 x 1,000 = 5M cells one simulation
1309
+ # peaked at ~234 MB traced / ~336 MB RSS, on the single default API worker
1310
+ # (processes.api.workers: 1, 2 GiB limit) that also serves every WebSocket, and the
1311
+ # what-if endpoint runs two back to back. 2.5M cells halves that (~117 MB traced,
1312
+ # ~168 MB peak RSS at the measured ~67 B/cell — size limits against RSS),
1313
+ # still runs the full 1,000 iterations up to 2,500 tasks, and gives a project at
1314
+ # MC_TASK_CAP 500 — the percentile resolution of a 500-sample run is 0.2%, well
1315
+ # inside the day granularity the forecast reports. An omitted n_simulations scales
1316
+ # down to fit (the response's `runs` reports it); an explicit one above the
1317
+ # size-derived maximum is refused 400 rather than silently shrunk. Not an edition
1318
+ # gate: it protects the worker, so Enterprise keeps it unless its hardware differs.
1319
+ # None = no product cap.
1320
+ MC_CELL_CAP: int | None = 2_500_000
1321
+
1322
+ # Simulations one API process runs at once (#2839). Sync views run one thread per
1323
+ # request under ASGI, so concurrent runs in one worker are real; MC_CELL_CAP bounds
1324
+ # each, this bounds how many stack. 2 x ~117 MB keeps a full-size burst far inside
1325
+ # the 2 GiB default worker limit. A request waits up to 10 s for a slot, then gets
1326
+ # 503 + Retry-After. Per process, so N workers admit N x this. None = unbounded.
1327
+ MC_MAX_CONCURRENT: int | None = 2
1328
+
1329
+ # Project Monte Carlo run-history retention (ADR-0175, #961): the nightly purge
1330
+ # keeps the newest MC_HISTORY_CAP MonteCarloRun rows per project so a PM can read
1331
+ # finish-date forecast drift over time. None = unlimited (Enterprise overrides it
1332
+ # — unbounded history + cross-program rollup is the portfolio upsell).
1333
+ MC_HISTORY_CAP: int | None = 100
1334
+
1335
+ # Per-run distribution persistence (#1231, ADR-0144). The full
1336
+ # {histogram_buckets, confidence_curve, sensitivity} payload is stored on
1337
+ # MonteCarloRun.distribution so the histogram survives cache expiry. Bounded at
1338
+ # 32 KB: a payload over this is down-sampled (every Nth histogram bucket) before
1339
+ # persist so a pathological high-bucket run cannot bloat the row. The cache copy
1340
+ # is unaffected by the down-sample.
1341
+ MC_DISTRIBUTION_MAX_BYTES: int = 32_768
1342
+
1343
+ # Absolute upper bound on the per-workspace forecast-history retention cap
1344
+ # (ADR-0144). The per-workspace mc_history_retention_cap is clamped to this on
1345
+ # read so an operator (or future Enterprise override) cannot configure an
1346
+ # unbounded per-project history that the nightly purge would never trim.
1347
+ MC_HISTORY_HARD_CAP: int = 500
1348
+
1349
+ # Tiered retention curve for project-grain forecast snapshots (ADR-0154, #388).
1350
+ # The nightly prune keeps every snapshot younger than ``daily_days``, then thins to
1351
+ # one-per-ISO-week up to ``weekly_days``, then one-per-calendar-month beyond that
1352
+ # (kept forever — the cold tail is ~12 rows/project/year). Operators can tune the
1353
+ # two windows; the per-period keeper is always the newest row in that bucket.
1354
+ FORECAST_SNAPSHOT_RETENTION: dict[str, int] = {
1355
+ "daily_days": 90,
1356
+ "weekly_days": 365,
1357
+ }
1358
+
1359
+ # ---------------------------------------------------------------------------
1360
+ # Upload caps (ADR-0075, task attachments)
1361
+ # ---------------------------------------------------------------------------
1362
+
1363
+ # Hard 100 MB ceiling matches the TaskAttachment cap (ADR-0075 locked
1364
+ # constraint #4). The serializer also enforces this, but the setting must
1365
+ # fire FIRST — without it, Django buffers the entire body to /tmp before
1366
+ # the serializer runs, which lets an authenticated Member spam 100 MB+
1367
+ # multipart bodies and exhaust worker time + disk. Operators should also
1368
+ # set nginx `client_max_body_size: 100m` so over-sized requests are
1369
+ # rejected at the edge, not after Django finishes parsing them.
1370
+ DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024 # 100 MB
1371
+ FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440 # 2.5 MB (Django default; explicit)
1372
+
1373
+ # Days a public share link lasts when the mint request omits ``expires_at`` (#3177).
1374
+ # An explicit null still means "never expires" — this is a default, not a ceiling, so
1375
+ # standing embeds and wallboards stay possible. Set to 0 to restore the pre-#3177
1376
+ # behavior in which an omitted expiry meant never.
1377
+ TRUEPPM_SHARE_LINK_DEFAULT_EXPIRY_DAYS = env.int(
1378
+ "TRUEPPM_SHARE_LINK_DEFAULT_EXPIRY_DAYS", default=90
1379
+ )
1380
+
1381
+ # Per-file cap for MS Project (.mpp/.xml) imports. Lower than the 100 MB
1382
+ # attachment ceiling on purpose: unlike attachments (FileField → disk), an
1383
+ # import is read fully into memory and stored base64-encoded in a single
1384
+ # ImportRequest DB row (~+33%), so a 50 MB upload already costs ~67 MB of
1385
+ # memory and row size. 50 MB is the practical MS Project file ceiling
1386
+ # (larger schedules degrade in Project itself). The global
1387
+ # DATA_UPLOAD_MAX_MEMORY_SIZE and nginx client_max_body_size (both 100 MB)
1388
+ # remain the hard edge cap — do not configure this above them.
1389
+ MSPROJECT_MAX_UPLOAD_MB: int = env.int("MSPROJECT_MAX_UPLOAD_MB", default=50)
1390
+
1391
+ # Max number of tasks (also applied to resources and dependency links) a single
1392
+ # MS Project import may contain (#1721). The upload SIZE cap alone is not enough:
1393
+ # a 50 MB MSPDI XML can encode ~1M tasks, and the importer builds one Task object
1394
+ # per task, computes WBS over all of them, then bulk-creates the lot — a
1395
+ # worker-memory / transaction-time DoS well within the byte ceiling. Rejecting
1396
+ # outright (like the risk-CSV importer's MAX_ROWS) is safer than partially
1397
+ # processing. 20k tasks is far above any realistic hand-authored schedule while
1398
+ # bounding the object graph a single request can materialize.
1399
+ MSPROJECT_MAX_ROWS: int = env.int("MSPROJECT_MAX_ROWS", default=20_000)
1400
+
1401
+ # batch_size for the import bulk_create() calls (#1721). Without it, Django emits
1402
+ # one giant multi-row INSERT that pins the whole task/dependency set in a single
1403
+ # statement (and can exceed PostgreSQL parameter limits); chunking bounds the
1404
+ # per-statement memory and parameter count. Shared by the MS Project and Jira
1405
+ # import paths (both persist through msproject.importer.import_project).
1406
+ IMPORT_BULK_BATCH_SIZE: int = env.int("IMPORT_BULK_BATCH_SIZE", default=500)
1407
+
1408
+ # Hard cap on the XML MPXJ streams to stdout when converting a .mpp (#1722). A
1409
+ # decompression-bomb .mpp within the 50 MB upload cap can expand to multi-GB XML;
1410
+ # buffering it unbounded (the old capture_output=True) OOMs the worker. We stream
1411
+ # stdout and abort past this many bytes. 512 MB is generous headroom for a
1412
+ # legitimate 50 MB .mpp (MSPDI XML is ~5-10x the binary) while still bounded.
1413
+ MPXJ_MAX_OUTPUT_MB: int = env.int("MPXJ_MAX_OUTPUT_MB", default=512)
1414
+
1415
+ # Per-file cap for CSV/Excel imports (#743). Much lower than the MS Project
1416
+ # ceiling on purpose: a spreadsheet migration is a hand-maintained sheet, not a
1417
+ # generated plan, and 10 MB is already far past what any real one weighs. The
1418
+ # same base64-in-a-DB-row cost applies (~+33%).
1419
+ CSV_IMPORT_MAX_UPLOAD_MB: int = env.int("CSV_IMPORT_MAX_UPLOAD_MB", default=10)
1420
+
1421
+ # Max data rows a single CSV/Excel import may contain (#743). The byte cap alone
1422
+ # is not enough — a 10 MB CSV can encode well over a million short rows, and the
1423
+ # parser builds one TaskData per row before anything is written. Rows past the
1424
+ # cap are reported back as `truncated_rows` rather than silently dropped.
1425
+ CSV_IMPORT_MAX_ROWS: int = env.int("CSV_IMPORT_MAX_ROWS", default=5_000)
1426
+
1427
+ # The tested-comfortable Schedule size (#3388, administration/sizing.md
1428
+ # "Tested envelope"): whole-project load is measured at 1.85s at 1,000 tasks and
1429
+ # breaches at 2,000 (60s) — a cliff, not a slope. CSV_IMPORT_MAX_ROWS (5,000) is
1430
+ # far above this, so nothing stops an import from landing a project past the
1431
+ # ceiling it cannot comfortably open. This is a *warn, do not block* line, not a
1432
+ # correctness limit — someone on better hardware may legitimately want more —
1433
+ # so it is only ever surfaced as advisory fields on the import preview response
1434
+ # (`exceeds_recommended_ceiling`) and read client-side against the already-loaded
1435
+ # Schedule task count. Configurable because the comfortable figure is
1436
+ # hardware-dependent and is expected to move as #3383 lands.
1437
+ SCHEDULE_TASK_CEILING: int = env.int("TRUEPPM_SCHEDULE_TASK_CEILING", default=1_000)
1438
+
1439
+ # Decompression-bomb ceiling for .xlsx uploads (#743). An .xlsx is a zip of XML:
1440
+ # openpyxl inherits XXE hardening from defusedxml automatically, but nothing in
1441
+ # that stack bounds the *inflated* size, so a 1 MB upload can expand to
1442
+ # gigabytes and OOM the worker. The parser sums the zip central directory's
1443
+ # declared sizes — cheap, and before any XML is touched — and rejects above this.
1444
+ CSV_IMPORT_MAX_UNCOMPRESSED_MB: int = env.int("CSV_IMPORT_MAX_UNCOMPRESSED_MB", default=100)
1445
+
1446
+ # JVM max-heap (-Xmx) for the MPXJ subprocess (#1722). Bounding the JVM heap is a
1447
+ # second line of defense: even if MPXJ tries to build a giant in-memory model
1448
+ # from a bomb file, the JVM dies with an OutOfMemoryError (caught as a non-zero
1449
+ # exit) instead of driving the host into swap.
1450
+ MPXJ_MAX_HEAP_MB: int = env.int("MPXJ_MAX_HEAP_MB", default=512)
1451
+
1452
+ # Max size of an uploaded Jira XML export (#1664). Jira issue-navigator exports
1453
+ # are small relative to .mpp files; the 25 MB default is generous for the minimal
1454
+ # import slice and stays well under the DATA_UPLOAD_MAX_MEMORY_SIZE hard cap.
1455
+ JIRA_IMPORT_MAX_UPLOAD_MB: int = env.int("JIRA_IMPORT_MAX_UPLOAD_MB", default=25)
1456
+
1457
+ # Max number of issues a single Jira XML import may contain (#1721). Same
1458
+ # rationale as MSPROJECT_MAX_ROWS: the byte cap does not bound the derived task
1459
+ # count, and every issue becomes a Task object built and bulk-created through the
1460
+ # shared importer. Rejected outright over the limit.
1461
+ JIRA_IMPORT_MAX_ROWS: int = env.int("JIRA_IMPORT_MAX_ROWS", default=20_000)
1462
+
1463
+ # Max size of an uploaded JSON program seed (ADR-0109, #615). Seeds are bounded
1464
+ # (the largest bundled sample is a few hundred KB); 5 MB is generous headroom
1465
+ # while bounding the memory a single authenticated import request can consume.
1466
+ SEED_MAX_UPLOAD_MB: int = env.int("SEED_MAX_UPLOAD_MB", default=5)
1467
+
1468
+ # Max seed imports one account may have queued or running at once (#2615). The
1469
+ # ``seed_import`` throttle bounds the *rate* of job creation; it does not bound
1470
+ # how many of those jobs are outstanding, because the per-program in-flight
1471
+ # de-dupe in ``enqueue_program_import`` can never match on this path — the
1472
+ # request creates a fresh Program three lines earlier, so a brand-new program
1473
+ # has no prior job to de-dupe against. Without a per-user ceiling, six requests
1474
+ # a minute sustained becomes an unbounded worker backlog of full subtree builds.
1475
+ SEED_IMPORT_MAX_CONCURRENT_JOBS: int = env.int("SEED_IMPORT_MAX_CONCURRENT_JOBS", default=3)
1476
+
1477
+ # ---------------------------------------------------------------------------
1478
+ # Django REST Framework
1479
+ # ---------------------------------------------------------------------------
1480
+
1481
+ # The tightest scoped-throttle tier, shared by the handful of single-shot
1482
+ # endpoints whose per-call cost — one outbound egress probe or one double
1483
+ # CPM + Monte Carlo pass — makes even a modest loop abusive. Kept as one named
1484
+ # constant so the rate lives in a single place rather than a repeated literal.
1485
+ _STRICT_ABUSE_RATE = "6/min"
1486
+
1487
+ # The remaining scoped-throttle tiers, from the general per-user default down
1488
+ # to the credential-shaped endpoints (login, password reset, invite resend).
1489
+ # Named once for the same reason as _STRICT_ABUSE_RATE above.
1490
+ # Trusted-proxy depth, validated here rather than left to DRF (#4020). A negative
1491
+ # value makes DRF's ``addrs[-min(NUM_PROXIES, len(addrs))]`` index from the LEFT —
1492
+ # the client-written end of X-Forwarded-For — or raise IndexError on a one-entry
1493
+ # header, so it is never a working configuration; refuse to boot on it.
1494
+ _NUM_PROXIES = env.int("TRUEPPM_NUM_PROXIES", default=1)
1495
+ if _NUM_PROXIES < 0:
1496
+ raise ImproperlyConfigured(
1497
+ f"TRUEPPM_NUM_PROXIES must be 0 or a positive integer (got {_NUM_PROXIES}). "
1498
+ "It is the number of reverse proxies in front of Django; see "
1499
+ "administration/networking.md."
1500
+ )
1501
+
1502
+ _STANDARD_RATE = "60/min"
1503
+ _MODERATE_RATE = "20/min"
1504
+ _STRICT_RATE = "10/min"
1505
+ _RELAXED_RATE = "120/min"
1506
+ _STRICTEST_RATE = "5/min"
1507
+
1508
+ REST_FRAMEWORK = {
1509
+ # JWT + owner-scoped API token in the base/prod posture (#2248, #2547). The SPA
1510
+ # authenticates with the bearer access token; there is no Django-session login
1511
+ # path for the API and the browsable API is disabled (JSON-only renderer below),
1512
+ # so SessionAuthentication would be an unused second auth surface (and its CSRF
1513
+ # coupling) in production. Dev settings re-add SessionAuthentication so
1514
+ # `client.force_login()` in the test suite can populate request.user.
1515
+ #
1516
+ # OwnerScopedApiTokenAuthentication is listed first (mirroring
1517
+ # McpReadableViewMixin.get_authenticators' ordering): it returns None for any
1518
+ # bearer that isn't one of our tppm_-prefixed tokens, so JWT still gets a turn
1519
+ # for every human/SPA request. It only accepts an owner-scoped Personal Access
1520
+ # Token carrying the legacy:full scope — project/program-scoped integration
1521
+ # tokens and mcp:read-only tokens are rejected here and keep authenticating only
1522
+ # via the narrow surfaces that reference ProjectApiTokenAuthentication directly
1523
+ # (TaskSyncView, the acceptance-result view, the MCP-readable viewsets).
1524
+ "DEFAULT_AUTHENTICATION_CLASSES": [
1525
+ "trueppm_api.apps.projects.authentication.OwnerScopedApiTokenAuthentication",
1526
+ "rest_framework_simplejwt.authentication.JWTAuthentication",
1527
+ ],
1528
+ "DEFAULT_PERMISSION_CLASSES": [
1529
+ "rest_framework.permissions.IsAuthenticated",
1530
+ ],
1531
+ "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
1532
+ "PAGE_SIZE": 50,
1533
+ # API-only service: render JSON, nothing else. DRF's default renderer set
1534
+ # includes BrowsableAPIRenderer, which content-negotiates on the request
1535
+ # Accept header and, for a serializer-less GenericViewSet, calls
1536
+ # get_serializer() to build its HTML form — firing DRF's
1537
+ # `assert self.serializer_class is not None` (an unhandled 500) whenever a
1538
+ # caller sends `Accept: text/html`. A fuzzer (or any browser) trivially
1539
+ # trips this on every serializer-less action. Restricting to JSONRenderer
1540
+ # turns those requests into a clean 406 and removes the whole class of bug
1541
+ # (#2213). Set unconditionally (not DEBUG-gated) because the nightly fuzz
1542
+ # and the test suite run under dev settings with DEBUG=True — the browsable
1543
+ # API is redundant with the drf-spectacular Swagger UI anyway.
1544
+ "DEFAULT_RENDERER_CLASSES": [
1545
+ "rest_framework.renderers.JSONRenderer",
1546
+ ],
1547
+ # Custom handler translates a malformed UUID (bad path segment or query param)
1548
+ # into 404/400 instead of the 500 DRF's default returns for the Django
1549
+ # ValidationError / uuid.UUID ValueError those raise (#2125).
1550
+ "EXCEPTION_HANDLER": "trueppm_api.core.exception_handlers.trueppm_exception_handler",
1551
+ # Tolerant OPTIONS metadata: DRF's default calls get_serializer() while
1552
+ # building action metadata, which asserts (→ 500) on serializer-less
1553
+ # GenericViewSets. This subclass degrades that to empty action metadata (#2229).
1554
+ "DEFAULT_METADATA_CLASS": "trueppm_api.core.metadata.TolerantMetadata",
1555
+ "DEFAULT_FILTER_BACKENDS": [
1556
+ "rest_framework.filters.SearchFilter",
1557
+ "rest_framework.filters.OrderingFilter",
1558
+ ],
1559
+ # Custom AutoSchema documents a 429 response on rate-limited operations (#1333);
1560
+ # throttling is a view attribute invisible to the schema post-processing hooks.
1561
+ "DEFAULT_SCHEMA_CLASS": "trueppm_api.core.openapi.TruePPMAutoSchema",
1562
+ # General default throttle (#1080). Every endpoint that does NOT declare its
1563
+ # own throttle_classes gets the baseline "anon"/"user" rate below — the
1564
+ # general DoS / resource-starvation protection a self-hoster expects. The
1565
+ # ProbeExempt* classes are the stock DRF anon/user throttles with one
1566
+ # override: they never count the unauthenticated /api/v1/health/ and
1567
+ # /api/v1/edition/ probe endpoints, which Kubernetes hits on a tight
1568
+ # liveness/readiness loop (the reason a bare AnonRateThrottle was previously
1569
+ # avoided — now solved by the exemption). A view that sets its own
1570
+ # throttle_classes (login, refresh, monte_carlo, etc. below) REPLACES this
1571
+ # default rather than stacking on top of it, so those endpoints keep only
1572
+ # their specific, stricter scope.
1573
+ "DEFAULT_THROTTLE_CLASSES": [
1574
+ "trueppm_api.core.throttling.ProbeExemptAnonRateThrottle",
1575
+ "trueppm_api.core.throttling.ProbeExemptUserRateThrottle",
1576
+ ],
1577
+ # Trusted-proxy depth for client-IP extraction (#1080). Without this DRF keys
1578
+ # the anon throttle on the client-supplied X-Forwarded-For header verbatim, so
1579
+ # an attacker could rotate XFF to mint a fresh anon identity per request and
1580
+ # bypass the anon limit entirely. NUM_PROXIES tells DRF how many trusted proxies
1581
+ # sit in front of the app (the standard Helm chart has a single ingress → 1), so
1582
+ # it reads the real client IP from a trusted position and ignores attacker-
1583
+ # prepended entries. Set to the deployment's actual proxy depth via env; 0 means
1584
+ # "no proxy, use REMOTE_ADDR". The authenticated "user" scope keys on the account
1585
+ # id and is unaffected.
1586
+ #
1587
+ # Wrong in EITHER direction is silent (#4020): too low collapses every per-IP
1588
+ # throttle into one shared bucket, too high lets a client pick its own bucket
1589
+ # via X-Forwarded-For. Nothing here can know the real topology, so the check is
1590
+ # a readback — GET /api/v1/health/system/ → security.client_address shows the
1591
+ # address THIS request was keyed on. See administration/networking.md.
1592
+ "NUM_PROXIES": _NUM_PROXIES,
1593
+ "DEFAULT_THROTTLE_RATES": {
1594
+ # General default rates for the ProbeExempt* classes above. Env-tunable
1595
+ # so operators can tighten or loosen the baseline without a rebuild.
1596
+ # "anon" bounds unauthenticated traffic per client IP; "user" bounds an
1597
+ # authenticated account. Both are generous enough that a normal
1598
+ # interactive client never trips them.
1599
+ "anon": env("TRUEPPM_THROTTLE_ANON_RATE", default=_STANDARD_RATE),
1600
+ "user": env("TRUEPPM_THROTTLE_USER_RATE", default="1000/min"),
1601
+ # /api/v1/readyz (#2820). Unlike /health/ and /edition/, readyz is not
1602
+ # free — every call does a real database round-trip and a real cache
1603
+ # round-trip — and it used to be FULLY exempt from the shared throttle
1604
+ # (the same exemption /health/ and /edition/ still get), which meant an
1605
+ # unauthenticated caller who found the pod IP directly (the probe path
1606
+ # bypasses the Ingress) could drive unbounded database load with no rate
1607
+ # limit at all. A dedicated scope, not the shared "anon" bucket: readyz
1608
+ # traffic comes from kubelet hitting the pod IP directly rather than
1609
+ # through the Ingress, so its source IP is the node, not an end user's,
1610
+ # and sharing "anon" would let a legitimate probe loop compete with real
1611
+ # anonymous traffic in both directions. The default is generous enough
1612
+ # that no documented probe configuration comes close to tripping it —
1613
+ # see administration/probes.md for the numbers this assumes — while
1614
+ # still bounding an outright flood from an unauthenticated source.
1615
+ "readyz": env("TRUEPPM_THROTTLE_READYZ_RATE", default="2000/min"),
1616
+ "login": _STRICT_RATE,
1617
+ # Per-account login lockout (#1717). The "login" scope above keys on the
1618
+ # client IP, so it only bounds guesses per source address; a distributed
1619
+ # credential-stuffing attack from a rotating IP pool gets the full per-IP
1620
+ # allowance from every fresh IP and is unbounded in aggregate against one
1621
+ # account. This scope keys on the (hashed, normalized) submitted username
1622
+ # instead, so attempts against a single account are capped no matter how
1623
+ # many IPs participate. It is STACKED with "login" — both throttles apply
1624
+ # to the login view. Env-tunable so an operator can tighten or loosen it.
1625
+ # 5/min still leaves room for a human who fat-fingers a password a few
1626
+ # times, while making cross-IP account targeting expensive.
1627
+ "login_account": env("TRUEPPM_THROTTLE_LOGIN_ACCOUNT_RATE", default=_STRICTEST_RATE),
1628
+ # Self-service password reset (#765, ADR-0209). Covers BOTH the request
1629
+ # endpoint (which emails a reset link) and the confirm endpoint (which sets
1630
+ # the new password). 5/min per IP bounds two abuses at once: email-bombing a
1631
+ # victim by looping the request endpoint, and timing/enumeration probing of
1632
+ # which addresses have accounts (the request endpoint returns an identical
1633
+ # 200 either way, so the throttle is the primary enumeration defense — see
1634
+ # ADR-0209). 5/min still leaves ample room for a human who mistypes, resends,
1635
+ # and retries a confirm a couple of times.
1636
+ "password_reset": _STRICTEST_RATE,
1637
+ # JWT refresh (#814). 60/min is loose enough that any realistic
1638
+ # web/mobile client (5-minute access-token TTL → ~12 refreshes/hour)
1639
+ # never trips it, but tight enough that a stolen/leaked refresh token
1640
+ # cannot be exchanged for access tokens at unbounded rate.
1641
+ "refresh": _STANDARD_RATE,
1642
+ # User typeahead for member invite (#815). Tight enough that a single
1643
+ # account cannot bulk-scrape the workspace user list, loose enough that
1644
+ # interactive typeahead (debounced, >=2 chars) never trips it.
1645
+ "user_search": _STANDARD_RATE,
1646
+ # ⌘K global Epic/Story omni-search (ADR-0508 D4, #2103). A debounced,
1647
+ # >=2-char typeahead over the caller's own membership; the 60/min bound
1648
+ # matches user_search — snug enough that a scripted loop cannot bulk-map
1649
+ # every epic/story title the account can see, loose enough that live typing
1650
+ # never trips it.
1651
+ "omni_search": env("TRUEPPM_THROTTLE_OMNI_SEARCH_RATE", default=_STANDARD_RATE),
1652
+ # Project/program key resolver + key suggestion (ADR-1237 §5-§6). One
1653
+ # per-user bucket for both: the resolver runs once per cold key-URL load
1654
+ # and the suggestion once per debounced keystroke on a create form, so
1655
+ # 120/min never trips a person — and it bounds using either endpoint as a
1656
+ # scan engine over the key namespace (the accepted existence oracle).
1657
+ "resolve": env("TRUEPPM_THROTTLE_RESOLVE_RATE", default=_RELAXED_RATE),
1658
+ # WebSocket connection tickets (#818, ADR-0141). One ticket is minted per
1659
+ # socket open (and per reconnect, since tickets are single-use); 120/min
1660
+ # covers aggressive reconnect storms without letting one account flood
1661
+ # Redis with 30-second ticket keys.
1662
+ "ws_ticket": _RELAXED_RATE,
1663
+ # Invite-email resend (#969, ADR-0149). Each request re-issues a token and
1664
+ # re-queues an email; 5/min per admin bounds email-bomb abuse while leaving
1665
+ # ample room for a human clicking "Resend" on a handful of stuck invites.
1666
+ # "Resend all" is one request → one bucket hit, so it cannot be looped past
1667
+ # the cap.
1668
+ "invite_resend": _STRICTEST_RATE,
1669
+ # Workspace SMTP config (#712, ADR-0213). Writes re-open a candidate SMTP
1670
+ # connection (validate-before-persist); "probe" covers the two outbound
1671
+ # amplifiers — send-test (opens a real SMTP socket) and the deliverability
1672
+ # health check (fires live DNS TXT lookups). Both are blind-SSRF / egress
1673
+ # oracles if unbounded, so cap them tightly (security review H3). An admin
1674
+ # configuring mail never trips these; a script probing hosts does.
1675
+ "email_settings": "12/min",
1676
+ "email_settings_probe": _STRICT_ABUSE_RATE,
1677
+ # SSO domain discovery (ADR-0187). Unauthenticated; reveals only whether a
1678
+ # *domain* uses SSO (never whether an account exists), but the rate still
1679
+ # bounds bulk domain-probing. Loose enough for the interactive login page.
1680
+ "oidc_discover": "30/min",
1681
+ # SSO login start (ADR-0187). Each hit mints a single-use state/PKCE/nonce
1682
+ # cache entry and 302s to the IdP; this also implicitly bounds the callback
1683
+ # (every callback requires a state minted here). Snug but ample for humans.
1684
+ "oidc_login": _MODERATE_RATE,
1685
+ # SSO callback (ADR-0187). A token-issuing endpoint; the implicit bound via
1686
+ # oidc_login already caps legitimate use, but an explicit cap blunts a flood
1687
+ # of forged callbacks (each fails fast at the browser-state-cookie check).
1688
+ "oidc_callback": "30/min",
1689
+ # Admin "Test connection" probe (ADR-0517 §3.4). Each call triggers
1690
+ # server-side egress (OIDC discovery + JWKS, or the GitHub API), so it needs
1691
+ # an abuse bound so an admin cannot drive unbounded outbound requests.
1692
+ # 20/min is ample for a human wiring up a provider.
1693
+ "sso_test_connection": _MODERATE_RATE,
1694
+ # SSO provider create/update/delete (#3552, ADR-1120). These views previously
1695
+ # declared no throttle and fell back to the global "user" rate of 1000/min. Each
1696
+ # accepted write now also appends an AuditEvent row, and the OSS audit table has
1697
+ # no retention or pruning — so a scripted `enabled` toggle would append at the
1698
+ # request ceiling, every row a *genuine* change that the no-op suppression cannot
1699
+ # filter. Note who pays: in a self-hosted install the workspace Admin driving the
1700
+ # loop and the operator whose disk fills are frequently different people. Applied
1701
+ # to the mutating methods only (see ``_SsoProviderWriteThrottle``) — the
1702
+ # collection GET backs the admin page and must not be capped at a write rate.
1703
+ "sso_provider_write": _MODERATE_RATE,
1704
+ # Integration-credential and Git webhook-secret endpoints (#1551). Covers the
1705
+ # per-user credential store (connect/rotate/revoke/read) and the project-admin
1706
+ # Git-automation config + secret-rotation views. Each of these mints, returns,
1707
+ # or reveals the presence of a plaintext credential/secret, so they need the
1708
+ # same brute-force/abuse bound the other credential-adjacent endpoints already
1709
+ # carry. 10/min is snug for a human wiring up integrations yet far below any
1710
+ # automated rotation-scraping rate.
1711
+ "credential_rotate": _STRICT_RATE,
1712
+ # Manual external-source pull trigger (POST /me/connections/{source}/sync/,
1713
+ # ADR-0097 §4, #1419). The service-layer 60 s per-connection cooldown is the
1714
+ # primary spacing control; this scope is the coarse abuse bound so a scripted
1715
+ # loop cannot spam the outbox faster than a human ever would. 20/min sits
1716
+ # above the cooldown's ~1/min steady state with headroom for multiple
1717
+ # connected sources.
1718
+ "external_sync": _MODERATE_RATE,
1719
+ # Synchronous Monte Carlo simulation (#1552). run_monte_carlo executes an
1720
+ # expensive (up to MC_SIMULATION_CAP iterations, caller-controlled
1721
+ # n_simulations) simulation inline in the request/response cycle, gated only
1722
+ # by project membership. Without a rate cap any single member can loop the
1723
+ # endpoint to exhaust CPU. 10/min leaves ample headroom for a human tuning
1724
+ # estimates and re-running the forecast a handful of times, while blunting a
1725
+ # scripted resource-exhaustion flood.
1726
+ "monte_carlo": _STRICT_RATE,
1727
+ # Non-mutating Monte Carlo what-if (#993). The what-if endpoint runs *two*
1728
+ # CPM passes and *two* Monte Carlo simulations per call (baseline vs the
1729
+ # perturbed schedule, so the delta isolates the perturbation) — roughly
1730
+ # double the CPU of a single run_monte_carlo — so it carries a tighter cap
1731
+ # than the plain "monte_carlo" scope. 6/min still leaves ample room for a
1732
+ # human (or an MCP agent) exploring a handful of "what if I slip this task"
1733
+ # scenarios while blunting a scripted resource-exhaustion flood.
1734
+ "monte_carlo_whatif": _STRICT_ABUSE_RATE,
1735
+ # Burn chart / flow metrics reads (#3581). ProjectBurnView.get and
1736
+ # FlowMetricsView.get both reconstruct their series from HistoricalTask
1737
+ # snapshots — the floor cost scales with project history (#3579) even
1738
+ # though #3566 bounds the window per request. Either endpoint is reachable
1739
+ # by any project member (down to Viewer), and both were left on the
1740
+ # general "user" default of 1000/min before this scope existed, which is
1741
+ # meaningful amplification against a shared database. Shared bucket
1742
+ # between the two views (same cost class, same table) rather than a
1743
+ # scope each. 60/min is far above what the Reports page generates —
1744
+ # useBurnChart has a 5-minute staleTime.
1745
+ "burn": env("TRUEPPM_THROTTLE_BURN_RATE", default=_STANDARD_RATE),
1746
+ # On-read CPM derivation (#2839). GET /projects/<pk>/schedule/derivation/ runs
1747
+ # a full schedule() pass inside derive_value on every CPM-quantity call — the
1748
+ # compute class #1808 bounded on its Monte Carlo siblings but missed here, so
1749
+ # it fell through to the general 1000/min account default. One CPM pass is
1750
+ # far cheaper than a simulation, so this sits at the general read tier rather
1751
+ # than the 10/min monte_carlo tier: a person clicking through the "why" of a
1752
+ # dozen values never trips it, a scripted loop is held to one pass a second.
1753
+ "cpm_on_read": env("TRUEPPM_THROTTLE_CPM_ON_READ_RATE", default=_STANDARD_RATE),
1754
+ # On-read merged program CPM (#2839), GET /programs/<pk>/schedule/. Same tier
1755
+ # as cpm_on_read: a 5,000-task merged pass measured ~470 ms, MORE than the
1756
+ # derivation's single-project pass, so it must not be laxer. 60/min holds a
1757
+ # loop to ~0.5 core (vs ~7.8 at the 1000/min default). Per account, so tabs
1758
+ # share it; one open tab can send up to 200/min (shared 300 ms debounce) in
1759
+ # an edit storm, and on a 429 the page keeps its last chart. See
1760
+ # ProgramScheduleThrottle.
1761
+ "program_schedule": env("TRUEPPM_THROTTLE_PROGRAM_SCHEDULE_RATE", default=_STANDARD_RATE),
1762
+ # Bundled-sample demo loader (#2402). POST /programs/load-sample/ is the
1763
+ # most expensive single write in the API: it hard-deletes the caller's
1764
+ # prior program with the same code (memberships -> projects -> cascade)
1765
+ # and rebuilds the whole fixture — three projects, per-row saves with
1766
+ # simple_history rows, the v2 replay timeline, and a forecast backfill —
1767
+ # synchronously inside one transaction. The default sample measures ~3.4 s
1768
+ # and ~3,700 sequential DB round-trips locally, several times that on
1769
+ # modest hardware. Left on the general "user" default (1000/min) one
1770
+ # authenticated member could drive a thousand full teardown-and-rebuilds a
1771
+ # minute. 6/min matches monte_carlo_whatif — the same "one call is costly
1772
+ # enough that even a modest loop is abusive" tier — and is ample for a
1773
+ # human clicking "Load demo data" and trying a couple of samples.
1774
+ "sample_load": env("TRUEPPM_THROTTLE_SAMPLE_LOAD_RATE", default=_STRICT_ABUSE_RATE),
1775
+ # Caller-supplied seed import (#2402). POST /programs/import/ runs the
1776
+ # *same* importer as load-sample on a payload the caller sizes, under the
1777
+ # same IsAuthenticated-only gate, so it is the cheaper of the two
1778
+ # exhaustion vectors and needs the same bound. A separate bucket from
1779
+ # sample_load so loading a demo never spends a user's real import
1780
+ # allowance — importing a handful of program bundles in a sitting is
1781
+ # normal; sixty a minute is not.
1782
+ "seed_import": env("TRUEPPM_THROTTLE_SEED_IMPORT_RATE", default=_STRICT_ABUSE_RATE),
1783
+ # Seed dry run (#2418). POST /programs/import/validate/ parses and
1784
+ # JSON-Schema-checks a caller-sized document but writes nothing, so it
1785
+ # costs CPU proportional to the payload and none of the teardown-rebuild
1786
+ # that makes seed_import expensive — a looser bound is right. It gets its
1787
+ # own bucket rather than sharing seed_import's for a usability reason as
1788
+ # much as a cost one: the whole point of a dry run is to iterate on a file
1789
+ # until it passes, and spending the real import allowance to do that would
1790
+ # lock an operator out of the import their fixed file just earned.
1791
+ "seed_validate": env("TRUEPPM_THROTTLE_SEED_VALIDATE_RATE", default=_MODERATE_RATE),
1792
+ # Bundled-fixture download (#2490). GET /programs/samples/{key}/download/
1793
+ # streams a file off disk (<= ~78 KB) and touches no table, so it is orders
1794
+ # of magnitude cheaper than sample_load and gets a correspondingly generous
1795
+ # bound. It gets its own bucket rather than sharing sample_load's for an
1796
+ # intent reason as much as a cost one: auditing all four fixtures before
1797
+ # importing is the behavior this feature exists to encourage, and it must
1798
+ # not spend the allowance a user needs to actually load a demo.
1799
+ # Not unthrottled: it serves a file keyed by a client-supplied string, so
1800
+ # even with the registry lookup closing traversal it would otherwise be a
1801
+ # bandwidth amplifier and a free oracle for enumerating valid keys — and
1802
+ # the scope becomes load-bearing the moment a deployment allowlists the
1803
+ # route for anonymous access (ADR-0685).
1804
+ "sample_download": env("TRUEPPM_THROTTLE_SAMPLE_DOWNLOAD_RATE", default=_STANDARD_RATE),
1805
+ # MCP read surface per-token rate limits (#1808 finding F4). These bound
1806
+ # token-authenticated reads on any McpReadableViewMixin view ONLY — human
1807
+ # JWT/Session traffic on the same views is unaffected (the throttles'
1808
+ # get_cache_key returns None for non-token callers, so DRF skips them; see
1809
+ # apps/access/throttles.py). "mcp_read" is the baseline cap every
1810
+ # MCP-readable view applies per token; "mcp_read_compute" is the tighter
1811
+ # bucket the compute-heavy reads (whatif, monte-carlo/latest, forecast,
1812
+ # sprint-forecast, schedule derivation #2839, and the program schedule
1813
+ # action) STACK on top, since each triggers a CPM or Monte Carlo
1814
+ # recompute per call and a read-only token loop must not burn arbitrary CPU.
1815
+ # Env-tunable so an operator can widen the budget for a trusted agent fleet
1816
+ # or tighten it under load. OSS ships basic per-token limits; per-agent
1817
+ # budgets and anomaly auto-suspend are Phase 4 / Enterprise.
1818
+ "mcp_read": env("TRUEPPM_THROTTLE_MCP_READ_RATE", default=_RELAXED_RATE),
1819
+ "mcp_read_compute": env("TRUEPPM_THROTTLE_MCP_READ_COMPUTE_RATE", default="12/min"),
1820
+ # Public board share-link endpoints (#283, ADR-0245). "share_mint" bounds
1821
+ # how fast one Admin account can spray share links; "share_access" bounds
1822
+ # scraping/abuse of the unauthenticated public board endpoint (the 256-bit
1823
+ # token is already non-enumerable — this stops a leaked/viral link becoming
1824
+ # an unthrottled load source on a self-hosted box, per Omar's VoC concern).
1825
+ "share_mint": env("TRUEPPM_THROTTLE_SHARE_MINT_RATE", default=_MODERATE_RATE),
1826
+ "share_access": env("TRUEPPM_THROTTLE_SHARE_ACCESS_RATE", default=_STANDARD_RATE),
1827
+ # Telemetry test-export probe (#2110). An admin-only button that opens one
1828
+ # outbound canary/reachability probe to the configured OTLP collector;
1829
+ # scoped-throttled so it can't be used to hammer the collector.
1830
+ "telemetry_test": env("TRUEPPM_THROTTLE_TELEMETRY_TEST_RATE", default=_STRICT_ABUSE_RATE),
1831
+ # Inbound Git-event webhook receiver, per client IP (#2881,
1832
+ # integrations.throttles.GitWebhookIpThrottle). The receiver's other
1833
+ # throttle keys on the project UUID *from the URL*, so rotating that UUID
1834
+ # minted a fresh bucket per request and the endpoint was effectively
1835
+ # unbounded for an anonymous caller. Deliberately loose: a provider delivers
1836
+ # from a small pool of shared egress addresses, so every repo on the install
1837
+ # lands in ONE bucket and a tight limit would throttle honest traffic across
1838
+ # unrelated projects. 600/min (10/s) sits far above any realistic aggregate
1839
+ # PR/MR event rate while still bounding a single source. Env-tunable for an
1840
+ # install with unusually heavy Git traffic.
1841
+ "git_webhook_ip": env("TRUEPPM_THROTTLE_GIT_WEBHOOK_IP_RATE", default="600/min"),
1842
+ },
1843
+ }
1844
+
1845
+ # Global rate-limiting kill switch (ADR-0604): when an operator has disabled rate
1846
+ # limiting (with acknowledgment — see RATE_LIMIT_ENABLED above), neutralize every
1847
+ # DRF SimpleRateThrottle-family throttle in one place — drop the default throttle
1848
+ # classes and set every named scope's rate to None (DRF treats rate=None as "do not
1849
+ # throttle"). This covers all present and future scopes (anon, user, login,
1850
+ # monte_carlo, mcp_read, share_*, …) with no per-class edits. The custom Redis
1851
+ # BaseThrottle classes don't read these rates, so they bypass at request time via
1852
+ # the bypass_when_disabled decorator instead.
1853
+ if not RATE_LIMIT_ENABLED:
1854
+ apply_rate_limit_disable(REST_FRAMEWORK)
1855
+
1856
+ # MCP administration controls (#2021, ADR-0497).
1857
+ #
1858
+ # Instance-wide MCP kill switch. Default True preserves backward compatibility —
1859
+ # existing deployments keep agent (mcp:read token) access. Set False to deny every
1860
+ # MCP-token read at the McpReadableViewMixin chokepoint (403), even though the
1861
+ # token exists; human JWT/session auth on the same viewsets is unaffected. This is
1862
+ # the operator lever for "no agent access on this instance, period."
1863
+ TRUEPPM_MCP_ENABLED: bool = env.bool("TRUEPPM_MCP_ENABLED", default=True)
1864
+
1865
+ # Whether a member project's ADR-0678 agent-read opt-out withholds a PROGRAM-LEVEL
1866
+ # BULK EXPORT from an agent (`mcp:read`) token — the sync JSON seed and the async
1867
+ # .tar.gz bundle both carry every member project's data verbatim (#3014).
1868
+ #
1869
+ # "withhold" (default) — an agent token is refused (403) when ANY member project
1870
+ # has opted out. The cascade's rule is that a team's "no" is final, and a
1871
+ # bulk export is the widest possible read of the data it covers.
1872
+ # "allow" — pre-0.4 behavior: the export is treated as a program-level artifact
1873
+ # governed by the program's own `mcp_enabled` alone.
1874
+ #
1875
+ # OPERATOR-LEVEL ON PURPOSE, and the reason is the whole design (see
1876
+ # projects/mcp_settings.py, which refuses an `mcp_override_policy` field outright).
1877
+ # A workspace- or program-scoped toggle for this would be a *tenant* scope granting
1878
+ # over a team's denial — precisely the "consent that only an admin can grant or
1879
+ # revoke on the team's behalf is consent in name only" objection (#2415) the opt-out
1880
+ # exists to answer. An operator switch is the same kind of lever as
1881
+ # TRUEPPM_MCP_ENABLED above: the person running the server, not a scope above the
1882
+ # team inside it. Do not promote this to a model field.
1883
+ #
1884
+ # Humans are never affected by either value.
1885
+ TRUEPPM_MCP_PROGRAM_EXPORT_POLICY: str = env.str(
1886
+ "TRUEPPM_MCP_PROGRAM_EXPORT_POLICY", default="withhold"
1887
+ )
1888
+
1889
+ # Env-overridable safety caps for API tokens and inbound task-sync. Each keeps its
1890
+ # historical hardcoded default; surfacing them as settings lets an operator tune the
1891
+ # blast-radius/limits per deployment without a code change. Read at request time so
1892
+ # override_settings (and a live env change) takes effect.
1893
+ #
1894
+ # Max ACTIVE personal access tokens per user (ADR-0214). Bounds the blast radius of
1895
+ # a leaked account and keeps the /me/api-tokens/ list navigable.
1896
+ TRUEPPM_MAX_PERSONAL_ACCESS_TOKENS: int = env.int("TRUEPPM_MAX_PERSONAL_ACCESS_TOKENS", default=10)
1897
+ # Max pinned projects + programs per user (#2390, ADR-0627). A pin is a personal
1898
+ # wayfinding shortcut, so the cap exists to bound the row count and keep the rail
1899
+ # and /me/pinned/ navigable — not as a security control. Exceeding it is a 400
1900
+ # carrying `code: "pin_limit_reached"` so the client can say which limit was hit.
1901
+ TRUEPPM_MAX_USER_PINS: int = env.int("TRUEPPM_MAX_USER_PINS", default=100)
1902
+ # Token-issuance rate cap (req/min/user) on the mint endpoint (ADR-0068). Caps the
1903
+ # blast radius of a compromised admin session even when RBAC is satisfied.
1904
+ TRUEPPM_TOKEN_ISSUANCE_PER_MINUTE: int = env.int("TRUEPPM_TOKEN_ISSUANCE_PER_MINUTE", default=5)
1905
+ # Token-revocation rate cap (req/min/user) on the revoke endpoint (#2878). Set well
1906
+ # above the issuance cap deliberately: revocation is the containment action, and a
1907
+ # user cutting off a leak may legitimately revoke every token they hold in one burst.
1908
+ TRUEPPM_TOKEN_REVOCATION_PER_MINUTE: int = env.int(
1909
+ "TRUEPPM_TOKEN_REVOCATION_PER_MINUTE", default=30
1910
+ )
1911
+ # Inbound task-sync per-project rate caps (ADR-0068). Steady-state limit applies
1912
+ # after the 60-minute backfill window; the higher backfill limit applies to a
1913
+ # freshly minted token so a large first import gets headroom.
1914
+ TRUEPPM_TASK_SYNC_STEADY_STATE_LIMIT: int = env.int(
1915
+ "TRUEPPM_TASK_SYNC_STEADY_STATE_LIMIT", default=100
1916
+ )
1917
+ TRUEPPM_TASK_SYNC_BACKFILL_LIMIT: int = env.int("TRUEPPM_TASK_SYNC_BACKFILL_LIMIT", default=1000)
1918
+
1919
+ # Public read-only sharing — board (#283, ADR-0245) and schedule (#1486, ADR-0265).
1920
+ # The instance kill switch governs BOTH share kinds: when False, minting new links
1921
+ # (403) and every public share endpoint (uniform 404, retroactively disabling
1922
+ # existing links) are turned off org-wide — the operator/PMO off lever for the
1923
+ # unauthenticated egress surface. One lever, one mental model: a deployment can
1924
+ # never leak schedules while "board sharing" reads off. Default True is safe because
1925
+ # no data is exposed until an Owner/Admin explicitly mints a link. The SHARE_*_MAX_*
1926
+ # caps bound each public snapshot payload (the projection flags "truncated" past the
1927
+ # cap rather than silently dropping content). The env var keeps its #283 name for
1928
+ # back-compat with deployments that already set it.
1929
+ TRUEPPM_PUBLIC_BOARD_SHARING_ENABLED = env.bool(
1930
+ "TRUEPPM_PUBLIC_BOARD_SHARING_ENABLED", default=True
1931
+ )
1932
+ SHARE_BOARD_MAX_CARDS = env.int("TRUEPPM_SHARE_BOARD_MAX_CARDS", default=1000)
1933
+ SHARE_SCHEDULE_MAX_TASKS = env.int("TRUEPPM_SHARE_SCHEDULE_MAX_TASKS", default=1000)
1934
+
1935
+ # ---------------------------------------------------------------------------
1936
+ # django-allauth
1937
+ # ---------------------------------------------------------------------------
1938
+
1939
+ SITE_ID = 1
1940
+ # "none" is safe *because allauth's self-service account flows are never reached*
1941
+ # (#2248): we do NOT mount `allauth.urls`, so there is no allauth signup/login/
1942
+ # email-confirmation surface. Account creation flows only through (a) the invite
1943
+ # path — the email is fixed to the invite, not user-chosen — and (b) SSO, which
1944
+ # enforces its own `email_verified is True` + admin domain allow-list gate in
1945
+ # `apps.sso.services.resolve_user`. Do NOT mount `allauth.urls` (which would open
1946
+ # self-service registration) without first flipping this to a real verification
1947
+ # policy — otherwise unverified self-service signup would be silently enabled.
1948
+ ACCOUNT_EMAIL_VERIFICATION = "none"
1949
+
1950
+ # ModelBackend FIRST so username/password login keeps working exactly as before
1951
+ # (ADR-0517 §1). The allauth backend is appended only so allauth's own account
1952
+ # machinery resolves; our SSO views never call allauth's login() — they mint the
1953
+ # simplejwt cookie session via the JWT bridge (apps/sso/views.py).
1954
+ AUTHENTICATION_BACKENDS = [
1955
+ "django.contrib.auth.backends.ModelBackend",
1956
+ "allauth.account.auth_backends.AuthenticationBackend",
1957
+ ]
1958
+
1959
+ # SOCIALACCOUNT_PROVIDERS is used purely as a per-provider CONFIG REGISTRY — it
1960
+ # pins the server-fixed OSS scopes and PKCE. Per-provider APPS are NOT hardcoded
1961
+ # here: each configured IdP lives as a DB `SocialApp` row (provider_id == our
1962
+ # registry slug) written through the admin API, so the APPS list stays empty
1963
+ # (ADR-0517 §1). We deliberately do NOT set LOGIN_REDIRECT_URL or
1964
+ # SOCIALACCOUNT_ADAPTER — our own views own the flow, allauth is a library.
1965
+ SOCIALACCOUNT_PROVIDERS = {
1966
+ "openid_connect": {
1967
+ "APPS": [],
1968
+ "OAUTH_PKCE_ENABLED": True,
1969
+ # Server-fixed in OSS — `groups`/custom claims are an Enterprise widening.
1970
+ "SCOPE": ["openid", "email", "profile"],
1971
+ },
1972
+ "github": {
1973
+ # OIDC-equivalent scopes: identity via GET /user + /user/emails.
1974
+ "SCOPE": ["read:user", "user:email"],
1975
+ },
1976
+ }
1977
+
1978
+ # ---------------------------------------------------------------------------
1979
+ # Outbound email transport (#639 read-only status page, #764)
1980
+ # ---------------------------------------------------------------------------
1981
+ # Bind Django's standard EMAIL_* settings from the environment so operators
1982
+ # configure SMTP via container env vars / Helm values with no settings override.
1983
+ # The Beat-driven notification drain runs on the api, celery, and celery-beat
1984
+ # workloads, so all three must receive the same env. EMAIL_HOST_PASSWORD is read
1985
+ # from the environment but never exposed by the API or logged (#639).
1986
+ #
1987
+ # EMAIL_HOST defaults to "" (not Django's implicit "localhost") so an
1988
+ # unconfigured deployment reports "Not configured" on the read-only status page
1989
+ # and the drain skips sending, rather than silently attempting localhost:25.
1990
+ EMAIL_BACKEND = env("EMAIL_BACKEND", default="django.core.mail.backends.smtp.EmailBackend")
1991
+ EMAIL_HOST = env("EMAIL_HOST", default="")
1992
+ EMAIL_PORT = env.int("EMAIL_PORT", default=587)
1993
+ EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True)
1994
+ EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", default=False)
1995
+ EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="")
1996
+ EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="")
1997
+ EMAIL_TIMEOUT = env.int("EMAIL_TIMEOUT", default=10)
1998
+ DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="notifications@trueppm.local")
1999
+
2000
+ # ---------------------------------------------------------------------------
2001
+ # drf-spectacular (OpenAPI)
2002
+ # ---------------------------------------------------------------------------
2003
+
2004
+ # ---------------------------------------------------------------------------
2005
+ # Object change history (django-simple-history)
2006
+ # ---------------------------------------------------------------------------
2007
+
2008
+ # Retention window in days. Records older than this are purged nightly by the
2009
+ # Celery beat task in trueppm_api.apps.history.tasks.
2010
+ # Set to None to disable automatic purging (enterprise unlimited retention).
2011
+ # Operator env var standardized on the TRUEPPM_ prefix pre-0.3 (#1325) so all
2012
+ # tunables share one namespace in ConfigMaps/Secrets; the legacy bare name is read
2013
+ # as a fallback so deploys that already set it keep working until they migrate.
2014
+ HISTORY_RETENTION_DAYS: int | None = env.int(
2015
+ "TRUEPPM_HISTORY_RETENTION_DAYS", default=env.int("HISTORY_RETENTION_DAYS", default=90)
2016
+ )
2017
+
2018
+ # ---------------------------------------------------------------------------
2019
+ # Signal-privacy ceiling-raise ratification (ADR-0104 Amendment A, #930)
2020
+ # ---------------------------------------------------------------------------
2021
+
2022
+ # How long a team-ratification proposal to raise a signal's ceiling stays OPEN
2023
+ # before it lazily expires UNRATIFIED (the ceiling is left unchanged — silence is
2024
+ # never consent for widening a team signal's exposure). 72h is long enough for an
2025
+ # async team yet short enough not to stall a pending share. Evaluated lazily on
2026
+ # read/vote/propose; no Celery/Beat sweep is required.
2027
+ SIGNAL_CEILING_PROPOSAL_TTL_HOURS: int = env.int("SIGNAL_CEILING_PROPOSAL_TTL_HOURS", default=72)
2028
+
2029
+ # ---------------------------------------------------------------------------
2030
+ # Task run retention (trueppm_api.apps.taskruns)
2031
+ # ---------------------------------------------------------------------------
2032
+
2033
+ # Retention window in days for completed/failed/cancelled TaskRun records.
2034
+ # Set to None to disable automatic purging. Env var standardized on the TRUEPPM_
2035
+ # prefix pre-0.3 (#1325); legacy bare name read as a fallback.
2036
+ TASK_RUN_RETENTION_DAYS: int | None = env.int(
2037
+ "TRUEPPM_TASK_RUN_RETENTION_DAYS", default=env.int("TASK_RUN_RETENTION_DAYS", default=30)
2038
+ )
2039
+
2040
+ # ---------------------------------------------------------------------------
2041
+ # Time tracking (trueppm_api.apps.timetracking, ADR-0185)
2042
+ # ---------------------------------------------------------------------------
2043
+
2044
+ # Stale-timer ceiling: a running timer that exceeds this many minutes is flagged
2045
+ # ``stale`` on GET /me/timer/, and ``stop`` caps the logged minutes at it rather
2046
+ # than the raw elapsed (so a timer left running over a weekend logs the ceiling,
2047
+ # not thousands of minutes). Default 600 = 10 h.
2048
+ TIMETRACKING_TIMER_MAX_MINUTES: int = env.int("TIMETRACKING_TIMER_MAX_MINUTES", default=600)
2049
+
2050
+ # Manual-entry backdate window: a manual ``entry_date`` is rejected if it is in the
2051
+ # future or older than this many days, so a contributor can fill in last week but
2052
+ # not rewrite arbitrary history. Default 60 days.
2053
+ TIMETRACKING_BACKDATE_DAYS: int = env.int("TIMETRACKING_BACKDATE_DAYS", default=60)
2054
+
2055
+ # ---------------------------------------------------------------------------
2056
+ # Dead-letter inspector bulk actions (ADR-0210)
2057
+ # ---------------------------------------------------------------------------
2058
+
2059
+ # Upper bound on how many parked tasks one `requeue_all` / `drop_all` call may act
2060
+ # on, oldest-first; the response's `capped` flag tells the operator more remain
2061
+ # (ADR-0210 §4). Documented as tunable in docs → Administration → System Health, and
2062
+ # for its whole life it was not: `scheduling/views.py` read it with
2063
+ # `getattr(settings, "FAILED_TASK_BULK_ACTION_MAX", 500)` and no settings module
2064
+ # bound it, so the fallback always won and setting the env var did nothing (#2880 —
2065
+ # the same class as TRUEPPM_INTEGRATION_ALLOWED_HOSTS in #2860). The env var is
2066
+ # TRUEPPM_-prefixed per #1355; the setting name keeps its established spelling
2067
+ # because the view constant and its tests already reference it.
2068
+ FAILED_TASK_BULK_ACTION_MAX: int = env.int("TRUEPPM_FAILED_TASK_BULK_ACTION_MAX", default=500)
2069
+
2070
+ # ---------------------------------------------------------------------------
2071
+ # Outbox retention + Beat liveness (ADR-0081)
2072
+ # ---------------------------------------------------------------------------
2073
+
2074
+ # Retention window in days for terminal (SUCCESS/FAILED) WebhookDelivery rows.
2075
+ # Set to None to disable the nightly purge. New tunables are TRUEPPM_-prefixed
2076
+ # for env-var namespacing in shared ConfigMaps/Secrets (ADR-0081 §D).
2077
+ TRUEPPM_WEBHOOK_RETENTION_DAYS: int | None = env.int("TRUEPPM_WEBHOOK_RETENTION_DAYS", default=7)
2078
+
2079
+ # Retention window in days for terminal (DONE/DEAD) ImportRequest rows. These
2080
+ # carry multi-MB file_content_b64 blobs. Set to None to disable the nightly purge.
2081
+ TRUEPPM_IMPORT_RETENTION_DAYS: int | None = env.int("TRUEPPM_IMPORT_RETENTION_DAYS", default=7)
2082
+
2083
+ # Download-link validity (days) for a completed WorkspaceExportJob; past this the
2084
+ # nightly purge deletes the job row and its stored archive (ADR-0174). The full
2085
+ # archive can be large and contains every project's data, so it is not kept
2086
+ # indefinitely. Set to None to disable expiry/purge (links never lapse).
2087
+ TRUEPPM_EXPORT_RETENTION_DAYS: int | None = env.int("TRUEPPM_EXPORT_RETENTION_DAYS", default=7)
2088
+
2089
+ # Retention window (days) for the ⌘Z undo ledger rows behind paste-many and
2090
+ # cascade classification (ADR-0810, #2756) — undone or not. 30 days comfortably
2091
+ # exceeds any plausible "wait, I meant to undo that" gap while staying inside
2092
+ # the 90-day HISTORY_RETENTION_DAYS these tables sit alongside. Set to None to
2093
+ # disable the purge (ledger rows kept indefinitely).
2094
+ TRUEPPM_BATCH_OPERATION_RETENTION_DAYS: int | None = env.int(
2095
+ "TRUEPPM_BATCH_OPERATION_RETENTION_DAYS", default=30
2096
+ )
2097
+
2098
+ # Window in seconds during which the same person repeating the change whose
2099
+ # config-change notice was the last one sent for a project is not re-notified
2100
+ # (ADR-1174 §D). A different change, a different person, or an intervening
2101
+ # different notice always sends. 0 disables the cooldown.
2102
+ TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS: int = env.int(
2103
+ "TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS", default=600
2104
+ )
2105
+
2106
+ # Age in seconds past which the Beat heartbeat is considered stale. Drives both
2107
+ # the GET /api/v1/health/beat/ stale flag and the beat.check_stale_heartbeat
2108
+ # WARNING log. Default 120 s = four missed 30 s beats.
2109
+ TRUEPPM_BEAT_STALE_SECONDS: int = env.int("TRUEPPM_BEAT_STALE_SECONDS", default=120)
2110
+
2111
+ # Escape hatch for a *deliberate* image-only rollback (#2806). /api/v1/readyz
2112
+ # reports not-ready when the database records migrations this image does not ship
2113
+ # ("migration_state": "ahead"), because the probe cannot tell an additive upgrade
2114
+ # — whose schema the old code can usually still serve — from a destructive one it
2115
+ # certainly cannot. Fail-closed is the right default, but it also blocks the
2116
+ # additive-only rollback the upgrade runbook recommends, so an operator who has
2117
+ # read the release's migrations and knows they are additive can set this to true
2118
+ # to let the older image serve. It never suppresses the "behind" gate: a
2119
+ # mid-upgrade pod is still kept out of the Service.
2120
+ TRUEPPM_READYZ_ALLOW_DB_AHEAD: bool = env.bool("TRUEPPM_READYZ_ALLOW_DB_AHEAD", default=False)
2121
+
2122
+ # Freshness/dedup window in hours for mobile sync upload batches (ADR-0082). A
2123
+ # duplicate upload with the same client_batch_id within this window replays the
2124
+ # stored response; past it, the id is allowed to re-run and the nightly
2125
+ # sync.purge_sync_batches task reaps the stale row.
2126
+ TRUEPPM_SYNC_BATCH_RETENTION_HOURS: int = env.int("TRUEPPM_SYNC_BATCH_RETENTION_HOURS", default=24)
2127
+
2128
+ # Retention window in hours for the WebSocket event replay buffer (ADR-0236,
2129
+ # #321). BoardEvent rows older than this are reaped by the nightly
2130
+ # sync.purge_board_events task so the buffer stays bounded; a client reconnecting
2131
+ # with a ?since= older than the retained window gets a resync_required frame and
2132
+ # refetches. Kept deliberately short (a reconnect gap is minutes, not days).
2133
+ TRUEPPM_BOARD_EVENT_RETENTION_HOURS: int = env.int(
2134
+ "TRUEPPM_BOARD_EVENT_RETENTION_HOURS", default=24
2135
+ )
2136
+
2137
+ # Retention window in days for soft-deleted (trashed) projects. Once a project has
2138
+ # been soft-deleted for longer than this, the consolidated retention purge
2139
+ # (retention.run_purge, ADR-0173) HARD-deletes it and all its child data (tasks,
2140
+ # dependencies, sprints, baselines, …) via DB CASCADE. Set to None to disable the
2141
+ # purge (trashed projects are retained unbounded). A project soft-deleted before
2142
+ # this column existed has a NULL deleted_at and is never auto-purged.
2143
+ TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS: int | None = env.int(
2144
+ "TRUEPPM_PROJECT_SOFT_DELETE_RETENTION_DAYS", default=30
2145
+ )
2146
+
2147
+ # How long a manual retention purge may be considered "in progress" before the
2148
+ # run endpoint stops treating a RUNNING PurgeRow as blocking (ADR-0173 §G). Bounds
2149
+ # the API-level single-flight guard to the coordinator's Redis lock window so a
2150
+ # worker that died mid-run can't block all future manual runs with a stale row.
2151
+ RETENTION_PURGE_INFLIGHT_SECONDS: int = env.int("RETENTION_PURGE_INFLIGHT_SECONDS", default=600)
2152
+
2153
+ # Maximum rows (created + updated + deleted) in a single mobile sync upload
2154
+ # batch (ADR-0082). The batch applies in one transaction; this bounds how long
2155
+ # that transaction (and its per-task row locks) can be held by one request.
2156
+ TRUEPPM_SYNC_BATCH_MAX_ROWS: int = env.int("TRUEPPM_SYNC_BATCH_MAX_ROWS", default=500)
2157
+
2158
+ # Batch-wide cap on inline ``owners`` entries across a sync upload batch (#3643):
2159
+ # summed over every created/updated task row's ``owners`` list, independent of
2160
+ # the row cap above (few rows can still carry many owners each).
2161
+ TRUEPPM_SYNC_BATCH_MAX_OWNERS: int = env.int("TRUEPPM_SYNC_BATCH_MAX_OWNERS", default=500)
2162
+
2163
+ # Max simultaneously in-flight sync upload batches per user (#1756, defense in
2164
+ # depth over the SyncUploadThrottle rate cap). Each accepted batch is a heavy
2165
+ # transaction.atomic() (up to TRUEPPM_SYNC_BATCH_MAX_ROWS row locks + a CPM recalc
2166
+ # enqueue); under lock contention a burst of concurrent batches from one user can
2167
+ # tie up worker/DB resources *within* the per-minute rate cap. A per-user Redis
2168
+ # semaphore (INCR on entry / DECR in finally) bounds simultaneous heavy
2169
+ # transactions. Fails OPEN on a Redis error — parity with the rate throttle's
2170
+ # offline-client-retries reasoning would over-penalize here, and the rate cap
2171
+ # remains the hard bound. The counter carries a TTL so a worker that dies holding
2172
+ # a slot cannot leak it permanently.
2173
+ TRUEPPM_SYNC_MAX_CONCURRENT_BATCHES: int = env.int("TRUEPPM_SYNC_MAX_CONCURRENT_BATCHES", default=4)
2174
+ # TTL (seconds) on the per-user in-flight counter. A crashed worker's slot is
2175
+ # reclaimed once no further batch refreshes the key within this window; set
2176
+ # comfortably above the longest legitimate batch-apply time so an in-progress
2177
+ # slot is never reclaimed out from under a live request.
2178
+ TRUEPPM_SYNC_INFLIGHT_TTL_SECONDS: int = env.int("TRUEPPM_SYNC_INFLIGHT_TTL_SECONDS", default=120)
2179
+
2180
+ # Cursor pagination for the offline delta PULL (#1013). A cold start (since=0)
2181
+ # on a large project would otherwise materialize every row into one unbounded
2182
+ # multi-MB response, past the "500-task delta < 3s" mobile target. The pull now
2183
+ # returns at most PAGE_SIZE rows (across all collections) per request and the
2184
+ # client loops on the cursor. PAGE_SIZE is the default; MAX_PAGE_SIZE clamps a
2185
+ # client-requested page_size so one request can never re-open the unbounded cliff.
2186
+ TRUEPPM_SYNC_PULL_PAGE_SIZE: int = env.int("TRUEPPM_SYNC_PULL_PAGE_SIZE", default=1000)
2187
+ TRUEPPM_SYNC_PULL_MAX_PAGE_SIZE: int = env.int("TRUEPPM_SYNC_PULL_MAX_PAGE_SIZE", default=5000)
2188
+
2189
+ # Retention window in days for per-row soft-deleted tombstones in live projects
2190
+ # (is_deleted=True rows on Task, Risk, Sprint, Dependency). Rows older than this
2191
+ # are hard-deleted by the nightly sync.reap_domain_tombstones task. The 90-day
2192
+ # default aligns with the HistoricalTask history window so tombstones are never
2193
+ # retained longer than the audit trail that references them (#1321).
2194
+ TRUEPPM_TOMBSTONE_RETENTION_DAYS: int = env.int("TRUEPPM_TOMBSTONE_RETENTION_DAYS", default=90)
2195
+
2196
+ # Look-ahead horizon (days) for lazy recurring-task occurrence generation (ADR-0090).
2197
+ # The hourly projects.generate_recurring_occurrences sweep materializes only
2198
+ # occurrences due within this window — a bounded look-ahead, never the full series.
2199
+ TRUEPPM_RECURRENCE_HORIZON_DAYS: int = env.int("TRUEPPM_RECURRENCE_HORIZON_DAYS", default=14)
2200
+
2201
+ # ---------------------------------------------------------------------------
2202
+ # Workflow execution engine (ADR-0080)
2203
+ # ---------------------------------------------------------------------------
2204
+
2205
+ # Dotted path to the WorkflowBackend implementation. The OSS default composes
2206
+ # the transactional outbox + Celery; enterprise editions register an alternate
2207
+ # (e.g. Temporal) by overriding this — the edition-routing pattern of ADR-0030.
2208
+ # Env vars standardized on the TRUEPPM_ prefix pre-0.3 (#1325); legacy bare names
2209
+ # read as a fallback so existing deploys keep working until they migrate.
2210
+ WORKFLOW_BACKEND = env.str(
2211
+ "TRUEPPM_WORKFLOW_BACKEND",
2212
+ default=env.str(
2213
+ "WORKFLOW_BACKEND",
2214
+ default="trueppm_api.workflows.backends.default.DefaultWorkflowBackend",
2215
+ ),
2216
+ )
2217
+
2218
+ # Retention window in days for WorkflowHistoryEvent rows (purged nightly by
2219
+ # workflows.purge_old_records). Set to None / 0 to disable history purging.
2220
+ WORKFLOW_HISTORY_RETENTION_DAYS: int | None = env.int(
2221
+ "TRUEPPM_WORKFLOW_HISTORY_RETENTION_DAYS",
2222
+ default=env.int("WORKFLOW_HISTORY_RETENTION_DAYS", default=30),
2223
+ )
2224
+
2225
+ # Max rows the workflow outbox/timer drains process per tick. Bounds the work
2226
+ # per run so a large backlog (e.g. after a broker outage) can't exceed the task
2227
+ # time_limit — subsequent ticks drain the remainder.
2228
+ WORKFLOW_DRAIN_BATCH_SIZE = env.int(
2229
+ "TRUEPPM_WORKFLOW_DRAIN_BATCH_SIZE", default=env.int("WORKFLOW_DRAIN_BATCH_SIZE", default=200)
2230
+ )
2231
+
2232
+ # Rows deleted per statement by the nightly workflow retention purge. The purge
2233
+ # deletes in bounded chunks rather than one unbounded statement so the first run
2234
+ # on a mature install (e.g. after WORKFLOW_HISTORY_RETENTION_DAYS is first set)
2235
+ # cannot take a long lock over a large slice of the history/outbox tables.
2236
+ WORKFLOW_PURGE_BATCH_SIZE = env.int(
2237
+ "TRUEPPM_WORKFLOW_PURGE_BATCH_SIZE", default=env.int("WORKFLOW_PURGE_BATCH_SIZE", default=500)
2238
+ )
2239
+
2240
+ # ---------------------------------------------------------------------------
2241
+ # Idempotency-Key retention (trueppm_api.apps.idempotency, ADR-0170)
2242
+ # ---------------------------------------------------------------------------
2243
+
2244
+ # Retention window in hours for stored Idempotency-Key responses. Purged hourly by
2245
+ # the Celery beat task in trueppm_api.apps.idempotency.tasks. After expiry, a retry
2246
+ # with the same key re-runs the mutation. Set to None to disable automatic purging.
2247
+ # Env vars standardized on the TRUEPPM_ prefix pre-0.3 (#1325); legacy bare names
2248
+ # read as a fallback so existing deploys keep working until they migrate.
2249
+ IDEMPOTENCY_RETENTION_HOURS: int | None = env.int(
2250
+ "TRUEPPM_IDEMPOTENCY_RETENTION_HOURS",
2251
+ default=env.int("IDEMPOTENCY_RETENTION_HOURS", default=24),
2252
+ )
2253
+ # Maximum stored response body size (bytes). Responses larger than this are not stored
2254
+ # (the claim row is dropped, so a retry re-runs). Mutation responses are single objects
2255
+ # and effectively never approach this.
2256
+ IDEMPOTENCY_MAX_BODY_BYTES: int = env.int(
2257
+ "TRUEPPM_IDEMPOTENCY_MAX_BODY_BYTES",
2258
+ default=env.int("IDEMPOTENCY_MAX_BODY_BYTES", default=1 * 1024 * 1024),
2259
+ )
2260
+
2261
+ # ---------------------------------------------------------------------------
2262
+ # drf-spectacular (OpenAPI)
2263
+ # ---------------------------------------------------------------------------
2264
+
2265
+ SPECTACULAR_SETTINGS = {
2266
+ "TITLE": "TruePPM API",
2267
+ "DESCRIPTION": "REST API for the TruePPM project scheduling platform.",
2268
+ # Derived, never restated (#2605). This read "0.3.0" while the package was
2269
+ # 0.4.0-beta.1, so every generated client and every published schema consumer
2270
+ # was told the wrong version — and the literal sat ~900 lines below
2271
+ # _package_version(), the anti-drift helper written to prevent exactly this.
2272
+ # A version that has to be updated by hand at tag time is a version that will
2273
+ # be wrong between tags.
2274
+ "VERSION": TRUEPPM_VERSION,
2275
+ "SERVE_INCLUDE_SCHEMA": False,
2276
+ "COMPONENT_SPLIT_REQUEST": True,
2277
+ # Meaningful top-level tag block (#1333). drf-spectacular defaults every
2278
+ # /api/v1/ operation to the tag "v1", which collapses a generated client into a
2279
+ # single API class. Defining tags here (plus the resource-tag assignment in the
2280
+ # post-processing hook below) splits the SDK into ProjectsApi, SprintsApi,
2281
+ # SchedulingApi, … the way an SDK consumer expects. Kept in lockstep with the
2282
+ # tag-assignment map in trueppm_api.core.openapi (defined inline rather than
2283
+ # imported: settings must not import DRF-dependent app code, which would force
2284
+ # rest_framework's api_settings to cache before REST_FRAMEWORK is defined).
2285
+ "TAGS": [
2286
+ {
2287
+ "name": "auth",
2288
+ "description": "Authentication, login, token issuance and OIDC single sign-on.",
2289
+ },
2290
+ {
2291
+ "name": "me",
2292
+ "description": "The authenticated user's own account, credentials, timers and work.",
2293
+ },
2294
+ {
2295
+ "name": "workspace",
2296
+ "description": "Workspace-level settings, membership, invites and branding.",
2297
+ },
2298
+ {
2299
+ "name": "projects",
2300
+ "description": "Projects and their project-level views (overview, status, history).",
2301
+ },
2302
+ {
2303
+ "name": "programs",
2304
+ "description": "Programs — grouping of related projects managed by one PM.",
2305
+ },
2306
+ {
2307
+ "name": "tasks",
2308
+ "description": "Tasks, dependencies, milestones, acceptance criteria and task runs.",
2309
+ },
2310
+ {
2311
+ "name": "task-relations",
2312
+ "description": "Informational task-to-task relations (relates to, blocks, "
2313
+ "duplicates) — cross-references with no scheduling effect.",
2314
+ },
2315
+ {
2316
+ "name": "scheduling",
2317
+ "description": "CPM scheduling, baselines, Monte Carlo forecasts and slip analysis.",
2318
+ },
2319
+ {
2320
+ "name": "sprints",
2321
+ "description": "Sprints, backlog, velocity, retrospectives and agile ceremonies.",
2322
+ },
2323
+ {
2324
+ "name": "resources",
2325
+ "description": "Resources, skills, allocation, utilization and assignments.",
2326
+ },
2327
+ {"name": "calendars", "description": "Working calendars, exceptions and recurrence rules."},
2328
+ {"name": "teams", "description": "Teams and team membership."},
2329
+ {"name": "members", "description": "Project and program membership management."},
2330
+ {
2331
+ "name": "integrations",
2332
+ "description": "External task sources, Git automation and personal credentials.",
2333
+ },
2334
+ {
2335
+ "name": "assets",
2336
+ "description": "Workspace-scoped Assets feed: file/URL attachments and task links "
2337
+ "aggregated across every project the caller can read.",
2338
+ },
2339
+ {
2340
+ "name": "import-export",
2341
+ "description": (
2342
+ "File import and export: MS Project, Jira XML, CSV/Excel spreadsheets, "
2343
+ "import templates, and data exports."
2344
+ ),
2345
+ },
2346
+ {"name": "webhooks", "description": "Outbound webhook subscriptions."},
2347
+ {
2348
+ "name": "sync",
2349
+ "description": "Offline delta-sync protocol and WebSocket connection tickets.",
2350
+ },
2351
+ {
2352
+ "name": "share",
2353
+ "description": "Public read-only board share links: mint, list, revoke, and the "
2354
+ "public unauthenticated read endpoint.",
2355
+ },
2356
+ {
2357
+ "name": "agent-actions",
2358
+ "description": "Append-only, hash-chained audit trail of MCP/agent read actions "
2359
+ "and refusals, scoped to project membership.",
2360
+ },
2361
+ {
2362
+ "name": "meta",
2363
+ "description": "Deployment metadata, health probes and administrative endpoints.",
2364
+ },
2365
+ ],
2366
+ # Fill SDK-quality facets (operation summaries, resource tags, global + public
2367
+ # security) that per-view annotations leave thin. Runs after the built-in enum
2368
+ # post-processor, which must be preserved (drf-spectacular default).
2369
+ "POSTPROCESSING_HOOKS": [
2370
+ "drf_spectacular.hooks.postprocess_schema_enums",
2371
+ "trueppm_api.core.openapi.postprocess_openapi",
2372
+ ],
2373
+ # Serve the Swagger UI / ReDoc assets from the bundled sidecar package (Django
2374
+ # static, same origin) instead of the default jsdelivr CDN. Our strict CSP
2375
+ # (script-src / style-src 'self', no CDN host) blocks the CDN-hosted bundles,
2376
+ # leaving the docs pages blank. SIDECAR resolves every asset under 'self', so
2377
+ # the pages work under CSP and offline/air-gapped. Paired with the split
2378
+ # Swagger view in urls.py, which moves the bootstrap JS out of an inline
2379
+ # <script> (also blocked by script-src 'self') to a same-origin request.
2380
+ "SWAGGER_UI_DIST": "SIDECAR",
2381
+ "SWAGGER_UI_FAVICON_HREF": "SIDECAR",
2382
+ "REDOC_DIST": "SIDECAR",
2383
+ # Self-hosted deployments serve the API from their own origin, so the schema
2384
+ # advertises a single templated server (scheme + host variables) whose
2385
+ # defaults are the local-dev address. Without a top-level `servers` array,
2386
+ # openapi-typescript / Orval / openapi-generator emit base-URL-less clients
2387
+ # or fail outright (#1329). Paths already carry the `/api/v1/` prefix, so the
2388
+ # server URL is just the deployment origin.
2389
+ "SERVERS": [
2390
+ {
2391
+ "url": "{scheme}://{host}",
2392
+ "description": "Your TruePPM deployment",
2393
+ "variables": {
2394
+ "scheme": {"default": "https", "enum": ["https", "http"]},
2395
+ "host": {
2396
+ "default": "localhost:8000",
2397
+ "description": "Host (and port) of your TruePPM API server.",
2398
+ },
2399
+ },
2400
+ },
2401
+ ],
2402
+ # Pin state-enum names (ADR-0173). PurgeRun.state shares the field name "state"
2403
+ # with the sprint lifecycle enum; introducing a second "state" choice set makes
2404
+ # drf-spectacular disambiguate *both* by model prefix, renaming the sprint enum
2405
+ # away from the stable `StateEnum` component (a schema regression). Pinning the
2406
+ # sprint enum to `StateEnum` and ours to `PurgeRunStateEnum` keeps both stable.
2407
+ # Likewise for "frequency": TaskRecurrenceRule.frequency (#736) introduces a
2408
+ # second choice set sharing the field name with RetentionSchedule.frequency,
2409
+ # so drf-spectacular would disambiguate both by model prefix and rename the
2410
+ # stable `FrequencyEnum` component (a regression). Pin the retention enum back
2411
+ # to `FrequencyEnum` and the recurrence enum to `RecurrenceFrequencyEnum`.
2412
+ "ENUM_NAME_OVERRIDES": {
2413
+ # #3037: the bulk refusal vocabulary. Pinned rather than left to
2414
+ # drf-spectacular's derived naming because these two names are the handle a
2415
+ # generated client's types carry — the whole point of publishing the enum is
2416
+ # that an integrator can branch on it, and a component that renames itself
2417
+ # when an unrelated field's choices change would break exactly that.
2418
+ # `code` appears on three buckets of one response and must resolve to ONE
2419
+ # component, not three postfixed variants.
2420
+ "TaskBulkRefusalCodeEnum": "trueppm_api.apps.projects.refusal_codes.BulkRefusalCode",
2421
+ "StructuralUndoBlockedReasonEnum": (
2422
+ "trueppm_api.apps.projects.refusal_codes.StructuralUndoBlockedReason"
2423
+ ),
2424
+ # #3680: MonteCarloForecastDiagnosticSerializer.reason introduces a SECOND
2425
+ # "reason" choice set alongside TaskGroupLeftAloneEntrySerializer.reason
2426
+ # (an inline tuple, not an enum class — hence the raw-list value, same
2427
+ # shape as OutcomeEnum below). Without a pin drf-spectacular disambiguates
2428
+ # both by serializer prefix and renames the published `ReasonEnum` — the
2429
+ # project_drf_enum_name_collision regression every pin in this block
2430
+ # exists to prevent. Pin the incumbent to its stable name; the new
2431
+ # Monte Carlo set is then free to take the prefixed form.
2432
+ "ReasonEnum": ["ancestor_selected", "different_parent"],
2433
+ "StateEnum": "trueppm_api.apps.projects.models.SprintState",
2434
+ "PurgeRunStateEnum": "trueppm_api.apps.observability.models.PurgeRun.State",
2435
+ "PurgeRunTriggerEnum": "trueppm_api.apps.observability.models.PurgeRun.Trigger",
2436
+ "FrequencyEnum": "trueppm_api.apps.observability.models.RetentionSchedule.Frequency",
2437
+ "RecurrenceFrequencyEnum": "trueppm_api.apps.projects.models.TaskRecurrenceFrequency",
2438
+ # ADR-0102: SprintScopeChange.status (pending|accepted|rejected) introduces
2439
+ # a new "status" choice set; without a pin drf-spectacular disambiguates
2440
+ # every "status" field by model prefix and renames existing stable
2441
+ # components (a schema-drift regression — project memory
2442
+ # project_drf_enum_name_collision). Pin ours to a stable name.
2443
+ "ScopeChangeStatusEnum": "trueppm_api.apps.projects.models.ScopeChangeStatus",
2444
+ # ADR-0789 (#2729): ProjectTemplate.source_kind introduces a SECOND
2445
+ # "source_kind" choice set alongside Task.source_kind (ADR-0786). Without
2446
+ # a pin drf-spectacular disambiguates both by model prefix, which renames
2447
+ # the already-published `SourceKindEnum` to `TaskSourceKindEnum` — a
2448
+ # removed component, and exactly the schema-drift regression the two pins
2449
+ # above exist to prevent. Pin Task's to its existing stable name; the
2450
+ # template's own set is then free to take the model-prefixed name.
2451
+ "SourceKindEnum": "trueppm_api.apps.projects.models.TaskSource",
2452
+ # ADR-0880 (#2974): StructuralOperation.kind introduces a SECOND "kind"
2453
+ # choice set alongside AssetItemSerializer.kind (file|link). Without a pin
2454
+ # drf-spectacular disambiguates both by prefix, renaming the published
2455
+ # `KindEnum` to `AssetItemKindEnum` — a removed component referenced by
2456
+ # AgentAction, AssetItem, ShareLink, ShareLinkCreateRequest,
2457
+ # ShareLinkCreateResponse and Task. Same regression class as every pin in
2458
+ # this block. Keep the incumbent's name; the new set takes the prefixed one.
2459
+ "KindEnum": "trueppm_api.apps.projects.asset_feed.KIND_CHOICES",
2460
+ # #3525: health_band_source is declared on BOTH the single-project status
2461
+ # summary and the my-projects health summary. Identical choices already
2462
+ # resolve to one component, but the pin is proactive rather than
2463
+ # incidental: the next endpoint that grows a "source" choice set would
2464
+ # otherwise make drf-spectacular disambiguate every one of them by prefix
2465
+ # and rename the published component — the project_drf_enum_name_collision
2466
+ # regression every pin in this block exists to prevent. Pinned to the
2467
+ # shared constant the two declarations reference, so the name cannot drift
2468
+ # from the vocabulary.
2469
+ "HealthBandSourceEnum": "trueppm_api.apps.projects.views.HEALTH_BAND_SOURCE_CHOICES",
2470
+ "TemplateSourceEnum": "trueppm_api.apps.projects.models.TemplateSource",
2471
+ # ADR-0116: Workspace.iteration_label_override_policy adds an
2472
+ # INHERIT/SUGGEST/ENFORCE choice set. Pin it so drf-spectacular does not
2473
+ # rename existing stable enums (the same schema-drift regression class as
2474
+ # ScopeChangeStatus above — project_drf_enum_name_collision).
2475
+ "TermOverridePolicyEnum": "trueppm_api.apps.workspace.models.TermOverridePolicy",
2476
+ # #2723: TaskBulkAppliedEntry.outcome adds a SECOND "outcome" choice set,
2477
+ # alongside the telemetry export result's. Without a pin drf-spectacular
2478
+ # disambiguates both by serializer prefix and *renames* the existing stable
2479
+ # OutcomeEnum to TelemetryTestExportResultOutcomeEnum — a published component
2480
+ # disappearing, which breaks generated SDKs and trips schema-check. Pinning
2481
+ # keeps the incumbent's name and lets the new one take the prefixed form.
2482
+ # Same regression class as ScopeChangeStatus above (project_drf_enum_name_collision).
2483
+ "OutcomeEnum": ["success", "reachable", "failure"],
2484
+ # ADR-0107: Workspace gains a third use of Methodology (after Project +
2485
+ # Program) and methodology_override_policy reuses TermOverridePolicy. Pin
2486
+ # Methodology to its stable component name proactively so the third use does
2487
+ # not trip the drf-spectacular enum-name-collision / schema-drift regression
2488
+ # (project_drf_enum_name_collision), same class as the entries above.
2489
+ "MethodologyEnum": "trueppm_api.apps.projects.models.Methodology",
2490
+ # ADR-0105: Task gains task_type / readiness and Project gains
2491
+ # backlog_scoring_model. Each is a new choice set on a *_type / status-adjacent
2492
+ # field; pin to model-prefixed names so drf-spectacular does not rename the
2493
+ # existing stable enums (same regression class as ScopeChangeStatus above).
2494
+ "TaskTypeEnum": "trueppm_api.apps.projects.models.TaskType",
2495
+ # ADR-0638 (#2333): the program label view adds a second serializer
2496
+ # exposing Task.status, which was enough for drf-spectacular to stop
2497
+ # resolving the stable `TaskStatusEnum` and emit a hashed
2498
+ # `Status3ebEnum` in its place — renaming the component on every
2499
+ # *existing* task endpoint, i.e. a breaking change to generated clients
2500
+ # for a purely additive read view. Same regression class as the entries
2501
+ # above (project_drf_enum_name_collision); pin it.
2502
+ "TaskStatusEnum": "trueppm_api.apps.projects.models.TaskStatus",
2503
+ "DorStateEnum": "trueppm_api.apps.projects.models.DorState",
2504
+ "PrioritizationModelEnum": "trueppm_api.apps.projects.models.PrioritizationModel",
2505
+ # ADR-0078 (#927): TeamMembership.role (member|admin) is a second "role"
2506
+ # choice set sharing the field name with the access Role ordinal. Without
2507
+ # a pin drf-spectacular disambiguates both by hash and renames the stable
2508
+ # `RoleEnum` component (same regression class as ScopeChangeStatus above —
2509
+ # project memory project_drf_enum_name_collision). Pin the access role back
2510
+ # to `RoleEnum` and the team role to `TeamRoleEnum`. `RoleEnum` never reaches
2511
+ # the published schema: the post-processing hook finds it by this pinned name
2512
+ # and replaces it with the banded-integer `RoleOrdinal` (ADR-1291), so the
2513
+ # pin is what keeps that hook from silently missing a renamed component.
2514
+ "RoleEnum": "trueppm_api.apps.access.models.Role",
2515
+ "TeamRoleEnum": "trueppm_api.apps.teams.models.TeamRole",
2516
+ # ADR-0036 (#407): Task.governance_class / .delivery_mode add two new choice
2517
+ # sets. Pin to stable model-prefixed names so a later delivery_mode on another
2518
+ # model (e.g. Project, #410) cannot trigger a drf-spectacular rename of the
2519
+ # existing component (same regression class as ScopeChangeStatus — project
2520
+ # memory project_drf_enum_name_collision).
2521
+ "GovernanceClassEnum": "trueppm_api.apps.projects.models.GovernanceClass",
2522
+ "DeliveryModeEnum": "trueppm_api.apps.projects.models.DeliveryMode",
2523
+ # ADR-0106 §5 (#860): ForecastSnapshot.basis / .confidence introduce two
2524
+ # new choice sets on "basis" / "confidence" fields. Pin to model-prefixed
2525
+ # names so drf-spectacular does not rename existing stable components
2526
+ # (same regression class as ScopeChangeStatus — project memory
2527
+ # project_drf_enum_name_collision).
2528
+ "ForecastBasisEnum": "trueppm_api.apps.projects.models.ForecastBasis",
2529
+ "ForecastConfidenceEnum": "trueppm_api.apps.projects.models.ForecastConfidence",
2530
+ # ADR-0104 (#553): SignalAudience is a fresh ladder enum used in the
2531
+ # signal-privacy serializer; pin it to a stable name so a future "audience"
2532
+ # field cannot collide-and-rename it (project memory
2533
+ # project_drf_enum_name_collision).
2534
+ "SignalAudienceEnum": "trueppm_api.apps.projects.models.SignalAudience",
2535
+ # #983: Sprint.goal_outcome (MET|PARTIAL|MISSED) is a new choice set; pin
2536
+ # to a stable model-prefixed name so drf-spectacular does not rename
2537
+ # existing components (same regression class as ScopeChangeStatus —
2538
+ # project memory project_drf_enum_name_collision).
2539
+ "SprintGoalOutcomeEnum": "trueppm_api.apps.projects.models.SprintGoalOutcome",
2540
+ # #985: SprintTaskDisposition is exposed in the /outcome/ read serializer
2541
+ # (DidntShipItemSerializer.disposition). Pin to a stable name so
2542
+ # drf-spectacular does not hash-disambiguate it (project memory
2543
+ # project_drf_enum_name_collision).
2544
+ "SprintTaskDispositionEnum": "trueppm_api.apps.projects.models.SprintTaskDisposition",
2545
+ # ADR-0124 (#1135): Task.blocker_type adds a new structured-blocker choice
2546
+ # set. Pin to a stable model-prefixed name so drf-spectacular does not
2547
+ # hash-disambiguate or rename existing components (same regression class as
2548
+ # ScopeChangeStatus above — project memory project_drf_enum_name_collision).
2549
+ "BlockerTypeEnum": "trueppm_api.apps.projects.models.BlockerType",
2550
+ # #3416: annotating the four batch-undo actions adds a SECOND serializer
2551
+ # exposing StructuralOperation.kind and TemplateApplication.status (the
2552
+ # `*Undo` response wrappers subclass the ledger serializers). A second
2553
+ # exposure of an already-resolved choice set is all it takes for
2554
+ # drf-spectacular to stop deriving the stable name and emit a hashed
2555
+ # `KindD4dEnum` / `StatusCf2Enum` instead — renaming the component on every
2556
+ # *existing* endpoint that serves those rows, i.e. a Breaking change under
2557
+ # `api/stability.md` for a purely additive response annotation. Exactly the
2558
+ # TaskStatusEnum case above, and the same regression class as every pin in
2559
+ # this block (project memory project_drf_enum_name_collision).
2560
+ "StructuralOperationKindEnum": ("trueppm_api.apps.projects.models.StructuralOperationKind"),
2561
+ "TemplateApplicationStatusEnum": (
2562
+ "trueppm_api.apps.projects.models.TemplateApplicationStatus"
2563
+ ),
2564
+ # ADR-0219 (#1266): ProjectExportJob.status introduces a second export-job
2565
+ # status choice set with the SAME members (pending|running|success|failed)
2566
+ # as workspace.ExportJobStatus. drf-spectacular unifies identical value-sets
2567
+ # into ONE component, and without a pin it hash-disambiguates and DROPS the
2568
+ # existing WorkspaceExportJobStatusEnum (a schema-drift regression — project
2569
+ # memory project_drf_enum_name_collision). Pin the shared value-set to the
2570
+ # already-published WorkspaceExportJobStatusEnum name so no component is
2571
+ # removed; the project export field simply reuses that stable component.
2572
+ "WorkspaceExportJobStatusEnum": "trueppm_api.apps.workspace.models.ExportJobStatus",
2573
+ # ADR-0112 RC1 (#1805): the AgentAction read serializer exposes verdict,
2574
+ # refusal_reason, and actor_kind choice sets. "verdict"/"refusal_reason" are
2575
+ # generic field names a future model could reuse; pin to stable model-prefixed
2576
+ # names so drf-spectacular does not hash-disambiguate or rename them (same
2577
+ # regression class as ScopeChangeStatus — project_drf_enum_name_collision).
2578
+ "AgentActionVerdictEnum": "trueppm_api.apps.agents.models.AgentActionVerdict",
2579
+ "AgentActionRefusalReasonEnum": "trueppm_api.apps.agents.models.AgentActionRefusalReason",
2580
+ "AgentActorKindEnum": "trueppm_api.apps.agents.models.AgentActorKind",
2581
+ # ADR-0421 (#1850): the refusal side-car exposes a "constraint" choice set;
2582
+ # "constraint" is a generic field name, so pin it to a stable model-prefixed
2583
+ # component name (same rationale as the verdict/refusal_reason enums above).
2584
+ "RefusalConstraintEnum": "trueppm_api.apps.agents.models.RefusalConstraint",
2585
+ # #3679: ProjectSprintHealthSignalSerializer.key ("orphan" |
2586
+ # "phase_span" | "summary_in_sprint") introduces a second "key" choice
2587
+ # set alongside RetentionPolicy.key, which is enough for drf-spectacular
2588
+ # to stop resolving the stable `KeyEnum` and rename it to
2589
+ # `RetentionPolicyWriteKeyEnum` — a removed component (same regression
2590
+ # class as every pin in this block, project_drf_enum_name_collision).
2591
+ # Pin the incumbent back to its stable name; the sprint-health set is
2592
+ # then free to take the serializer-prefixed name it already resolves to.
2593
+ "KeyEnum": "trueppm_api.apps.observability.retention.RETENTION_KEYS",
2594
+ # #3684: giving the weekly time-entry rollup (`GET /me/time-entries/`) a
2595
+ # real response schema exposes `TimeEntryWeeklySerializer.source`, a SECOND
2596
+ # serializer surfacing `TimeEntry.source` alongside `TimeEntrySerializer`'s.
2597
+ # A second exposure of an already-resolved choice set is exactly what makes
2598
+ # drf-spectacular stop deriving the stable `TimeEntrySourceEnum` name and
2599
+ # hash-disambiguate both instead — renaming the component on every
2600
+ # *existing* time-entry endpoint for a purely additive response annotation
2601
+ # (same regression class as ScopeChangeStatus above —
2602
+ # project_drf_enum_name_collision). Pin it.
2603
+ "TimeEntrySourceEnum": "trueppm_api.apps.timetracking.models.TimeEntrySource",
2604
+ },
2605
+ }