trueppm-api 0.4.0b2__py3-none-any.whl

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