trueppm-api 0.4.0b2__py3-none-any.whl

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