trueppm-api 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (799) hide show
  1. trueppm_api/__init__.py +27 -0
  2. trueppm_api/apps/access/__init__.py +0 -0
  3. trueppm_api/apps/access/apps.py +19 -0
  4. trueppm_api/apps/access/groups.py +518 -0
  5. trueppm_api/apps/access/management/__init__.py +0 -0
  6. trueppm_api/apps/access/management/commands/__init__.py +0 -0
  7. trueppm_api/apps/access/management/commands/create_admin.py +346 -0
  8. trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
  9. trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
  10. trueppm_api/apps/access/migrations/0001_initial.py +61 -0
  11. trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
  12. trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
  13. trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
  14. trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
  15. trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
  16. trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
  17. trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
  18. trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
  19. trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
  20. trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
  21. trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
  22. trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
  23. trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
  24. trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
  25. trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
  26. trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
  27. trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
  28. trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
  29. trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
  30. trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
  31. trueppm_api/apps/access/migrations/__init__.py +0 -0
  32. trueppm_api/apps/access/minter_role.py +38 -0
  33. trueppm_api/apps/access/models.py +588 -0
  34. trueppm_api/apps/access/permissions.py +3263 -0
  35. trueppm_api/apps/access/serializers.py +1007 -0
  36. trueppm_api/apps/access/services.py +878 -0
  37. trueppm_api/apps/access/signals.py +171 -0
  38. trueppm_api/apps/access/tasks.py +50 -0
  39. trueppm_api/apps/access/throttles.py +96 -0
  40. trueppm_api/apps/access/urls.py +191 -0
  41. trueppm_api/apps/access/views.py +1965 -0
  42. trueppm_api/apps/agents/__init__.py +6 -0
  43. trueppm_api/apps/agents/apps.py +11 -0
  44. trueppm_api/apps/agents/canonical.py +73 -0
  45. trueppm_api/apps/agents/deferred.py +108 -0
  46. trueppm_api/apps/agents/management/__init__.py +0 -0
  47. trueppm_api/apps/agents/management/commands/__init__.py +0 -0
  48. trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
  49. trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
  50. trueppm_api/apps/agents/middleware.py +44 -0
  51. trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
  52. trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
  53. trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
  54. trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
  55. trueppm_api/apps/agents/migrations/__init__.py +0 -0
  56. trueppm_api/apps/agents/models.py +371 -0
  57. trueppm_api/apps/agents/refusal.py +103 -0
  58. trueppm_api/apps/agents/serializers.py +85 -0
  59. trueppm_api/apps/agents/services.py +397 -0
  60. trueppm_api/apps/agents/signals.py +30 -0
  61. trueppm_api/apps/agents/urls.py +15 -0
  62. trueppm_api/apps/agents/views.py +109 -0
  63. trueppm_api/apps/csvimport/__init__.py +12 -0
  64. trueppm_api/apps/csvimport/apps.py +11 -0
  65. trueppm_api/apps/csvimport/mapping.py +363 -0
  66. trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
  67. trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
  68. trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
  69. trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
  70. trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
  71. trueppm_api/apps/csvimport/models.py +111 -0
  72. trueppm_api/apps/csvimport/parser.py +1825 -0
  73. trueppm_api/apps/csvimport/services.py +48 -0
  74. trueppm_api/apps/csvimport/tasks.py +459 -0
  75. trueppm_api/apps/csvimport/template.py +37 -0
  76. trueppm_api/apps/csvimport/urls.py +50 -0
  77. trueppm_api/apps/csvimport/views.py +793 -0
  78. trueppm_api/apps/history/__init__.py +0 -0
  79. trueppm_api/apps/history/apps.py +12 -0
  80. trueppm_api/apps/history/changelog.py +359 -0
  81. trueppm_api/apps/history/diff_policy.py +178 -0
  82. trueppm_api/apps/history/serializers.py +104 -0
  83. trueppm_api/apps/history/signals.py +82 -0
  84. trueppm_api/apps/history/tasks.py +105 -0
  85. trueppm_api/apps/history/urls.py +38 -0
  86. trueppm_api/apps/history/views.py +443 -0
  87. trueppm_api/apps/idempotency/__init__.py +0 -0
  88. trueppm_api/apps/idempotency/apps.py +11 -0
  89. trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
  90. trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
  91. trueppm_api/apps/idempotency/mixins.py +226 -0
  92. trueppm_api/apps/idempotency/models.py +75 -0
  93. trueppm_api/apps/idempotency/tasks.py +61 -0
  94. trueppm_api/apps/integrations/__init__.py +13 -0
  95. trueppm_api/apps/integrations/apps.py +59 -0
  96. trueppm_api/apps/integrations/connections.py +806 -0
  97. trueppm_api/apps/integrations/encryption.py +92 -0
  98. trueppm_api/apps/integrations/external_sources.py +863 -0
  99. trueppm_api/apps/integrations/git_automation_services.py +231 -0
  100. trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
  101. trueppm_api/apps/integrations/http.py +597 -0
  102. trueppm_api/apps/integrations/me_work.py +218 -0
  103. trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
  104. trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
  105. trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
  106. trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
  107. trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
  108. trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
  109. trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
  110. trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
  111. trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
  112. trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
  113. trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
  114. trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
  115. trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
  116. trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
  117. trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
  118. trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
  119. trueppm_api/apps/integrations/migrations/__init__.py +0 -0
  120. trueppm_api/apps/integrations/models.py +635 -0
  121. trueppm_api/apps/integrations/notification_channels.py +57 -0
  122. trueppm_api/apps/integrations/opengraph.py +229 -0
  123. trueppm_api/apps/integrations/outgoing.py +201 -0
  124. trueppm_api/apps/integrations/providers.py +528 -0
  125. trueppm_api/apps/integrations/registry.py +345 -0
  126. trueppm_api/apps/integrations/serializers.py +453 -0
  127. trueppm_api/apps/integrations/services.py +171 -0
  128. trueppm_api/apps/integrations/tasks.py +734 -0
  129. trueppm_api/apps/integrations/throttles.py +165 -0
  130. trueppm_api/apps/integrations/urls.py +77 -0
  131. trueppm_api/apps/integrations/views.py +1158 -0
  132. trueppm_api/apps/jiraimport/__init__.py +8 -0
  133. trueppm_api/apps/jiraimport/apps.py +11 -0
  134. trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
  135. trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
  136. trueppm_api/apps/jiraimport/models.py +70 -0
  137. trueppm_api/apps/jiraimport/parser.py +350 -0
  138. trueppm_api/apps/jiraimport/services.py +46 -0
  139. trueppm_api/apps/jiraimport/tasks.py +280 -0
  140. trueppm_api/apps/jiraimport/urls.py +15 -0
  141. trueppm_api/apps/jiraimport/views.py +153 -0
  142. trueppm_api/apps/msproject/__init__.py +0 -0
  143. trueppm_api/apps/msproject/apps.py +11 -0
  144. trueppm_api/apps/msproject/dataclasses.py +153 -0
  145. trueppm_api/apps/msproject/exporter.py +551 -0
  146. trueppm_api/apps/msproject/extended_attributes.py +77 -0
  147. trueppm_api/apps/msproject/importer.py +1095 -0
  148. trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
  149. trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
  150. trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
  151. trueppm_api/apps/msproject/migrations/__init__.py +0 -0
  152. trueppm_api/apps/msproject/models.py +81 -0
  153. trueppm_api/apps/msproject/parser.py +1276 -0
  154. trueppm_api/apps/msproject/serializers.py +136 -0
  155. trueppm_api/apps/msproject/services.py +58 -0
  156. trueppm_api/apps/msproject/tasks.py +439 -0
  157. trueppm_api/apps/msproject/urls.py +39 -0
  158. trueppm_api/apps/msproject/views.py +531 -0
  159. trueppm_api/apps/notifications/__init__.py +7 -0
  160. trueppm_api/apps/notifications/apps.py +15 -0
  161. trueppm_api/apps/notifications/backfill.py +79 -0
  162. trueppm_api/apps/notifications/categories.py +112 -0
  163. trueppm_api/apps/notifications/delivery_limits.py +258 -0
  164. trueppm_api/apps/notifications/digests.py +462 -0
  165. trueppm_api/apps/notifications/email_backend.py +350 -0
  166. trueppm_api/apps/notifications/email_health.py +128 -0
  167. trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
  168. trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
  169. trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
  170. trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
  171. trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
  172. trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
  173. trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
  174. trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
  175. trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
  176. trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
  177. trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
  178. trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
  179. trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
  180. trueppm_api/apps/notifications/migrations/__init__.py +0 -0
  181. trueppm_api/apps/notifications/models.py +1148 -0
  182. trueppm_api/apps/notifications/receivers.py +155 -0
  183. trueppm_api/apps/notifications/schema_migrations.py +56 -0
  184. trueppm_api/apps/notifications/serializers.py +1094 -0
  185. trueppm_api/apps/notifications/services.py +1489 -0
  186. trueppm_api/apps/notifications/tasks.py +762 -0
  187. trueppm_api/apps/notifications/throttles.py +98 -0
  188. trueppm_api/apps/notifications/urls.py +97 -0
  189. trueppm_api/apps/notifications/views.py +919 -0
  190. trueppm_api/apps/observability/__init__.py +0 -0
  191. trueppm_api/apps/observability/apps.py +31 -0
  192. trueppm_api/apps/observability/logging.py +213 -0
  193. trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
  194. trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
  195. trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
  196. trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
  197. trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
  198. trueppm_api/apps/observability/migrations/__init__.py +0 -0
  199. trueppm_api/apps/observability/models.py +141 -0
  200. trueppm_api/apps/observability/otel/__init__.py +45 -0
  201. trueppm_api/apps/observability/otel/attributes.py +247 -0
  202. trueppm_api/apps/observability/otel/export_health.py +533 -0
  203. trueppm_api/apps/observability/otel/instrumentation.py +351 -0
  204. trueppm_api/apps/observability/otel/metrics.py +566 -0
  205. trueppm_api/apps/observability/otel/provider.py +511 -0
  206. trueppm_api/apps/observability/otel/request_attributes.py +204 -0
  207. trueppm_api/apps/observability/purge_registry.py +124 -0
  208. trueppm_api/apps/observability/retention.py +139 -0
  209. trueppm_api/apps/observability/selectors.py +993 -0
  210. trueppm_api/apps/observability/serializers.py +142 -0
  211. trueppm_api/apps/observability/services.py +407 -0
  212. trueppm_api/apps/observability/tasks.py +266 -0
  213. trueppm_api/apps/observability/urls.py +27 -0
  214. trueppm_api/apps/observability/views.py +537 -0
  215. trueppm_api/apps/profiles/__init__.py +0 -0
  216. trueppm_api/apps/profiles/apps.py +15 -0
  217. trueppm_api/apps/profiles/constants.py +54 -0
  218. trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
  219. trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
  220. trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
  221. trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
  222. trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
  223. trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
  224. trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
  225. trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
  226. trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
  227. trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
  228. trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
  229. trueppm_api/apps/profiles/migrations/__init__.py +0 -0
  230. trueppm_api/apps/profiles/models.py +276 -0
  231. trueppm_api/apps/profiles/serializers.py +199 -0
  232. trueppm_api/apps/profiles/services.py +541 -0
  233. trueppm_api/apps/profiles/views.py +124 -0
  234. trueppm_api/apps/projects/__init__.py +0 -0
  235. trueppm_api/apps/projects/actual_date_rules.py +163 -0
  236. trueppm_api/apps/projects/amend.py +133 -0
  237. trueppm_api/apps/projects/apps.py +38 -0
  238. trueppm_api/apps/projects/asset_feed.py +459 -0
  239. trueppm_api/apps/projects/asset_views.py +317 -0
  240. trueppm_api/apps/projects/attachment_policy.py +278 -0
  241. trueppm_api/apps/projects/authentication.py +458 -0
  242. trueppm_api/apps/projects/backfill.py +459 -0
  243. trueppm_api/apps/projects/backlog_services.py +324 -0
  244. trueppm_api/apps/projects/backlog_views.py +302 -0
  245. trueppm_api/apps/projects/batch_operation_views.py +264 -0
  246. trueppm_api/apps/projects/blocker_services.py +510 -0
  247. trueppm_api/apps/projects/board_activity.py +462 -0
  248. trueppm_api/apps/projects/board_activity_views.py +186 -0
  249. trueppm_api/apps/projects/board_lanes.py +134 -0
  250. trueppm_api/apps/projects/bulk_settings.py +194 -0
  251. trueppm_api/apps/projects/bundled_templates.py +215 -0
  252. trueppm_api/apps/projects/calendar_settings.py +180 -0
  253. trueppm_api/apps/projects/ceremony_views.py +244 -0
  254. trueppm_api/apps/projects/commit_moment.py +212 -0
  255. trueppm_api/apps/projects/config_notice.py +1330 -0
  256. trueppm_api/apps/projects/custom_field_values.py +266 -0
  257. trueppm_api/apps/projects/decisions_services.py +132 -0
  258. trueppm_api/apps/projects/decisions_views.py +97 -0
  259. trueppm_api/apps/projects/estimation_scale.py +126 -0
  260. trueppm_api/apps/projects/export_bundle.py +423 -0
  261. trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
  262. trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
  263. trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
  264. trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
  265. trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
  266. trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
  267. trueppm_api/apps/projects/inbound_sync.py +555 -0
  268. trueppm_api/apps/projects/iteration_label.py +85 -0
  269. trueppm_api/apps/projects/key_views.py +468 -0
  270. trueppm_api/apps/projects/keys.py +205 -0
  271. trueppm_api/apps/projects/lifecycle.py +191 -0
  272. trueppm_api/apps/projects/management/__init__.py +0 -0
  273. trueppm_api/apps/projects/management/commands/__init__.py +0 -0
  274. trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
  275. trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
  276. trueppm_api/apps/projects/management/commands/export_program.py +50 -0
  277. trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
  278. trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
  279. trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
  280. trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
  281. trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
  282. trueppm_api/apps/projects/mcp_settings.py +305 -0
  283. trueppm_api/apps/projects/methodology.py +178 -0
  284. trueppm_api/apps/projects/migration_helpers.py +75 -0
  285. trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
  286. trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
  287. trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
  288. trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
  289. trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
  290. trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
  291. trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
  292. trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
  293. trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
  294. trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
  295. trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
  296. trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
  297. trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
  298. trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
  299. trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
  300. trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
  301. trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
  302. trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
  303. trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
  304. trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
  305. trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
  306. trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
  307. trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
  308. trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
  309. trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
  310. trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
  311. trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
  312. trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
  313. trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
  314. trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
  315. trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
  316. trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
  317. trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
  318. trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
  319. trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
  320. trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
  321. trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
  322. trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
  323. trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
  324. trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
  325. trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
  326. trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
  327. trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
  328. trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
  329. trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
  330. trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
  331. trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
  332. trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
  333. trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
  334. trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
  335. trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
  336. trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
  337. trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
  338. trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
  339. trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
  340. trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
  341. trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
  342. trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
  343. trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
  344. trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
  345. trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
  346. trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
  347. trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
  348. trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
  349. trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
  350. trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
  351. trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
  352. trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
  353. trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
  354. trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
  355. trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
  356. trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
  357. trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
  358. trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
  359. trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
  360. trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
  361. trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
  362. trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
  363. trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
  364. trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
  365. trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
  366. trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
  367. trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
  368. trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
  369. trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
  370. trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
  371. trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
  372. trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
  373. trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
  374. trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
  375. trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
  376. trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
  377. trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
  378. trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
  379. trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
  380. trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
  381. trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
  382. trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
  383. trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
  384. trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
  385. trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
  386. trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
  387. trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
  388. trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
  389. trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
  390. trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
  391. trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
  392. trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
  393. trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
  394. trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
  395. trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
  396. trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
  397. trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
  398. trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
  399. trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
  400. trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
  401. trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
  402. trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
  403. trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
  404. trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
  405. trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
  406. trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
  407. trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
  408. trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
  409. trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
  410. trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
  411. trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
  412. trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
  413. trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
  414. trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
  415. trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
  416. trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
  417. trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
  418. trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
  419. trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
  420. trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
  421. trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
  422. trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
  423. trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
  424. trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
  425. trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
  426. trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
  427. trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
  428. trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
  429. trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
  430. trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
  431. trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
  432. trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
  433. trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
  434. trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
  435. trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
  436. trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
  437. trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
  438. trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
  439. trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
  440. trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
  441. trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
  442. trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
  443. trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
  444. trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
  445. trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
  446. trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
  447. trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
  448. trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
  449. trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
  450. trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
  451. trueppm_api/apps/projects/migrations/__init__.py +0 -0
  452. trueppm_api/apps/projects/models.py +9209 -0
  453. trueppm_api/apps/projects/poker_services.py +154 -0
  454. trueppm_api/apps/projects/poker_views.py +398 -0
  455. trueppm_api/apps/projects/product_backlog_services.py +450 -0
  456. trueppm_api/apps/projects/program_label_services.py +94 -0
  457. trueppm_api/apps/projects/program_label_views.py +189 -0
  458. trueppm_api/apps/projects/program_rollup.py +903 -0
  459. trueppm_api/apps/projects/program_schedule.py +767 -0
  460. trueppm_api/apps/projects/program_views.py +3445 -0
  461. trueppm_api/apps/projects/project_templates.py +422 -0
  462. trueppm_api/apps/projects/receivers.py +234 -0
  463. trueppm_api/apps/projects/refusal_codes.py +140 -0
  464. trueppm_api/apps/projects/reorder_services.py +145 -0
  465. trueppm_api/apps/projects/restructure_hooks.py +54 -0
  466. trueppm_api/apps/projects/retro_board_services.py +368 -0
  467. trueppm_api/apps/projects/retro_services.py +285 -0
  468. trueppm_api/apps/projects/risk_import.py +396 -0
  469. trueppm_api/apps/projects/row_identity.py +113 -0
  470. trueppm_api/apps/projects/schema_migrations.py +228 -0
  471. trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
  472. trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
  473. trueppm_api/apps/projects/seed/__init__.py +61 -0
  474. trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
  475. trueppm_api/apps/projects/seed/exporter.py +1717 -0
  476. trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
  477. trueppm_api/apps/projects/seed/importer.py +2764 -0
  478. trueppm_api/apps/projects/seed/reanchor.py +551 -0
  479. trueppm_api/apps/projects/seed/reldates.py +126 -0
  480. trueppm_api/apps/projects/seed/replace.py +128 -0
  481. trueppm_api/apps/projects/seed/replay.py +1695 -0
  482. trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
  483. trueppm_api/apps/projects/seed/samples.py +427 -0
  484. trueppm_api/apps/projects/seed/validation.py +1169 -0
  485. trueppm_api/apps/projects/serializers.py +12904 -0
  486. trueppm_api/apps/projects/services.py +7525 -0
  487. trueppm_api/apps/projects/share_serializers.py +142 -0
  488. trueppm_api/apps/projects/share_services.py +373 -0
  489. trueppm_api/apps/projects/share_views.py +395 -0
  490. trueppm_api/apps/projects/sharing_settings.py +187 -0
  491. trueppm_api/apps/projects/signal_privacy_services.py +910 -0
  492. trueppm_api/apps/projects/signal_privacy_views.py +485 -0
  493. trueppm_api/apps/projects/signals.py +132 -0
  494. trueppm_api/apps/projects/slip_conflict.py +288 -0
  495. trueppm_api/apps/projects/sprint_cadence.py +453 -0
  496. trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
  497. trueppm_api/apps/projects/standup.py +304 -0
  498. trueppm_api/apps/projects/standup_views.py +99 -0
  499. trueppm_api/apps/projects/structural_operation_services.py +727 -0
  500. trueppm_api/apps/projects/structural_operation_views.py +311 -0
  501. trueppm_api/apps/projects/surface_visibility.py +126 -0
  502. trueppm_api/apps/projects/task_batch_services.py +379 -0
  503. trueppm_api/apps/projects/task_bulk.py +1244 -0
  504. trueppm_api/apps/projects/task_classification.py +561 -0
  505. trueppm_api/apps/projects/task_classification_defaults.py +95 -0
  506. trueppm_api/apps/projects/task_duration_settings.py +142 -0
  507. trueppm_api/apps/projects/task_grouping.py +754 -0
  508. trueppm_api/apps/projects/tasks.py +1979 -0
  509. trueppm_api/apps/projects/template_divergence.py +168 -0
  510. trueppm_api/apps/projects/template_services.py +160 -0
  511. trueppm_api/apps/projects/template_tasks.py +261 -0
  512. trueppm_api/apps/projects/template_views.py +829 -0
  513. trueppm_api/apps/projects/throttles.py +311 -0
  514. trueppm_api/apps/projects/urls.py +1016 -0
  515. trueppm_api/apps/projects/utilization.py +909 -0
  516. trueppm_api/apps/projects/views.py +21225 -0
  517. trueppm_api/apps/projects/wbs_paths.py +119 -0
  518. trueppm_api/apps/resources/__init__.py +0 -0
  519. trueppm_api/apps/resources/apps.py +8 -0
  520. trueppm_api/apps/resources/capacity.py +121 -0
  521. trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
  522. trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
  523. trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
  524. trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
  525. trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
  526. trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
  527. trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
  528. trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
  529. trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
  530. trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
  531. trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
  532. trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
  533. trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
  534. trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
  535. trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
  536. trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
  537. trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
  538. trueppm_api/apps/resources/migrations/__init__.py +0 -0
  539. trueppm_api/apps/resources/models.py +298 -0
  540. trueppm_api/apps/resources/serializers.py +426 -0
  541. trueppm_api/apps/resources/services.py +465 -0
  542. trueppm_api/apps/resources/urls.py +28 -0
  543. trueppm_api/apps/resources/views.py +1752 -0
  544. trueppm_api/apps/scheduling/__init__.py +0 -0
  545. trueppm_api/apps/scheduling/apps.py +153 -0
  546. trueppm_api/apps/scheduling/calendars.py +144 -0
  547. trueppm_api/apps/scheduling/deadletter.py +91 -0
  548. trueppm_api/apps/scheduling/finish_reading.py +198 -0
  549. trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
  550. trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
  551. trueppm_api/apps/scheduling/graph_guard.py +263 -0
  552. trueppm_api/apps/scheduling/management/__init__.py +0 -0
  553. trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
  554. trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
  555. trueppm_api/apps/scheduling/mc_admission.py +181 -0
  556. trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
  557. trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
  558. trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
  559. trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
  560. trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
  561. trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
  562. trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
  563. trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
  564. trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
  565. trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
  566. trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
  567. trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
  568. trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
  569. trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
  570. trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
  571. trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
  572. trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
  573. trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
  574. trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
  575. trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
  576. trueppm_api/apps/scheduling/models.py +542 -0
  577. trueppm_api/apps/scheduling/receivers.py +160 -0
  578. trueppm_api/apps/scheduling/risk_premium.py +334 -0
  579. trueppm_api/apps/scheduling/serializers.py +723 -0
  580. trueppm_api/apps/scheduling/services.py +1251 -0
  581. trueppm_api/apps/scheduling/signals.py +56 -0
  582. trueppm_api/apps/scheduling/tasks.py +2248 -0
  583. trueppm_api/apps/scheduling/telemetry.py +80 -0
  584. trueppm_api/apps/scheduling/units.py +35 -0
  585. trueppm_api/apps/scheduling/urls.py +57 -0
  586. trueppm_api/apps/scheduling/views.py +2908 -0
  587. trueppm_api/apps/sso/__init__.py +0 -0
  588. trueppm_api/apps/sso/apps.py +19 -0
  589. trueppm_api/apps/sso/extensions.py +130 -0
  590. trueppm_api/apps/sso/management/__init__.py +0 -0
  591. trueppm_api/apps/sso/management/commands/__init__.py +0 -0
  592. trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
  593. trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
  594. trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
  595. trueppm_api/apps/sso/migrations/__init__.py +0 -0
  596. trueppm_api/apps/sso/models.py +148 -0
  597. trueppm_api/apps/sso/serializers.py +446 -0
  598. trueppm_api/apps/sso/services.py +1473 -0
  599. trueppm_api/apps/sso/urls.py +47 -0
  600. trueppm_api/apps/sso/views.py +935 -0
  601. trueppm_api/apps/sync/__init__.py +0 -0
  602. trueppm_api/apps/sync/apps.py +15 -0
  603. trueppm_api/apps/sync/backfill.py +208 -0
  604. trueppm_api/apps/sync/broadcast.py +369 -0
  605. trueppm_api/apps/sync/conflict.py +262 -0
  606. trueppm_api/apps/sync/consumers.py +548 -0
  607. trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
  608. trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
  609. trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
  610. trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
  611. trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
  612. trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
  613. trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
  614. trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
  615. trueppm_api/apps/sync/migrations/__init__.py +0 -0
  616. trueppm_api/apps/sync/models.py +272 -0
  617. trueppm_api/apps/sync/pagination.py +231 -0
  618. trueppm_api/apps/sync/sequence.py +488 -0
  619. trueppm_api/apps/sync/serializers.py +688 -0
  620. trueppm_api/apps/sync/tasks.py +260 -0
  621. trueppm_api/apps/sync/throttles.py +175 -0
  622. trueppm_api/apps/sync/upload.py +556 -0
  623. trueppm_api/apps/sync/urls.py +17 -0
  624. trueppm_api/apps/sync/views.py +957 -0
  625. trueppm_api/apps/sync/ws_auth.py +193 -0
  626. trueppm_api/apps/taskruns/__init__.py +0 -0
  627. trueppm_api/apps/taskruns/apps.py +11 -0
  628. trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
  629. trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
  630. trueppm_api/apps/taskruns/models.py +68 -0
  631. trueppm_api/apps/taskruns/serializers.py +92 -0
  632. trueppm_api/apps/taskruns/tasks.py +60 -0
  633. trueppm_api/apps/taskruns/tracker.py +286 -0
  634. trueppm_api/apps/taskruns/urls.py +46 -0
  635. trueppm_api/apps/taskruns/views.py +195 -0
  636. trueppm_api/apps/teams/__init__.py +8 -0
  637. trueppm_api/apps/teams/apps.py +16 -0
  638. trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
  639. trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
  640. trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
  641. trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
  642. trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
  643. trueppm_api/apps/teams/migrations/__init__.py +0 -0
  644. trueppm_api/apps/teams/models.py +145 -0
  645. trueppm_api/apps/teams/permissions.py +149 -0
  646. trueppm_api/apps/teams/serializers.py +95 -0
  647. trueppm_api/apps/teams/services.py +440 -0
  648. trueppm_api/apps/teams/signals.py +61 -0
  649. trueppm_api/apps/teams/urls.py +34 -0
  650. trueppm_api/apps/teams/views.py +201 -0
  651. trueppm_api/apps/timetracking/__init__.py +0 -0
  652. trueppm_api/apps/timetracking/apps.py +9 -0
  653. trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
  654. trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
  655. trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
  656. trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
  657. trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
  658. trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
  659. trueppm_api/apps/timetracking/models.py +178 -0
  660. trueppm_api/apps/timetracking/serializers.py +175 -0
  661. trueppm_api/apps/timetracking/services.py +211 -0
  662. trueppm_api/apps/timetracking/urls.py +47 -0
  663. trueppm_api/apps/timetracking/views.py +597 -0
  664. trueppm_api/apps/webhooks/__init__.py +0 -0
  665. trueppm_api/apps/webhooks/apps.py +8 -0
  666. trueppm_api/apps/webhooks/backfill.py +90 -0
  667. trueppm_api/apps/webhooks/dispatch.py +279 -0
  668. trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
  669. trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
  670. trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
  671. trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
  672. trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
  673. trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
  674. trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
  675. trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
  676. trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
  677. trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
  678. trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
  679. trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
  680. trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
  681. trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
  682. trueppm_api/apps/webhooks/models.py +437 -0
  683. trueppm_api/apps/webhooks/serializers.py +326 -0
  684. trueppm_api/apps/webhooks/tasks.py +680 -0
  685. trueppm_api/apps/webhooks/urls.py +22 -0
  686. trueppm_api/apps/webhooks/views.py +533 -0
  687. trueppm_api/apps/workflow_engine/__init__.py +0 -0
  688. trueppm_api/apps/workflow_engine/apps.py +23 -0
  689. trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
  690. trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
  691. trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
  692. trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
  693. trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
  694. trueppm_api/apps/workflow_engine/models.py +265 -0
  695. trueppm_api/apps/workflow_engine/tasks.py +371 -0
  696. trueppm_api/apps/workspace/__init__.py +0 -0
  697. trueppm_api/apps/workspace/apps.py +10 -0
  698. trueppm_api/apps/workspace/backfill.py +111 -0
  699. trueppm_api/apps/workspace/export.py +254 -0
  700. trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
  701. trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
  702. trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
  703. trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
  704. trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
  705. trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
  706. trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
  707. trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
  708. trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
  709. trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
  710. trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
  711. trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
  712. trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
  713. trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
  714. trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
  715. trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
  716. trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
  717. trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
  718. trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
  719. trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
  720. trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
  721. trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
  722. trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
  723. trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
  724. trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
  725. trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
  726. trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
  727. trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
  728. trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
  729. trueppm_api/apps/workspace/migrations/__init__.py +0 -0
  730. trueppm_api/apps/workspace/models.py +857 -0
  731. trueppm_api/apps/workspace/permissions.py +187 -0
  732. trueppm_api/apps/workspace/serializers.py +590 -0
  733. trueppm_api/apps/workspace/services.py +967 -0
  734. trueppm_api/apps/workspace/signals.py +37 -0
  735. trueppm_api/apps/workspace/tasks.py +616 -0
  736. trueppm_api/apps/workspace/urls.py +113 -0
  737. trueppm_api/apps/workspace/views.py +1581 -0
  738. trueppm_api/asgi.py +49 -0
  739. trueppm_api/celery.py +40 -0
  740. trueppm_api/core/__init__.py +0 -0
  741. trueppm_api/core/admin_site.py +206 -0
  742. trueppm_api/core/auth_views.py +934 -0
  743. trueppm_api/core/constant_time.py +51 -0
  744. trueppm_api/core/csp.py +54 -0
  745. trueppm_api/core/db.py +87 -0
  746. trueppm_api/core/db_session.py +37 -0
  747. trueppm_api/core/demo_read_only.py +314 -0
  748. trueppm_api/core/exception_handlers.py +157 -0
  749. trueppm_api/core/export_downloads.py +56 -0
  750. trueppm_api/core/extension_providers.py +56 -0
  751. trueppm_api/core/extension_signals.py +147 -0
  752. trueppm_api/core/idempotent.py +260 -0
  753. trueppm_api/core/metadata.py +42 -0
  754. trueppm_api/core/middleware.py +51 -0
  755. trueppm_api/core/openapi.py +1221 -0
  756. trueppm_api/core/password_reset.py +513 -0
  757. trueppm_api/core/protect_conflict.py +100 -0
  758. trueppm_api/core/ratelimit.py +148 -0
  759. trueppm_api/core/redis_throttle.py +82 -0
  760. trueppm_api/core/refresh_cookie_policy.py +156 -0
  761. trueppm_api/core/request_body.py +86 -0
  762. trueppm_api/core/security_checks.py +1034 -0
  763. trueppm_api/core/storage_config.py +105 -0
  764. trueppm_api/core/text_decode.py +168 -0
  765. trueppm_api/core/throttling.py +514 -0
  766. trueppm_api/core/valkey.py +214 -0
  767. trueppm_api/core/valkey_cache.py +66 -0
  768. trueppm_api/core/valkey_checks.py +104 -0
  769. trueppm_api/core/valkey_config.py +39 -0
  770. trueppm_api/core/worker_broker_wait.py +149 -0
  771. trueppm_api/core/worker_heartbeat.py +134 -0
  772. trueppm_api/core/worker_selfheal.py +205 -0
  773. trueppm_api/fields.py +36 -0
  774. trueppm_api/permissions.py +30 -0
  775. trueppm_api/py.typed +0 -0
  776. trueppm_api/routing.py +11 -0
  777. trueppm_api/settings/__init__.py +0 -0
  778. trueppm_api/settings/base.py +2605 -0
  779. trueppm_api/settings/dev.py +296 -0
  780. trueppm_api/settings/prod.py +265 -0
  781. trueppm_api/urls.py +217 -0
  782. trueppm_api/workflows/__init__.py +38 -0
  783. trueppm_api/workflows/backends/__init__.py +6 -0
  784. trueppm_api/workflows/backends/default.py +297 -0
  785. trueppm_api/workflows/consumers/__init__.py +11 -0
  786. trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
  787. trueppm_api/workflows/interface.py +148 -0
  788. trueppm_api/workflows/registry.py +106 -0
  789. trueppm_api/workflows/services.py +90 -0
  790. trueppm_api/wsgi.py +11 -0
  791. trueppm_api-0.4.0.dist-info/METADATA +273 -0
  792. trueppm_api-0.4.0.dist-info/RECORD +799 -0
  793. trueppm_api-0.4.0.dist-info/WHEEL +4 -0
  794. trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
  795. trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
  796. trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
  797. trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
  798. trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
  799. trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
@@ -0,0 +1,1276 @@
1
+ """Parsers for MS Project .xml and .mpp files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import math
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ import threading
12
+ import uuid
13
+ import xml.etree.ElementTree as ET
14
+ from collections.abc import Callable
15
+ from datetime import date
16
+
17
+ from defusedxml.ElementTree import fromstring as _safe_fromstring
18
+ from django.conf import settings
19
+
20
+ from trueppm_api.apps.msproject.dataclasses import (
21
+ CONSTRAINT_TYPES_APPLIED_AS_SNET,
22
+ AssignmentData,
23
+ CalendarData,
24
+ CalendarExceptionData,
25
+ PredecessorLinkData,
26
+ ProjectData,
27
+ ResourceData,
28
+ TaskData,
29
+ )
30
+ from trueppm_api.apps.msproject.extended_attributes import (
31
+ DURATION1_FIELD_ID,
32
+ DURATION2_FIELD_ID,
33
+ DURATION3_FIELD_ID,
34
+ PERT_ALIAS_TOKENS,
35
+ PERT_ROLE_BY_FIELD_ID,
36
+ )
37
+ from trueppm_api.apps.scheduling.units import (
38
+ HOURS_PER_WORKING_DAY,
39
+ MSPDI_LAG_TENTHS_PER_WORKING_DAY,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # MS Project PredecessorLink/Type values -> TruePPM dep_type.
45
+ _LINK_TYPE_MAP = {
46
+ "0": "FF",
47
+ "1": "FS",
48
+ "2": "SF",
49
+ "3": "SS",
50
+ }
51
+
52
+ # MSPDI WeekDay/DayType (1=Sunday … 7=Saturday) -> TruePPM Calendar.working_days
53
+ # bit (Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64). DayType 0 is the
54
+ # legacy (2003-style) per-date exception form, handled separately.
55
+ _DAY_TYPE_TO_BIT = {
56
+ "1": 64, # Sunday
57
+ "2": 1, # Monday
58
+ "3": 2, # Tuesday
59
+ "4": 4, # Wednesday
60
+ "5": 8, # Thursday
61
+ "6": 16, # Friday
62
+ "7": 32, # Saturday
63
+ }
64
+
65
+ # Subprocess timeout for MPXJ (seconds).
66
+ _MPXJ_TIMEOUT = 120
67
+ _MPXJ_STDERR_KEEP = 8192
68
+
69
+ # Sane upper bound for resource MaxUnits / assignment Units (#1720). MS Project
70
+ # expresses these as fractions of a resource's capacity (1.0 = 100%); a value of
71
+ # 10 already means ten full-time equivalents. Anything past this is either a
72
+ # corrupt/hostile file or a non-finite value we clamp — the value flows into
73
+ # resource-leveling and allocation math, so we bound it rather than let inf/NaN
74
+ # or an astronomical figure poison those computations.
75
+ _MAX_UNITS = 1_000.0
76
+
77
+ # MSPDI <ConstraintType> codes and their MS Project names (#2891). Used in the
78
+ # import warnings, so an operator reading "12 tasks carry Finish No Later Than"
79
+ # sees the term their own plan uses.
80
+ MSPDI_CONSTRAINT_NAMES: dict[int, str] = {
81
+ 0: "As Soon As Possible",
82
+ 1: "As Late As Possible",
83
+ 2: "Must Start On",
84
+ 3: "Must Finish On",
85
+ 4: "Start No Earlier Than",
86
+ 5: "Start No Later Than",
87
+ 6: "Finish No Earlier Than",
88
+ 7: "Finish No Later Than",
89
+ }
90
+
91
+ #: The one code whose partial mapping needs saying out loud (see
92
+ #: ``CONSTRAINT_TYPES_APPLIED_AS_SNET`` in ``dataclasses``).
93
+ _CONSTRAINT_TYPE_PARTIALLY_APPLIED = 2
94
+
95
+ #: Codes that cost the operator nothing when dropped: MSPDI's default, and any
96
+ #: code we actually apply.
97
+ _CONSTRAINT_TYPES_NOT_A_LOSS: frozenset[int] = frozenset({0}) | CONSTRAINT_TYPES_APPLIED_AS_SNET
98
+
99
+ #: MS Project's default task priority. MSPDI writes ``<Priority>`` on *every*
100
+ #: task, so the warning has to key on a value the PM actually changed — otherwise
101
+ #: every import reports a loss that did not happen.
102
+ _MSPDI_DEFAULT_PRIORITY = 500
103
+
104
+
105
+ def _int_or_none(raw: str) -> int | None:
106
+ """Read an integer element's text, or ``None`` when it is absent/unparseable."""
107
+ try:
108
+ return int((raw or "").strip())
109
+ except ValueError:
110
+ return None
111
+
112
+
113
+ def _is_nonzero_msp_amount(raw: str) -> bool:
114
+ """True when an MSPDI duration or currency value carries a real quantity.
115
+
116
+ MS Project writes ``<Work>PT0H0M0S</Work>`` and ``<Cost>0</Cost>`` on tasks
117
+ that have neither, so "the element is present" is not evidence the PM entered
118
+ anything. Any digit other than zero is.
119
+ """
120
+ text = (raw or "").strip()
121
+ if not text:
122
+ return False
123
+ return any(ch.isdigit() and ch != "0" for ch in text)
124
+
125
+
126
+ #: MSPDI task elements TruePPM has no column for, as
127
+ #: ``(element name, warning phrase, "is this value meaningful?")``. Each entry is
128
+ #: a **deliberate** non-import, and the warning is the entire mechanism by which
129
+ #: the operator learns that — the importer previously dropped all of them with
130
+ #: ``warnings == []``, so a plan that round-tripped came back subtly different
131
+ #: with nothing said (#2891).
132
+ #:
133
+ #: The third element exists because a full MS Project export writes ``Priority``,
134
+ #: ``Work`` and ``Cost`` on every row whether or not the PM set them. Counting
135
+ #: those defaults as dropped data would bury the families that *were* set under
136
+ #: noise, and a warning nobody can act on trains people to ignore the list. So
137
+ #: each predicate answers "did this file actually carry something here?".
138
+ #:
139
+ #: Order is the order the warnings appear in the summary: schedule commitments
140
+ #: first, then the estimating fields.
141
+ _UNMAPPED_TASK_FIELDS: tuple[tuple[str, str, Callable[[str], bool]], ...] = (
142
+ ("Deadline", "deadline dates (TruePPM has no deadline field)", bool),
143
+ (
144
+ "Baseline",
145
+ "baselines (capture a TruePPM baseline after the import lands instead)",
146
+ bool,
147
+ ),
148
+ (
149
+ "Priority",
150
+ "task priority (MS Project's 0-1000 weight has no TruePPM equivalent)",
151
+ lambda raw: _int_or_none(raw) not in (None, _MSPDI_DEFAULT_PRIORITY),
152
+ ),
153
+ (
154
+ "Work",
155
+ "work / effort hours (TruePPM schedules on duration, not effort)",
156
+ _is_nonzero_msp_amount,
157
+ ),
158
+ ("Cost", "cost figures (TruePPM tracks no task cost)", _is_nonzero_msp_amount),
159
+ )
160
+
161
+
162
+ class MsProjectImportError(ValueError):
163
+ """The uploaded MS Project file is too large to import (row-count cap, #1721).
164
+
165
+ A ``ValueError`` subclass so the import task's ``except Exception`` marks the
166
+ outbox row DEAD (deterministic failure — the same bytes always exceed the
167
+ cap) and surfaces ``str(exc)`` to the UI.
168
+ """
169
+
170
+
171
+ def _finite_float(raw: str, default: float, *, low: float, high: float) -> float:
172
+ """Parse ``raw`` to a finite float clamped to ``[low, high]`` (#1720).
173
+
174
+ ``bulk_create`` bypasses the model field validators, so a crafted MSPDI file
175
+ could otherwise smuggle ``nan``, ``inf``/``-inf``, or ``1e999`` (which
176
+ Python parses to ``inf``) straight into a ``FloatField``. Non-finite floats
177
+ then poison CPM / Monte Carlo math and — because ``json.dumps`` emits bare
178
+ ``NaN``/``Infinity`` tokens — produce invalid JSON in API, sync, and webhook
179
+ bodies. We reject non-finite values by falling back to ``default`` and clamp
180
+ the finite range so the same choke point also caps absurd magnitudes.
181
+ """
182
+ try:
183
+ value = float(raw)
184
+ except (TypeError, ValueError):
185
+ return default
186
+ if not math.isfinite(value):
187
+ return default
188
+ return max(low, min(value, high))
189
+
190
+
191
+ # Upper bound matches Task.duration's MaxValueValidator(36_525) (~100 years).
192
+ # bulk_create() bypasses the model validators, so a crafted MSPDI file could
193
+ # otherwise smuggle astronomical integer values into the database (e.g.
194
+ # PT9999999999H gets us 1.25 billion days). Clamping in the helper is a single
195
+ # choke point that covers every parser caller — primary Duration, PERT
196
+ # Duration1/2/3, and any future Duration-typed ExtendedAttribute.
197
+ _MAX_DURATION_DAYS = 36_525
198
+
199
+
200
+ def _enforce_row_cap(kind: str, count: int, limit: int) -> None:
201
+ """Reject an import whose ``kind`` element count exceeds ``limit`` (#1721)."""
202
+ if count > limit:
203
+ raise MsProjectImportError(
204
+ f"MS Project file has too many {kind} ({count}); the import limit is "
205
+ f"{limit}. Split the file and import in batches."
206
+ )
207
+
208
+
209
+ def _parse_duration_to_days(duration_str: str) -> int:
210
+ """Parse MS Project ISO 8601 duration string to working days.
211
+
212
+ MS Project uses PT<hours>H<minutes>M<seconds>S for task durations where
213
+ hours represent calendar hours. We convert to working days using 8h/day.
214
+ Also handles P<n>D and P<n>DT<n>H formats. The result is clamped to
215
+ ``[0, _MAX_DURATION_DAYS]`` because ``bulk_create`` skips the model's
216
+ ``MaxValueValidator(36_525)`` — see the constant above for rationale.
217
+ """
218
+ if not duration_str:
219
+ return 1
220
+
221
+ s = duration_str.strip()
222
+ if not s.startswith("P"):
223
+ return 1
224
+
225
+ s = s[1:]
226
+ days = 0
227
+ hours = 0
228
+
229
+ if "T" in s:
230
+ day_part, time_part = s.split("T", 1)
231
+ else:
232
+ day_part = s
233
+ time_part = ""
234
+
235
+ if day_part and day_part.endswith("D"):
236
+ with contextlib.suppress(ValueError):
237
+ days = int(day_part[:-1])
238
+
239
+ if time_part:
240
+ h_idx = time_part.find("H")
241
+ if h_idx > 0:
242
+ with contextlib.suppress(ValueError):
243
+ hours = int(time_part[:h_idx])
244
+
245
+ total_days = days + (hours // HOURS_PER_WORKING_DAY)
246
+ return max(0, min(total_days, _MAX_DURATION_DAYS))
247
+
248
+
249
+ def _parse_lag_to_days(lag_tenths_of_minutes: str) -> int:
250
+ """Convert MS Project LinkLag (tenths of minutes) to working days.
251
+
252
+ MS Project stores lag in tenths of minutes, so one working day is
253
+ ``MSPDI_LAG_TENTHS_PER_WORKING_DAY`` (4800 = 480 min = 8h; #2290 unit seam).
254
+ """
255
+ if not lag_tenths_of_minutes:
256
+ return 0
257
+ try:
258
+ tenths = int(lag_tenths_of_minutes)
259
+ except ValueError:
260
+ return 0
261
+ return tenths // MSPDI_LAG_TENTHS_PER_WORKING_DAY
262
+
263
+
264
+ def _positive_int_or_none(raw: str) -> int | None:
265
+ """Parse a calendar UID reference, mapping absent/none sentinels to ``None``.
266
+
267
+ Real calendar UIDs are positive; MSPDI uses ``-1`` for "no calendar"
268
+ (task-level) and ``0`` never refers to a calendar.
269
+ """
270
+ try:
271
+ value = int(raw)
272
+ except (TypeError, ValueError):
273
+ return None
274
+ return value if value > 0 else None
275
+
276
+
277
+ def _time_to_minutes(raw: str) -> int | None:
278
+ """Parse an MSPDI time-of-day string (``HH:MM:SS``) to minutes since midnight."""
279
+ parts = raw.strip().split(":")
280
+ if len(parts) < 2:
281
+ return None
282
+ try:
283
+ hours, minutes = int(parts[0]), int(parts[1])
284
+ except ValueError:
285
+ return None
286
+ if not (0 <= hours <= 24 and 0 <= minutes < 60):
287
+ return None
288
+ return hours * 60 + minutes
289
+
290
+
291
+ def _iso_date_or_none(raw: str) -> str | None:
292
+ """Take the ``YYYY-MM-DD`` prefix of an MSPDI datetime, or None if malformed.
293
+
294
+ Also the gate on MS Project's "no value" sentinels — ``NA`` and an empty
295
+ element both fail ``fromisoformat`` and come back as None, which is what lets
296
+ ``<ActualStart>NA</ActualStart>`` stay unset rather than landing in
297
+ ``Task.actual_start`` as a fabricated date (#2891).
298
+ """
299
+ candidate = raw.strip()[:10]
300
+ try:
301
+ date.fromisoformat(candidate)
302
+ except ValueError:
303
+ return None
304
+ return candidate
305
+
306
+
307
+ def _parse_calendar_exception_dates(exc_el: ET.Element, ns: str) -> tuple[str | None, str | None]:
308
+ """Extract the (start, end) ISO dates from an ``<Exception>`` element.
309
+
310
+ MSPDI has two encodings in the wild: the schema's ``<TimePeriod>``
311
+ (``FromDate``/``ToDate``, written by MS Project 2007+ and MPXJ) and the
312
+ ``EnteredStartDate``/``EnteredFinishDate`` pair some exporters emit instead.
313
+ Prefer TimePeriod, fall back to the Entered* pair.
314
+ """
315
+ period = exc_el.find(f"{ns}TimePeriod")
316
+ if period is not None:
317
+ start = period.findtext(f"{ns}FromDate") or ""
318
+ end = period.findtext(f"{ns}ToDate") or ""
319
+ else:
320
+ start = exc_el.findtext(f"{ns}EnteredStartDate") or ""
321
+ end = exc_el.findtext(f"{ns}EnteredFinishDate") or ""
322
+ return _iso_date_or_none(start), _iso_date_or_none(end)
323
+
324
+
325
+ def _weekday_working_hours(day_el: ET.Element, ns: str) -> float:
326
+ """Sum a working ``<WeekDay>``'s ``<WorkingTime>`` shifts into hours.
327
+
328
+ Zero-length and reversed (``To <= From``) shifts are ignored so a malformed
329
+ entry contributes nothing rather than a negative total.
330
+ """
331
+ total = 0.0
332
+ times_el = day_el.find(f"{ns}WorkingTimes")
333
+ for wt in times_el.findall(f"{ns}WorkingTime") if times_el is not None else []:
334
+ frm = _time_to_minutes(wt.findtext(f"{ns}FromTime") or "")
335
+ to = _time_to_minutes(wt.findtext(f"{ns}ToTime") or "")
336
+ if frm is not None and to is not None and to > frm:
337
+ total += (to - frm) / 60.0
338
+ return total
339
+
340
+
341
+ def _legacy_weekday_exception(
342
+ day_el: ET.Element, ns: str, name: str, *, working: bool, warnings: list[str]
343
+ ) -> CalendarExceptionData | None:
344
+ """Parse a legacy (2003-style) dated ``<WeekDay DayType=0>`` exception.
345
+
346
+ These encode an exception as a dated WeekDay entry rather than an
347
+ ``<Exceptions>`` child but share the non-working-only constraint: a working
348
+ exception is unrepresentable and warned+skipped, and an undated one is
349
+ dropped silently. Returns ``None`` when nothing usable was found.
350
+ """
351
+ start, end = _parse_calendar_exception_dates(day_el, ns)
352
+ if working:
353
+ warnings.append(
354
+ f"Calendar '{name}': working-time exception "
355
+ f"{start or '?'} to {end or '?'} is not supported and was skipped"
356
+ )
357
+ return None
358
+ if start and end:
359
+ return CalendarExceptionData(start=start, end=end)
360
+ return None
361
+
362
+
363
+ def _parse_weekday_definitions(
364
+ cal_el: ET.Element, ns: str, name: str, warnings: list[str]
365
+ ) -> tuple[int, list[float], list[CalendarExceptionData]]:
366
+ """Walk ``<WeekDays>`` into (working-day mask, per-day hours, legacy exceptions).
367
+
368
+ The mask starts from the Mon-Fri default and each listed ``<WeekDay>``
369
+ overrides its own day; a resulting all-non-working week is guarded back to
370
+ Mon-Fri (the model's ``validate_working_day_mask`` invariant — a maskless
371
+ calendar would spin the engine's calendar walk, and the importer writes rows
372
+ without running field validators). Legacy dated exceptions are collected here
373
+ so they precede the ``<Exceptions>`` block's rows in the final list.
374
+ """
375
+ mask = 31 # Mon–Fri baseline; listed WeekDay entries override per day.
376
+ day_hours: list[float] = []
377
+ exceptions: list[CalendarExceptionData] = []
378
+
379
+ weekdays_el = cal_el.find(f"{ns}WeekDays")
380
+ day_els = weekdays_el.findall(f"{ns}WeekDay") if weekdays_el is not None else []
381
+ for day_el in day_els:
382
+ mask = _apply_weekday_entry(
383
+ day_el, ns, name, mask, day_hours=day_hours, exceptions=exceptions, warnings=warnings
384
+ )
385
+
386
+ if mask & 0b111_1111 == 0:
387
+ warnings.append(
388
+ f"Calendar '{name}': no working weekday defined; defaulted to Monday-Friday"
389
+ )
390
+ mask = 31
391
+
392
+ return mask, day_hours, exceptions
393
+
394
+
395
+ def _apply_weekday_entry(
396
+ day_el: ET.Element,
397
+ ns: str,
398
+ name: str,
399
+ mask: int,
400
+ *,
401
+ day_hours: list[float],
402
+ exceptions: list[CalendarExceptionData],
403
+ warnings: list[str],
404
+ ) -> int:
405
+ """Fold one ``<WeekDay>`` entry into the mask, appending any hours/exception.
406
+
407
+ Returns the updated mask. ``day_hours`` and ``exceptions`` are appended in
408
+ place (the same accumulate-in-place convention the surrounding parser uses
409
+ for ``warnings``). A ``DayType=0`` entry is a legacy dated exception; an
410
+ unrecognized DayType is ignored.
411
+ """
412
+ day_type = (day_el.findtext(f"{ns}DayType") or "").strip()
413
+ working = (day_el.findtext(f"{ns}DayWorking") or "").strip() == "1"
414
+ if day_type == "0":
415
+ exc = _legacy_weekday_exception(day_el, ns, name, working=working, warnings=warnings)
416
+ if exc is not None:
417
+ exceptions.append(exc)
418
+ return mask
419
+ bit = _DAY_TYPE_TO_BIT.get(day_type)
420
+ if bit is None:
421
+ return mask
422
+ if working:
423
+ mask |= bit
424
+ hours = _weekday_working_hours(day_el, ns)
425
+ if hours > 0:
426
+ day_hours.append(hours)
427
+ else:
428
+ mask &= ~bit
429
+ return mask
430
+
431
+
432
+ def _parse_calendar_exceptions(
433
+ cal_el: ET.Element, ns: str, name: str, warnings: list[str]
434
+ ) -> list[CalendarExceptionData]:
435
+ """Parse the ``<Exceptions>`` block into non-working ``CalendarExceptionData`` rows.
436
+
437
+ Working exceptions (``DayWorking=1``, e.g. a make-up Saturday) cannot be
438
+ expressed by ``CalendarException`` (non-working only) and are warned+skipped,
439
+ as are entries whose date range is missing or reversed.
440
+ """
441
+ exceptions: list[CalendarExceptionData] = []
442
+ exceptions_el = cal_el.find(f"{ns}Exceptions")
443
+ for exc_el in exceptions_el.findall(f"{ns}Exception") if exceptions_el is not None else []:
444
+ exc_name = (exc_el.findtext(f"{ns}Name") or "").strip()
445
+ start, end = _parse_calendar_exception_dates(exc_el, ns)
446
+ working = (exc_el.findtext(f"{ns}DayWorking") or "0").strip() == "1"
447
+ if working:
448
+ warnings.append(
449
+ f"Calendar '{name}': working-time exception "
450
+ f"'{exc_name or (start or '?')}' is not supported and was skipped"
451
+ )
452
+ continue
453
+ if start is None or end is None or end < start:
454
+ warnings.append(
455
+ f"Calendar '{name}': exception '{exc_name or '?'}' has an "
456
+ f"unparseable date range and was skipped"
457
+ )
458
+ continue
459
+ exceptions.append(CalendarExceptionData(start=start, end=end, name=exc_name))
460
+ return exceptions
461
+
462
+
463
+ def _modal_hours_per_day(day_hours: list[float]) -> float:
464
+ """Collapse per-day working totals to the single ``Calendar.hours_per_day`` scalar.
465
+
466
+ Most common daily total wins (ties break toward the first seen): one scalar
467
+ has to represent the week, and the modal day length is what duration
468
+ conversion (days -> real dates) should assume. Defaults to 8.0 with no data
469
+ and is clamped to a sane [1, 24] range.
470
+ """
471
+ if not day_hours:
472
+ return 8.0
473
+ hours = max(day_hours, key=day_hours.count)
474
+ return max(1.0, min(hours, 24.0))
475
+
476
+
477
+ def _parse_one_calendar(cal_el: ET.Element, ns: str, warnings: list[str]) -> CalendarData | None:
478
+ """Parse a single ``<Calendar>`` element, or ``None`` to skip it.
479
+
480
+ Non-integer UIDs and non-base calendars (``IsBaseCalendar != 1``) are
481
+ skipped; per-resource calendars have no TruePPM equivalent and importing
482
+ them would only pollute the shared calendar library.
483
+ """
484
+ uid_str = (cal_el.findtext(f"{ns}UID") or "").strip()
485
+ try:
486
+ uid = int(uid_str)
487
+ except ValueError:
488
+ return None
489
+ if (cal_el.findtext(f"{ns}IsBaseCalendar") or "").strip() != "1":
490
+ return None
491
+ name = (cal_el.findtext(f"{ns}Name") or "").strip() or f"Imported calendar {uid}"
492
+
493
+ mask, day_hours, exceptions = _parse_weekday_definitions(cal_el, ns, name, warnings)
494
+ # Legacy dated exceptions (from the WeekDays walk) precede the <Exceptions>
495
+ # block's rows, matching the original single-pass append order.
496
+ exceptions.extend(_parse_calendar_exceptions(cal_el, ns, name, warnings))
497
+
498
+ return CalendarData(
499
+ uid=uid,
500
+ name=name,
501
+ working_days=mask,
502
+ hours_per_day=_modal_hours_per_day(day_hours),
503
+ exceptions=exceptions,
504
+ )
505
+
506
+
507
+ def _parse_calendars(root: ET.Element, ns: str, warnings: list[str]) -> list[CalendarData]:
508
+ """Parse the ``<Calendars>`` block into ``CalendarData`` rows (#1769).
509
+
510
+ Only *base* calendars are returned: MS Project requires project and task
511
+ calendars to be base calendars, and per-resource calendars
512
+ (``IsBaseCalendar=0``, one auto-generated per resource) have no TruePPM
513
+ equivalent — importing them would only pollute the shared calendar library.
514
+
515
+ Fidelity notes (each degradation is deliberate; see the field-coverage
516
+ matrix in the docs):
517
+
518
+ - The weekday mask starts from the Mon-Fri default and applies whatever
519
+ ``<WeekDay>`` entries the file lists. Genuine MS Project exports list all
520
+ seven days, fully overwriting the default; sparse third-party files only
521
+ shift the days they mention instead of losing the whole week.
522
+ - ``Calendar.hours_per_day`` is a single scalar, so per-day
523
+ ``<WorkingTimes>`` collapse to the most common daily total across working
524
+ days (ties break toward the first seen). Shift start/end times are not
525
+ preserved — TruePPM schedules in whole working days.
526
+ - Exceptions that *add* working time (``DayWorking=1``, e.g. a make-up
527
+ Saturday) cannot be expressed by ``CalendarException`` (non-working only)
528
+ and are skipped with a warning.
529
+ """
530
+ calendars_el = root.find(f"{ns}Calendars")
531
+ if calendars_el is None:
532
+ return []
533
+
534
+ calendars: list[CalendarData] = []
535
+ for cal_el in calendars_el.findall(f"{ns}Calendar"):
536
+ cal = _parse_one_calendar(cal_el, ns, warnings)
537
+ if cal is not None:
538
+ calendars.append(cal)
539
+ return calendars
540
+
541
+
542
+ def _child_text(el: ET.Element, ns: str, tag: str) -> str:
543
+ """Return a namespaced child's text, or ``""`` when absent or empty."""
544
+ child = el.find(f"{ns}{tag}")
545
+ return child.text if child is not None and child.text else ""
546
+
547
+
548
+ def _enforce_row_caps(root: ET.Element, ns: str) -> None:
549
+ """Reject an oversized import before any objects are materialized (#1721).
550
+
551
+ The upload SIZE is bounded but the row count is not. A 50 MB MSPDI encodes
552
+ ~1M tasks; without this cap the importer builds ~1M Task objects, computes
553
+ WBS over all of them, and bulk-creates the lot — exhausting worker memory
554
+ and holding a giant transaction open. Reject outright (like the risk-CSV
555
+ importer's MAX_ROWS) before building anything, counting directly off the
556
+ element tree so we never materialize the objects.
557
+ """
558
+ max_rows = getattr(settings, "MSPROJECT_MAX_ROWS", 20_000)
559
+ _enforce_row_cap("tasks", len(root.findall(f".//{ns}Task")), max_rows)
560
+ _enforce_row_cap("resources", len(root.findall(f".//{ns}Resource")), max_rows)
561
+ _enforce_row_cap("dependencies", len(root.findall(f".//{ns}PredecessorLink")), max_rows)
562
+ _enforce_row_cap("calendars", len(root.findall(f".//{ns}Calendar")), max_rows)
563
+ _enforce_row_cap("calendar exceptions", len(root.findall(f".//{ns}Exception")), max_rows)
564
+
565
+
566
+ def _parse_resources(
567
+ root: ET.Element, ns: str
568
+ ) -> tuple[list[ResourceData], dict[int, ResourceData]]:
569
+ """Parse ``<Resources>`` into (ordered list, ``{uid: ResourceData}`` lookup)."""
570
+ resources: list[ResourceData] = []
571
+ resource_map: dict[int, ResourceData] = {}
572
+ resources_el = root.find(f"{ns}Resources")
573
+ if resources_el is None:
574
+ return resources, resource_map
575
+ for res_el in resources_el.findall(f"{ns}Resource"):
576
+ uid_str = _child_text(res_el, ns, "UID")
577
+ name = _child_text(res_el, ns, "Name")
578
+ if not uid_str or not name:
579
+ continue
580
+ uid = int(uid_str)
581
+ if uid == 0:
582
+ continue
583
+ max_units_str = _child_text(res_el, ns, "MaxUnits")
584
+ # Finite-guard + clamp (#1720): reject nan/inf/1e999 and cap absurd
585
+ # capacities before the value reaches a bulk_create'd FloatField.
586
+ max_units = (
587
+ _finite_float(max_units_str, 1.0, low=0.0, high=_MAX_UNITS) if max_units_str else 1.0
588
+ )
589
+ rd = ResourceData(uid=uid, name=name, max_units=max_units)
590
+ resource_map[uid] = rd
591
+ resources.append(rd)
592
+ return resources, resource_map
593
+
594
+
595
+ def _parse_assignments(
596
+ root: ET.Element, ns: str, resource_map: dict[int, ResourceData]
597
+ ) -> dict[int, list[AssignmentData]]:
598
+ """Parse ``<Assignments>`` into ``{task_uid: [AssignmentData]}`` (indexed by task UID)."""
599
+ task_assignments: dict[int, list[AssignmentData]] = {}
600
+ assignments_el = root.find(f"{ns}Assignments")
601
+ if assignments_el is None:
602
+ return task_assignments
603
+ for asgn_el in assignments_el.findall(f"{ns}Assignment"):
604
+ task_uid_str = _child_text(asgn_el, ns, "TaskUID")
605
+ res_uid_str = _child_text(asgn_el, ns, "ResourceUID")
606
+ if not task_uid_str or not res_uid_str:
607
+ continue
608
+ task_uid = int(task_uid_str)
609
+ res_uid = int(res_uid_str)
610
+ if res_uid == 0 or res_uid not in resource_map:
611
+ continue
612
+ units_str = _child_text(asgn_el, ns, "Units")
613
+ # Finite-guard + clamp (#1720), same rationale as MaxUnits above.
614
+ units = _finite_float(units_str, 1.0, low=0.0, high=_MAX_UNITS) if units_str else 1.0
615
+ task_assignments.setdefault(task_uid, []).append(
616
+ AssignmentData(task_uid=task_uid, resource_uid=res_uid, units=units)
617
+ )
618
+ return task_assignments
619
+
620
+
621
+ def _parse_predecessor_links(task_el: ET.Element, ns: str) -> list[PredecessorLinkData]:
622
+ """Parse a task's ``<PredecessorLink>`` children into dependency rows."""
623
+ pred_links: list[PredecessorLinkData] = []
624
+ for pred_el in task_el.findall(f"{ns}PredecessorLink"):
625
+ pred_uid_str = _child_text(pred_el, ns, "PredecessorUID")
626
+ if not pred_uid_str:
627
+ continue
628
+ link_type_str = _child_text(pred_el, ns, "Type") or "1"
629
+ link_lag_str = _child_text(pred_el, ns, "LinkLag") or "0"
630
+ pred_links.append(
631
+ PredecessorLinkData(
632
+ predecessor_uid=int(pred_uid_str),
633
+ dep_type=_LINK_TYPE_MAP.get(link_type_str, "FS"),
634
+ lag_days=_parse_lag_to_days(link_lag_str),
635
+ )
636
+ )
637
+ return pred_links
638
+
639
+
640
+ def _resolve_task_pert_estimates(
641
+ task_el: ET.Element,
642
+ ns: str,
643
+ pert_role_to_field_id: dict[str, str],
644
+ task_name: str,
645
+ *,
646
+ is_milestone: bool,
647
+ warnings: list[str],
648
+ ) -> tuple[int | None, int | None, int | None]:
649
+ """Resolve a task's ``(optimistic, most_likely, pessimistic)`` PERT triple.
650
+
651
+ PERT three-point values come in as per-task ``<ExtendedAttribute>`` children
652
+ matching the FieldIDs detected at project level. Values are ISO-8601
653
+ durations parsed via the same ``_parse_duration_to_days`` helper used for the
654
+ primary Duration field. The triple is dropped (all three set to ``None``)
655
+ when it cannot be safely imported:
656
+
657
+ - Milestones (ADR-0093 Q5): MS Project leaves these empty in practice; if a
658
+ file violates that we still drop — silently, since milestones with
659
+ three-point values are common file noise from PMs who blanket-applied
660
+ estimates and there's nothing the user needs to do.
661
+ - All-or-none (Q3): the scheduler engine requires all three for PERT-Beta
662
+ sampling (engine.py:892), so a partial triple would surface as a
663
+ half-populated UI with no Monte Carlo effect — drop all three and warn.
664
+ - Ordering (#2002): even a complete triple is unusable if it violates
665
+ optimistic <= most_likely <= pessimistic. The engine rejects it
666
+ (engine._validate_project) and, because the importer marks estimates
667
+ accepted, the invalid row would detonate the first CPM/Monte-Carlo run
668
+ after import — drop all three and warn.
669
+ """
670
+ opt_days, ml_days, pess_days = (
671
+ _extract_pert_task_values(task_el, ns, pert_role_to_field_id)
672
+ if pert_role_to_field_id
673
+ else (None, None, None)
674
+ )
675
+ if is_milestone and (opt_days, ml_days, pess_days) != (None, None, None):
676
+ opt_days = ml_days = pess_days = None
677
+
678
+ present = sum(v is not None for v in (opt_days, ml_days, pess_days))
679
+ if 0 < present < 3:
680
+ missing = []
681
+ if opt_days is None:
682
+ missing.append("Optimistic")
683
+ if ml_days is None:
684
+ missing.append("Most Likely")
685
+ if pess_days is None:
686
+ missing.append("Pessimistic")
687
+ warnings.append(
688
+ f"Task '{task_name}': partial three-point estimate "
689
+ f"(missing {', '.join(missing)}), all three values skipped"
690
+ )
691
+ opt_days = ml_days = pess_days = None
692
+
693
+ if (
694
+ opt_days is not None
695
+ and ml_days is not None
696
+ and pess_days is not None
697
+ and not opt_days <= ml_days <= pess_days
698
+ ):
699
+ warnings.append(
700
+ f"Task '{task_name}': three-point estimate out of order "
701
+ f"(optimistic ≤ most likely ≤ pessimistic required; got "
702
+ f"{opt_days} ≤ {ml_days} ≤ {pess_days}), all three values skipped"
703
+ )
704
+ opt_days = ml_days = pess_days = None
705
+
706
+ return opt_days, ml_days, pess_days
707
+
708
+
709
+ def _derive_task_status(start_str: str, raw_percent: float) -> str | None:
710
+ """Derive task status from the RAW file percent, gated on having a start (#1768).
711
+
712
+ Keyed on the raw 0-100 value so it is independent of how percent is stored
713
+ downstream (the 0-1-vs-0-100 question, #1759). A task with no start has its
714
+ percent clamped to 0 by the importer (ADR-0057 Q5), so it must stay
715
+ NOT_STARTED — return ``None`` (the importer's default) rather than
716
+ IN_PROGRESS, so status and progress can never disagree.
717
+ """
718
+ from trueppm_api.apps.projects.models import TaskStatus
719
+
720
+ if not start_str:
721
+ return None
722
+ if raw_percent >= 100.0:
723
+ return TaskStatus.COMPLETE.value
724
+ if raw_percent > 0.0:
725
+ return TaskStatus.IN_PROGRESS.value
726
+ return None
727
+
728
+
729
+ def _parse_one_task(
730
+ task_el: ET.Element,
731
+ ns: str,
732
+ pert_role_to_field_id: dict[str, str],
733
+ task_assignments: dict[int, list[AssignmentData]],
734
+ warnings: list[str],
735
+ ) -> TaskData | None:
736
+ """Parse a single ``<Task>`` element into ``TaskData``, or ``None`` to skip it.
737
+
738
+ Skips UID-less / UID-0 rows and warns on a missing name — the same guards as
739
+ the original single-pass loop.
740
+ """
741
+ uid_str = _child_text(task_el, ns, "UID")
742
+ name = _child_text(task_el, ns, "Name")
743
+ if not uid_str:
744
+ return None
745
+ uid = int(uid_str)
746
+ if uid == 0:
747
+ return None
748
+ if not name:
749
+ warnings.append(f"Task UID {uid}: missing name, skipped")
750
+ return None
751
+
752
+ outline_level_str = _child_text(task_el, ns, "OutlineLevel")
753
+ pct_str = _child_text(task_el, ns, "PercentComplete")
754
+ start_str = _child_text(task_el, ns, "Start")
755
+ # Summary detection happens later (the importer rebuilds WBS), so we only
756
+ # know milestones here; summary filtering is left to the importer.
757
+ is_milestone = _child_text(task_el, ns, "Milestone") == "1"
758
+
759
+ opt_days, ml_days, pess_days = _resolve_task_pert_estimates(
760
+ task_el,
761
+ ns,
762
+ pert_role_to_field_id,
763
+ name,
764
+ is_milestone=is_milestone,
765
+ warnings=warnings,
766
+ )
767
+
768
+ # Finite-guard + clamp to [0, 100] (#1720): MS Project PercentComplete is
769
+ # 0-100. nan/inf/1e999 (and any out-of-range figure) is rejected/clamped
770
+ # before it reaches progress + EVM math. Task.percent_complete is the same
771
+ # 0-100 scale, so we keep the value on that scale (an earlier /100 divided it
772
+ # into a 0-1 fraction that the importer then wrote straight into the 0-100
773
+ # field — a 75% task landed as 0.75%). The same value drives status inference.
774
+ raw_percent = _finite_float(pct_str, 0.0, low=0.0, high=100.0) if pct_str else 0.0
775
+
776
+ return TaskData(
777
+ uid=uid,
778
+ name=name,
779
+ duration_days=_parse_duration_to_days(_child_text(task_el, ns, "Duration")),
780
+ outline_number=_child_text(task_el, ns, "OutlineNumber"),
781
+ outline_level=int(outline_level_str) if outline_level_str else 0,
782
+ is_milestone=is_milestone,
783
+ percent_complete=raw_percent,
784
+ status=_derive_task_status(start_str, raw_percent),
785
+ notes=_child_text(task_el, ns, "Notes"),
786
+ start=start_str[:10] if start_str else None,
787
+ calendar_uid=_positive_int_or_none(_child_text(task_el, ns, "CalendarUID")),
788
+ optimistic_duration_days=opt_days,
789
+ most_likely_duration_days=ml_days,
790
+ pessimistic_duration_days=pess_days,
791
+ constraint_type=_int_or_none(_child_text(task_el, ns, "ConstraintType")),
792
+ constraint_date=_iso_date_or_none(_child_text(task_el, ns, "ConstraintDate")),
793
+ actual_start=_iso_date_or_none(_child_text(task_el, ns, "ActualStart")),
794
+ actual_finish=_iso_date_or_none(_child_text(task_el, ns, "ActualFinish")),
795
+ predecessor_links=_parse_predecessor_links(task_el, ns),
796
+ resource_assignments=task_assignments.get(uid, []),
797
+ )
798
+
799
+
800
+ def _warn_unmapped_task_fields(tasks_el: ET.Element, ns: str, warnings: list[str]) -> None:
801
+ """Report every field family the import does **not** carry over, with counts.
802
+
803
+ This is the whole of #2891's "minimum for beta": before it, an MSPDI carrying
804
+ constraints, deadlines, baselines, priorities, work and cost imported with
805
+ ``ProjectData.warnings == []``. A PM migrating a plan got dates TruePPM had
806
+ recomputed from scratch, believed they were the dates they had committed to,
807
+ and found out at the next governance review.
808
+
809
+ Counts rather than a flat list of names, because "3 of 480 tasks" and "412 of
810
+ 480 tasks" are different decisions for the operator: the first is a note, the
811
+ second means the migration needs a plan.
812
+
813
+ Scans the raw XML rather than the parsed ``TaskData`` deliberately — most of
814
+ these families have no dataclass field to inspect, and adding one per family
815
+ purely to count it would imply the importer does something with it.
816
+ """
817
+ task_elements = tasks_el.findall(f"{ns}Task")
818
+ total = len(task_elements)
819
+ if not total:
820
+ return
821
+
822
+ for element_name, phrase, is_meaningful in _UNMAPPED_TASK_FIELDS:
823
+ count = sum(
824
+ 1
825
+ for task_el in task_elements
826
+ if _carries_value(task_el, ns, element_name, is_meaningful)
827
+ )
828
+ if count:
829
+ warnings.append(f"Not imported: {phrase} — set on {count} of {total} tasks.")
830
+
831
+ _warn_unmapped_constraints(task_elements, ns, total, warnings)
832
+
833
+
834
+ def _carries_value(
835
+ task_el: ET.Element, ns: str, element_name: str, is_meaningful: Callable[[str], bool]
836
+ ) -> bool:
837
+ """True when a task element carries a value worth reporting as dropped.
838
+
839
+ ``<Baseline>`` is a container (its data lives in child ``<Start>`` /
840
+ ``<Finish>`` elements, not in its own text), so presence-with-children counts
841
+ as a value; every other family is judged on its text by its own predicate.
842
+ """
843
+ child = task_el.find(f"{ns}{element_name}")
844
+ if child is None:
845
+ return False
846
+ if len(child):
847
+ return True
848
+ return is_meaningful(child.text or "")
849
+
850
+
851
+ def _warn_unmapped_constraints(
852
+ task_elements: list[ET.Element], ns: str, total: int, warnings: list[str]
853
+ ) -> None:
854
+ """Report constraint codes TruePPM cannot express, grouped by MS Project name.
855
+
856
+ Split out from the table-driven families above because a constraint is not
857
+ all-or-nothing: two of the eight codes land on ``Task.planned_start`` and the
858
+ rest have no home, so the warning has to name *which* ones were lost. A
859
+ single "constraints were dropped" line would be wrong for the file whose
860
+ constraints were all SNET and carried across perfectly.
861
+ """
862
+ unmapped: dict[int, int] = {}
863
+ partially_applied = 0
864
+ # Codes outside MSPDI's defined 0-7 are collapsed into ONE bucket rather than
865
+ # getting a line each. Two reasons, both structural: the code is read straight
866
+ # off the file, so a per-code line makes the *number of warnings* attacker-
867
+ # chosen (20,000 tasks each declaring a different code = 20,000 lines, ~3.4 MB
868
+ # in one `TaskRun.result_summary` JSONField), and this list is now served over
869
+ # the API rather than being write-only — so its length needs a bound at the
870
+ # point of writing, not at the point of reading. The same reasoning already
871
+ # applies per-string to the PERT `<Alias>` warning below, which truncates
872
+ # reflected text to 100 chars; length is the axis that became reachable.
873
+ unrecognized_tasks = 0
874
+ unrecognized_codes: set[int] = set()
875
+ for task_el in task_elements:
876
+ code = _int_or_none(_child_text(task_el, ns, "ConstraintType"))
877
+ if code is None:
878
+ continue
879
+ if code == _CONSTRAINT_TYPE_PARTIALLY_APPLIED:
880
+ partially_applied += 1
881
+ elif code in MSPDI_CONSTRAINT_NAMES:
882
+ if code not in _CONSTRAINT_TYPES_NOT_A_LOSS:
883
+ unmapped[code] = unmapped.get(code, 0) + 1
884
+ else:
885
+ unrecognized_tasks += 1
886
+ unrecognized_codes.add(code)
887
+
888
+ # Bounded by construction: at most one line per *defined* MSPDI code.
889
+ for code in sorted(unmapped):
890
+ warnings.append(
891
+ f"Not imported: '{MSPDI_CONSTRAINT_NAMES[code]}' constraints — set on "
892
+ f"{unmapped[code]} of {total} tasks. TruePPM models only "
893
+ "start-no-earlier-than, so these tasks are scheduled from their "
894
+ "dependencies alone."
895
+ )
896
+ if unrecognized_tasks:
897
+ # Deliberately reports how *many* distinct codes, never the codes
898
+ # themselves: a `<ConstraintType>` integer is unbounded up to Python's
899
+ # 4,300-digit int limit, so echoing it hands the file control of this
900
+ # string's length.
901
+ warnings.append(
902
+ f"Not imported: {len(unrecognized_codes)} constraint type(s) this file "
903
+ f"declares that are not defined by MS Project — found on "
904
+ f"{unrecognized_tasks} of {total} tasks. Those tasks are scheduled from "
905
+ "their dependencies alone."
906
+ )
907
+ if partially_applied:
908
+ name = MSPDI_CONSTRAINT_NAMES[_CONSTRAINT_TYPE_PARTIALLY_APPLIED]
909
+ warnings.append(
910
+ f"Partially imported: '{name}' constraints on {partially_applied} of "
911
+ f"{total} tasks became start-no-earlier-than dates. The start is "
912
+ "pinned; TruePPM cannot also stop the task starting later."
913
+ )
914
+
915
+
916
+ def _parse_tasks(
917
+ root: ET.Element,
918
+ ns: str,
919
+ pert_role_to_field_id: dict[str, str],
920
+ task_assignments: dict[int, list[AssignmentData]],
921
+ warnings: list[str],
922
+ ) -> list[TaskData]:
923
+ """Parse ``<Tasks>`` into ``TaskData`` rows (per-task skips/warnings applied)."""
924
+ tasks: list[TaskData] = []
925
+ tasks_el = root.find(f"{ns}Tasks")
926
+ if tasks_el is None:
927
+ return tasks
928
+ # Before building anything: account for what this file carries that the
929
+ # import will not (#2891). Runs first so the "what was dropped" lines lead
930
+ # the summary rather than trailing the per-task notes.
931
+ _warn_unmapped_task_fields(tasks_el, ns, warnings)
932
+ for task_el in tasks_el.findall(f"{ns}Task"):
933
+ td = _parse_one_task(task_el, ns, pert_role_to_field_id, task_assignments, warnings)
934
+ if td is not None:
935
+ tasks.append(td)
936
+ _reconcile_actuals(tasks, warnings)
937
+ return tasks
938
+
939
+
940
+ def _reconcile_actuals(tasks: list[TaskData], warnings: list[str]) -> None:
941
+ """Drop an actual date the file's own progress contradicts, and say how many.
942
+
943
+ On the REST path an actual date only ever appears as a *consequence* of a
944
+ status transition — ``TaskSerializer`` sets ``actual_start`` on the move to
945
+ IN_PROGRESS and ``actual_finish`` on COMPLETE, and clears the latter on
946
+ reopen. The importer writes through ``bulk_create``, which runs no validation
947
+ and no model ``clean()``, so importing an actual date straight from the file
948
+ creates states the API itself cannot reach: an ``<ActualFinish>`` on a task
949
+ the same file declares 0% complete lands as a finished-but-not-started task.
950
+
951
+ That is not a cosmetic inconsistency. ``actual_finish`` is read
952
+ unconditionally downstream — schedule variance, and the sprint forecast
953
+ resolver, which returns it as the authoritative date the moment it is
954
+ non-null — so an unreconciled actual silently corrupts variance and forecast
955
+ reporting rather than looking wrong on the task.
956
+
957
+ So the file's *progress* stays authoritative and the contradicting actual is
958
+ dropped, matching this module's standing contract everywhere else: keep the
959
+ row, drop the offending field, and report it. Reported as two aggregate lines
960
+ rather than one per task, so a 20,000-row file cannot turn this into 20,000
961
+ warnings.
962
+ """
963
+ # Imported lazily, exactly as `_derive_task_status` does and for the same
964
+ # reason: this module stays ORM-free at import time. Reading the enum rather
965
+ # than hardcoding its two values also means the pair cannot silently drift out
966
+ # of lockstep with the model.
967
+ from trueppm_api.apps.projects.models import TaskStatus
968
+
969
+ terminal_statuses = {TaskStatus.REVIEW.value, TaskStatus.COMPLETE.value}
970
+
971
+ dropped_starts = 0
972
+ dropped_finishes = 0
973
+ for td in tasks:
974
+ # A start with no progress at all is the contradiction; any progress, or
975
+ # an explicitly terminal status from the source, makes it coherent.
976
+ terminal = td.status in terminal_statuses
977
+ if td.actual_start is not None and td.percent_complete <= 0.0 and not terminal:
978
+ td.actual_start = None
979
+ dropped_starts += 1
980
+ # A finish only makes sense on work the file says is done.
981
+ if td.actual_finish is not None and td.percent_complete < 100.0 and not terminal:
982
+ td.actual_finish = None
983
+ dropped_finishes += 1
984
+ # And a finish can never precede the start it belongs to.
985
+ if (
986
+ td.actual_start is not None
987
+ and td.actual_finish is not None
988
+ and td.actual_finish < td.actual_start
989
+ ):
990
+ td.actual_finish = None
991
+ dropped_finishes += 1
992
+
993
+ if dropped_starts:
994
+ warnings.append(
995
+ f"Not imported: actual start dates on {dropped_starts} task(s) — the file "
996
+ "reports no progress on them, and TruePPM cannot record work as started "
997
+ "on a task that has not started."
998
+ )
999
+ if dropped_finishes:
1000
+ warnings.append(
1001
+ f"Not imported: actual finish dates on {dropped_finishes} task(s) — the "
1002
+ "file does not report them as complete, or the finish preceded the start."
1003
+ )
1004
+
1005
+
1006
+ def parse_xml(xml_content: bytes) -> ProjectData:
1007
+ """Parse MS Project XML content into a ProjectData structure.
1008
+
1009
+ Handles both namespaced (Project 2003+) and non-namespaced XML.
1010
+ """
1011
+ # Parse with defusedxml (#771): it forbids entity expansion and external-
1012
+ # entity resolution by default, defending against billion-laughs / XXE on the
1013
+ # user-uploaded file. The 10 MB upload cap in MsProjectImportView bounds size
1014
+ # but does NOT stop entity expansion — that was the prior, incorrect rationale.
1015
+ root = _safe_fromstring(xml_content)
1016
+
1017
+ # Detect namespace
1018
+ ns = ""
1019
+ if root.tag.startswith("{"):
1020
+ ns = root.tag.split("}")[0] + "}"
1021
+
1022
+ _enforce_row_caps(root, ns)
1023
+
1024
+ project_data = ProjectData(
1025
+ name=_child_text(root, ns, "Name"),
1026
+ start_date=(
1027
+ _child_text(root, ns, "StartDate")[:10] if _child_text(root, ns, "StartDate") else None
1028
+ ),
1029
+ calendar_uid=_positive_int_or_none(_child_text(root, ns, "CalendarUID")),
1030
+ )
1031
+
1032
+ # --- Parse calendars (#1769) ---
1033
+ project_data.calendars = _parse_calendars(root, ns, project_data.warnings)
1034
+
1035
+ # --- Parse project-level ExtendedAttribute definitions (#798, ADR-0093) ---
1036
+ # Build a {role: field_id} map (e.g. {"optimistic": "188743783"}) for the
1037
+ # three PERT roles. Files with no ExtendedAttributes block produce an empty
1038
+ # map and three-point import is silently skipped.
1039
+ pert_role_to_field_id = _parse_pert_extended_attribute_defs(root, ns, project_data.warnings)
1040
+
1041
+ # --- Parse resources, assignments, and tasks ---
1042
+ project_data.resources, resource_map = _parse_resources(root, ns)
1043
+ task_assignments = _parse_assignments(root, ns, resource_map)
1044
+ project_data.tasks = _parse_tasks(
1045
+ root, ns, pert_role_to_field_id, task_assignments, project_data.warnings
1046
+ )
1047
+
1048
+ return project_data
1049
+
1050
+
1051
+ def _parse_pert_extended_attribute_defs(
1052
+ root: ET.Element, ns: str, warnings: list[str]
1053
+ ) -> dict[str, str]:
1054
+ """Detect which FieldIDs the file uses for the three PERT roles.
1055
+
1056
+ Walks ``<ExtendedAttributes>/<ExtendedAttribute>`` and returns
1057
+ ``{role: field_id}`` for roles (optimistic / most_likely / pessimistic)
1058
+ backed by the canonical Duration1/2/3 FieldIDs. Duration4 is informational
1059
+ (formula slot) and ignored.
1060
+
1061
+ Refuses to bind a role when the FieldID matches but the alias text
1062
+ contradicts (e.g. someone repurposed Duration1 with alias 'Risk Score') —
1063
+ the FieldID is the interchange contract, but a contradicting alias is
1064
+ strong evidence the slot has been reused.
1065
+ """
1066
+ ea_block = root.find(f"{ns}ExtendedAttributes")
1067
+ if ea_block is None:
1068
+ return {}
1069
+ bound: dict[str, str] = {}
1070
+ for ea in ea_block.findall(f"{ns}ExtendedAttribute"):
1071
+ binding = _bind_pert_role(ea, ns, warnings)
1072
+ if binding is not None:
1073
+ role, field_id = binding
1074
+ bound[role] = field_id
1075
+ return bound
1076
+
1077
+
1078
+ def _bind_pert_role(ea: ET.Element, ns: str, warnings: list[str]) -> tuple[str, str] | None:
1079
+ """Resolve one ``<ExtendedAttribute>`` to a ``(role, field_id)`` PERT binding.
1080
+
1081
+ Returns ``None`` when the element is not a bindable PERT slot: missing/empty
1082
+ FieldID, a non-PERT or formula (Duration4) FieldID, or a FieldID whose alias
1083
+ text contradicts the slot (evidence the slot has been repurposed — warned).
1084
+ """
1085
+ fid_el = ea.find(f"{ns}FieldID")
1086
+ if fid_el is None:
1087
+ return None
1088
+ field_id = (fid_el.text or "").strip()
1089
+ if not field_id:
1090
+ return None
1091
+ role = PERT_ROLE_BY_FIELD_ID.get(field_id)
1092
+ if role is None or role == "expected":
1093
+ # Either not a PERT slot, or the formula slot (Duration4) we never import.
1094
+ return None
1095
+ alias_el = ea.find(f"{ns}Alias")
1096
+ alias_raw = alias_el.text if alias_el is not None else None
1097
+ alias_text = (alias_raw or "").strip().lower()
1098
+ if alias_text:
1099
+ expected_tokens = PERT_ALIAS_TOKENS.get(field_id, ())
1100
+ if not any(tok in alias_text for tok in expected_tokens):
1101
+ # Truncate the reflected alias before surfacing it in the import
1102
+ # summary — a multi-megabyte <Alias> would bloat the warnings list
1103
+ # (and any frontend rendering it). 100 chars is enough to diagnose.
1104
+ alias_display = (alias_raw or "")[:100]
1105
+ warnings.append(
1106
+ f"Project ExtendedAttribute FieldID {field_id} has "
1107
+ f"non-standard alias '{alias_display}'; three-point "
1108
+ f"estimate ({role}) skipped"
1109
+ )
1110
+ return None
1111
+ return role, field_id
1112
+
1113
+
1114
+ def _extract_pert_task_values(
1115
+ task_el: ET.Element, ns: str, pert_role_to_field_id: dict[str, str]
1116
+ ) -> tuple[int | None, int | None, int | None]:
1117
+ """Pull a task's Duration1/2/3 ExtendedAttribute values, in working days.
1118
+
1119
+ Returns ``(optimistic, most_likely, pessimistic)`` with ``None`` for any
1120
+ role whose FieldID was either not defined at project level or not present
1121
+ on this task. Uses the same ``_parse_duration_to_days`` helper as the
1122
+ primary Duration field so working-day semantics (8h/day floor) match.
1123
+ """
1124
+ opt_fid = pert_role_to_field_id.get("optimistic")
1125
+ ml_fid = pert_role_to_field_id.get("most_likely")
1126
+ pess_fid = pert_role_to_field_id.get("pessimistic")
1127
+ values: dict[str, int | None] = {
1128
+ DURATION1_FIELD_ID: None,
1129
+ DURATION2_FIELD_ID: None,
1130
+ DURATION3_FIELD_ID: None,
1131
+ }
1132
+ for ea in task_el.findall(f"{ns}ExtendedAttribute"):
1133
+ fid_el = ea.find(f"{ns}FieldID")
1134
+ val_el = ea.find(f"{ns}Value")
1135
+ if fid_el is None or val_el is None:
1136
+ continue
1137
+ fid = (fid_el.text or "").strip()
1138
+ if fid not in values:
1139
+ continue
1140
+ raw = (val_el.text or "").strip()
1141
+ if not raw:
1142
+ continue
1143
+ values[fid] = _parse_duration_to_days(raw)
1144
+ return (
1145
+ values.get(opt_fid) if opt_fid else None,
1146
+ values.get(ml_fid) if ml_fid else None,
1147
+ values.get(pess_fid) if pess_fid else None,
1148
+ )
1149
+
1150
+
1151
+ def parse_mpp(mpp_content: bytes) -> ProjectData:
1152
+ """Parse MS Project .mpp binary file via MPXJ subprocess.
1153
+
1154
+ Raises:
1155
+ RuntimeError: If MPXJ JAR is not available or conversion fails.
1156
+ """
1157
+ jar_path = getattr(settings, "MPXJ_JAR_PATH", "/opt/mpxj/mpxj-cli.jar")
1158
+ if not os.path.isfile(jar_path):
1159
+ # The path and setting name are operator detail; the user-facing
1160
+ # RuntimeError carries only a reference id that maps to this log line.
1161
+ error_id = uuid.uuid4().hex[:12]
1162
+ logger.error(
1163
+ "msproject.mpxj_jar_missing error_id=%s expected_path=%s "
1164
+ "(.mpp import needs Java and the MPXJ CLI JAR; set MPXJ_JAR_PATH)",
1165
+ error_id,
1166
+ jar_path,
1167
+ )
1168
+ raise RuntimeError(
1169
+ ".mpp import is not available on this server "
1170
+ f"(reference {error_id}). Contact your administrator."
1171
+ )
1172
+
1173
+ max_output = getattr(settings, "MPXJ_MAX_OUTPUT_MB", 512) * 1024 * 1024
1174
+ max_heap = getattr(settings, "MPXJ_MAX_HEAP_MB", 512)
1175
+
1176
+ with tempfile.NamedTemporaryFile(suffix=".mpp", delete=False) as tmp:
1177
+ tmp.write(mpp_content)
1178
+ tmp_path = tmp.name
1179
+
1180
+ try:
1181
+ # Bounded stdout streaming (#1722). The old capture_output=True buffered
1182
+ # MPXJ's entire stdout with no limit, so a decompression-bomb .mpp — small
1183
+ # enough to pass the 50 MB upload cap — could expand to multi-GB XML and
1184
+ # OOM the worker. We stream stdout in chunks and abort the moment the total
1185
+ # crosses MPXJ_MAX_OUTPUT_MB, killing the JVM. `-Xmx` is a second bound:
1186
+ # even a bomb that balloons MPXJ's in-memory model dies with a JVM OOM
1187
+ # (non-zero exit) rather than driving the host into swap. stderr is drained
1188
+ # CONCURRENTLY on a thread (#4385): reading it only after stdout EOF
1189
+ # deadlocks once a chatty JVM fills the ~64 KB stderr pipe (the JVM blocks,
1190
+ # stdout never closes, the worker is pinned until the watchdog). We do not
1191
+ # use communicate() because it would buffer all of stdout and lose the cap
1192
+ # above. The thread keeps only the first _MPXJ_STDERR_KEEP bytes and
1193
+ # discards the rest, so hostile stderr volume costs neither memory nor disk.
1194
+ proc = subprocess.Popen(
1195
+ ["java", f"-Xmx{max_heap}m", "-jar", jar_path, tmp_path, "-o", "xml"],
1196
+ stdout=subprocess.PIPE,
1197
+ stderr=subprocess.PIPE,
1198
+ )
1199
+ stderr_kept = bytearray()
1200
+
1201
+ def _drain_stderr() -> None:
1202
+ stream = proc.stderr
1203
+ if stream is None:
1204
+ return
1205
+ while True:
1206
+ chunk = stream.read(65536)
1207
+ if not chunk:
1208
+ return
1209
+ room = _MPXJ_STDERR_KEEP - len(stderr_kept)
1210
+ if room > 0:
1211
+ stderr_kept.extend(chunk[:room])
1212
+
1213
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
1214
+ stderr_thread.start()
1215
+ # Watchdog: preserve the original hard timeout even though we no longer use
1216
+ # subprocess.run's timeout= (a blocking read on a hung, silent JVM would
1217
+ # otherwise never return).
1218
+ timed_out = threading.Event()
1219
+
1220
+ def _kill_on_timeout() -> None:
1221
+ timed_out.set()
1222
+ proc.kill()
1223
+
1224
+ timer = threading.Timer(_MPXJ_TIMEOUT, _kill_on_timeout)
1225
+ timer.start()
1226
+
1227
+ chunks: list[bytes] = []
1228
+ total = 0
1229
+ try:
1230
+ assert proc.stdout is not None
1231
+ while True:
1232
+ chunk = proc.stdout.read(65536)
1233
+ if not chunk:
1234
+ break
1235
+ total += len(chunk)
1236
+ if total > max_output:
1237
+ proc.kill()
1238
+ raise RuntimeError(
1239
+ "MPXJ produced more than "
1240
+ f"{max_output // (1024 * 1024)} MB of XML — aborting as a "
1241
+ "likely decompression bomb."
1242
+ )
1243
+ chunks.append(chunk)
1244
+ proc.wait()
1245
+ finally:
1246
+ timer.cancel()
1247
+ # The JVM is dead or exited, so its stderr pipe reaches EOF and the
1248
+ # drain thread ends; the timeout is a belt for a grandchild holding it.
1249
+ stderr_thread.join(timeout=5)
1250
+
1251
+ if timed_out.is_set():
1252
+ raise RuntimeError(f"MPXJ conversion timed out after {_MPXJ_TIMEOUT}s")
1253
+
1254
+ if proc.returncode != 0:
1255
+ # stderr can carry JVM paths and internal class names, so it goes to
1256
+ # the server log only; the user-facing error carries a correlation id.
1257
+ error_id = uuid.uuid4().hex[:12]
1258
+ logger.warning(
1259
+ "msproject.mpxj_failed error_id=%s exit=%s stderr=%r",
1260
+ error_id,
1261
+ proc.returncode,
1262
+ bytes(stderr_kept).decode("utf-8", errors="replace"),
1263
+ )
1264
+ raise RuntimeError(
1265
+ f"MPXJ could not convert this file (exit {proc.returncode}, "
1266
+ f"reference {error_id}). Check that it is a valid .mpp file."
1267
+ )
1268
+
1269
+ xml_output = b"".join(chunks)
1270
+ if not xml_output:
1271
+ raise RuntimeError("MPXJ produced no output")
1272
+
1273
+ return parse_xml(xml_output)
1274
+ finally:
1275
+ with contextlib.suppress(OSError):
1276
+ os.unlink(tmp_path)