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,1825 @@
1
+ """Parse a CSV / Excel upload into computable ``ProjectData`` (#743, ADR-0632).
2
+
3
+ The output is the shared ``msproject`` interchange dataclass (``ProjectData``),
4
+ so the existing, battle-tested ``msproject.importer.import_project`` persists it
5
+ — exactly as the Jira adapter does (ADR-0259). This module writes nothing; it is
6
+ Django-free apart from importing that dataclass, which is itself ORM-free.
7
+
8
+ Two shapes of hierarchy are recognized, matching what real spreadsheets carry:
9
+
10
+ - a **WBS / outline code** column ("1", "1.1", "1.2") -> dotted ``outline_number``
11
+ - **indentation** in the task-name column (leading spaces, tabs, or dots)
12
+ -> ``outline_level``
13
+
14
+ ``_build_wbs_paths`` in the importer already prefers a dotted outline number and
15
+ falls back to the level sequence, so both land on correct ltree paths without
16
+ any hierarchy code here.
17
+
18
+ Row-level errors are **data, not exceptions**: a bad date or an unresolvable
19
+ predecessor records a ``RowError`` and the row still imports with that one field
20
+ dropped. Only a structurally unusable file (no header, no name column,
21
+ unreadable bytes) raises ``CsvImportError``.
22
+
23
+ A row that cannot become a plan task at all is **parked, not dropped** (#2732):
24
+ it becomes a task under an "Import review" summary branch appended at the bottom
25
+ of the outline, carrying its raw cell values in ``notes``. Fixing one is then an
26
+ ordinary task edit — undoable and broadcast to co-authors — rather than a
27
+ re-upload of the whole file. See ``_append_review_branch``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import contextlib
33
+ import csv
34
+ import io
35
+ import re
36
+ import zipfile
37
+ from dataclasses import dataclass, field
38
+ from datetime import date, datetime
39
+ from typing import Any
40
+
41
+ from trueppm_api.apps.csvimport.mapping import (
42
+ ColumnMapping,
43
+ detect_mapping,
44
+ indexes_by_field,
45
+ missing_required,
46
+ )
47
+ from trueppm_api.apps.msproject.dataclasses import (
48
+ AssignmentData,
49
+ PredecessorLinkData,
50
+ ProjectData,
51
+ ResourceData,
52
+ TaskData,
53
+ )
54
+ from trueppm_api.core.text_decode import (
55
+ ENCODING_ADVICE,
56
+ TextDecodeError,
57
+ decode_uploaded_text,
58
+ )
59
+
60
+ #: How many parsed rows the preview endpoint echoes back to the wizard.
61
+ SAMPLE_ROW_COUNT = 10
62
+
63
+ #: Ceiling on distinct labels one import may mint, and on labels per task. A
64
+ #: label column is free text under the operator's control, so an accidental
65
+ #: mapping (a Notes column landing on `labels`) would otherwise mint one catalog
66
+ #: entry per row. Both caps degrade by *dropping the excess with a warning*
67
+ #: rather than failing the import — the tasks are still worth having (#2406).
68
+ MAX_IMPORT_LABELS = 100
69
+ MAX_LABELS_PER_TASK = 20
70
+
71
+ #: `Label.name` is a CharField(max_length=50); a longer value is truncated
72
+ #: rather than dropped so the label still lands on the task.
73
+ LABEL_NAME_MAX_LENGTH = 50
74
+
75
+ #: Ceiling on inferred or declared outline depth (indentation levels, or a
76
+ #: bare-digit WBS column value). Well under Postgres's ltree label-count
77
+ #: limit -- this exists purely so a malformed or malicious cell (tens of
78
+ #: thousands of leading tabs, or a huge bare integer) can never reach
79
+ #: `_wbs_paths_from_levels` and mint an unbounded ltree path, which crashes
80
+ #: `bulk_create` with a `DataError` (#2761). A violation clamps and records a
81
+ #: row warning; the row still imports, same as every other malformed-cell
82
+ #: contract in this module.
83
+ MAX_OUTLINE_DEPTH = 100
84
+
85
+ #: Name of the summary task unresolvable rows are parked under (#2732). A fixed
86
+ #: string rather than one carrying the filename: the branch is meant to be
87
+ #: *emptied*, so the operator, the wizard copy, and the docs all have to be able
88
+ #: to name the same thing without knowing which file produced it.
89
+ REVIEW_BRANCH_NAME = "Import review"
90
+
91
+ #: Why a row could not become a plan task, phrased for the parked task's own
92
+ #: name. Keyed by the same diagnostic codes as ``SEVERITY_BY_CODE``, so a new
93
+ #: unresolvable cause declares its severity and its parked-row phrasing together.
94
+ UNRESOLVED_REASON: dict[str, str] = {
95
+ "missing_name": "no task name",
96
+ }
97
+
98
+ #: Fallback name fragment for a parked row whose code predates the table above.
99
+ DEFAULT_UNRESOLVED_REASON = "could not be read"
100
+
101
+ #: Cap on the raw values carried onto a parked task's notes. ``Task.notes`` is an
102
+ #: unbounded TextField, but a 200-column sheet would otherwise write a wall of
103
+ #: text into the outline; the row is preserved to be *fixed*, not archived.
104
+ MAX_PARKED_VALUE_CELLS = 40
105
+
106
+ #: Per-cell cap inside that block, applied to the header **and** the value. The
107
+ #: header side is not cosmetic: header cells are parsed with no length cap, and
108
+ #: an unbounded key is copied into every parked row's notes, so a workbook with
109
+ #: 40 megabyte-long headers and 5,000 nameless rows would multiply out to
110
+ #: hundreds of gigabytes of retained strings inside the worker.
111
+ MAX_PARKED_VALUE_LENGTH = 200
112
+
113
+ #: Hard ceiling on one parked row's assembled notes, independent of the two caps
114
+ #: above so the bound survives a future change to either. ``Task.notes`` is an
115
+ #: unbounded TextField, so nothing below this enforces a limit.
116
+ MAX_PARKED_NOTES_LENGTH = 10_000
117
+
118
+ _CSV_EXTENSIONS = {"csv", "tsv", "txt"}
119
+ _XLSX_EXTENSIONS = {"xlsx", "xlsm"}
120
+ SUPPORTED_EXTENSIONS = _CSV_EXTENSIONS | _XLSX_EXTENSIONS
121
+
122
+ #: Re-exported so this module's other messages quote the one canonical sentence.
123
+ _ENCODING_ADVICE = ENCODING_ADVICE
124
+
125
+ _TRUEY = {"y", "yes", "true", "t", "1", "x", "on"}
126
+
127
+ # "3", "3FS", "3FS+2d", "5SS-1 day", "12 ff + 3"
128
+ _PREDECESSOR_RE = re.compile(
129
+ r"^\s*(?P<ref>[^\s,;+\-]+?)\s*"
130
+ r"(?:(?P<type>FS|SS|FF|SF)\s*)?"
131
+ r"(?:(?P<sign>[+-])\s*(?P<lag>\d+(?:\.\d+)?)\s*(?:d|day|days)?\s*)?$",
132
+ re.IGNORECASE,
133
+ )
134
+
135
+ #: The first numeric run in a cell, separators included so ``_parse_decimal`` can
136
+ #: decide which of them is the decimal mark. Deliberately greedy over ``.`` and
137
+ #: ``,``: the previous pattern (``-?\d+(?:\.\d+)?``) stopped at the first comma,
138
+ #: so a European "3,5" matched only "3" and a 3.5-day task imported as 3 days
139
+ #: with no warning of any kind (#2892).
140
+ _NUMBER_RE = re.compile(r"-?\d[\d.,]*")
141
+
142
+ _SLASH_DATE_RE = re.compile(r"^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})$")
143
+
144
+ # Non-slash date layouts, tried in order against the raw cell text.
145
+ _DATE_FORMATS = (
146
+ "%Y-%m-%d",
147
+ "%Y/%m/%d",
148
+ "%d %b %Y",
149
+ "%d %B %Y",
150
+ "%b %d %Y",
151
+ "%B %d %Y",
152
+ "%d-%b-%Y",
153
+ "%d-%b-%y",
154
+ )
155
+
156
+ #: The date orders a caller may request on the import endpoints (#2926).
157
+ #:
158
+ #: ``auto`` is the shipped behavior — scan the file and settle the convention
159
+ #: from the first self-identifying value. The other three are the operator
160
+ #: asserting what their own export uses, which is the only thing that can
161
+ #: resolve a file where every value reads validly both ways.
162
+ DATE_ORDERS = ("auto", "mdy", "dmy", "iso")
163
+
164
+ #: What ``auto`` falls back to when the file identifies nothing. US English, per
165
+ #: project convention — and the reason the ambiguous state has to be announced
166
+ #: rather than assumed: it is a coin flip, not a reading.
167
+ AMBIGUOUS_FALLBACK_ORDER = "mdy"
168
+
169
+
170
+ class CsvImportError(Exception):
171
+ """The upload is structurally unusable and no rows can be produced."""
172
+
173
+
174
+ #: Severity is **declared per diagnostic code, not decided by control flow.**
175
+ #:
176
+ #: The distinction that matters to an operator is exactly one thing: *did the row
177
+ #: land as a plan task?* A row that could not be resolved is a different event
178
+ #: from a row that imported with its start date unset, and collapsing both into
179
+ #: "7 problems" hides the only one that needs a person. Keeping the mapping as a
180
+ #: table rather than as branch structure means a reader can answer "what is fatal
181
+ #: here?" by reading one dict, and the wizard can group by severity without
182
+ #: re-deriving the rule.
183
+ #:
184
+ #: ``error`` — the row did **not** become a plan task. Since #2732 it is not
185
+ #: dropped either: it is parked in the Import review branch with
186
+ #: its raw values intact, so ``error_count`` counts rows awaiting
187
+ #: a person rather than rows that vanished.
188
+ #: ``warning`` — the row imported; one field was dropped or defaulted.
189
+ SEVERITY_BY_CODE: dict[str, str] = {
190
+ "missing_name": "error",
191
+ "bad_date": "warning",
192
+ "bad_duration": "warning",
193
+ "bad_percent": "warning",
194
+ "unknown_predecessor": "warning",
195
+ "self_dependency": "warning",
196
+ "finish_before_start": "warning",
197
+ "excessive_indent_depth": "warning",
198
+ }
199
+
200
+ #: Fallback for a code added without a severity. Warning, not error: a new check
201
+ #: must not silently start dropping rows from the operator's point of view just
202
+ #: because someone forgot the table.
203
+ DEFAULT_SEVERITY = "warning"
204
+
205
+
206
+ @dataclass
207
+ class RowError:
208
+ """One row-scoped diagnostic.
209
+
210
+ A ``warning`` row still imports, minus the offending field; an ``error`` row
211
+ did not import at all. Both ride back in the same list so the operator sees
212
+ one ordered account of what happened to their file.
213
+ """
214
+
215
+ row: int
216
+ column: str | None
217
+ code: str
218
+ message: str
219
+
220
+ @property
221
+ def severity(self) -> str:
222
+ return SEVERITY_BY_CODE.get(self.code, DEFAULT_SEVERITY)
223
+
224
+ def as_dict(self) -> dict[str, Any]:
225
+ return {
226
+ "row": self.row,
227
+ "column": self.column,
228
+ "code": self.code,
229
+ "message": self.message,
230
+ "severity": self.severity,
231
+ }
232
+
233
+
234
+ @dataclass
235
+ class UnresolvedRow:
236
+ """One data row that could not be resolved into a plan task (#2732).
237
+
238
+ Carries the row's raw cell text verbatim, keyed by header, so the parked
239
+ task can reproduce the spreadsheet row inside the outline. This is what makes
240
+ "nothing is silently dropped" true rather than aspirational: an unresolvable
241
+ row becomes a task under the Import review branch instead of leaving only a
242
+ line number in a summary the operator may never open.
243
+ """
244
+
245
+ row: int
246
+ code: str
247
+ values: dict[str, str] = field(default_factory=dict)
248
+
249
+ @property
250
+ def reason(self) -> str:
251
+ """Short phrase naming why the row could not become a plan task."""
252
+ return UNRESOLVED_REASON.get(self.code, DEFAULT_UNRESOLVED_REASON)
253
+
254
+
255
+ @dataclass
256
+ class DateReading:
257
+ """One convention's reading of the file — the ambiguous state's comparison row.
258
+
259
+ An ambiguous file is one where both readings are internally valid, so the
260
+ only honest way to present the choice is to show what each one *does* to the
261
+ same task. ``duration_days`` is the field that makes a wrong order
262
+ impossible to miss: 03/04 → 05/04 is either 3 days or 62.
263
+ """
264
+
265
+ order: str
266
+ #: Spreadsheet row the sample was taken from, 1-based including the header.
267
+ sample_row: int | None = None
268
+ sample_name: str = ""
269
+ sample_raw_start: str = ""
270
+ start: str | None = None
271
+ finish: str | None = None
272
+ duration_days: int | None = None
273
+ #: Date cells that parse under this order, and those that do not.
274
+ values_matched: int = 0
275
+ values_failed: int = 0
276
+ #: Rows losing at least one date cell under this order.
277
+ rows_unparseable: int = 0
278
+
279
+ def as_dict(self) -> dict[str, Any]:
280
+ return {
281
+ "order": self.order,
282
+ "sample_row": self.sample_row,
283
+ "sample_name": self.sample_name,
284
+ "sample_raw_start": self.sample_raw_start,
285
+ "start": self.start,
286
+ "finish": self.finish,
287
+ "duration_days": self.duration_days,
288
+ "values_matched": self.values_matched,
289
+ "values_failed": self.values_failed,
290
+ "rows_unparseable": self.rows_unparseable,
291
+ }
292
+
293
+
294
+ @dataclass
295
+ class DateConvention:
296
+ """How the date order was settled, and the evidence for it (#2926).
297
+
298
+ The server has always known *why* it picked a convention and then discarded
299
+ that reasoning into a rendered sentence. This carries the reasoning across
300
+ the API boundary instead, because the client cannot reconstruct "row 14 is
301
+ 13/04/2026, so the file can only be day-first" from a bare enum — and a
302
+ statement the operator cannot check against their own file is just a guess
303
+ with better typography.
304
+ """
305
+
306
+ #: What the caller asked for: one of :data:`DATE_ORDERS`.
307
+ requested: str = "auto"
308
+ #: What will actually be used: ``mdy``/``dmy``/``iso``, never ``auto``.
309
+ resolved: str = AMBIGUOUS_FALLBACK_ORDER
310
+ #: What ``auto`` would have chosen. Equals ``resolved`` unless overridden —
311
+ #: the override copy names both ("Auto would have read this as M/D/Y").
312
+ auto_resolved: str = AMBIGUOUS_FALLBACK_ORDER
313
+ #: True only when auto had no evidence *and* there was something to settle.
314
+ ambiguous: bool = False
315
+ #: ``{row, column, value, reason}`` for the value that settled it, if any.
316
+ evidence: dict[str, Any] | None = None
317
+ values_matched: int = 0
318
+ values_failed: int = 0
319
+ #: Both readings, populated only for the ambiguous case that needs them.
320
+ readings: list[DateReading] = field(default_factory=list)
321
+ #: False when no column is mapped to a date field — the control is inert.
322
+ has_date_columns: bool = False
323
+
324
+ def as_dict(self) -> dict[str, Any]:
325
+ return {
326
+ "requested": self.requested,
327
+ "resolved": self.resolved,
328
+ "auto_resolved": self.auto_resolved,
329
+ "ambiguous": self.ambiguous,
330
+ "evidence": self.evidence,
331
+ "values_matched": self.values_matched,
332
+ "values_failed": self.values_failed,
333
+ "readings": [r.as_dict() for r in self.readings],
334
+ "has_date_columns": self.has_date_columns,
335
+ }
336
+
337
+
338
+ @dataclass
339
+ class ParseResult:
340
+ """Everything both the preview and the commit path need from one upload."""
341
+
342
+ project_data: ProjectData
343
+ headers: list[str] = field(default_factory=list)
344
+ mapping: list[ColumnMapping] = field(default_factory=list)
345
+ #: First ``SAMPLE_ROW_COUNT`` data rows, raw cell text, for the wizard.
346
+ sample_rows: list[list[str]] = field(default_factory=list)
347
+ row_errors: list[RowError] = field(default_factory=list)
348
+ #: Rows parked in the Import review branch rather than dropped (#2732).
349
+ unresolved_rows: list[UnresolvedRow] = field(default_factory=list)
350
+ warnings: list[str] = field(default_factory=list)
351
+ #: Data rows read (after skipping blank and ``#``-comment rows).
352
+ total_rows: int = 0
353
+ #: Data rows dropped because ``max_rows`` was reached.
354
+ truncated_rows: int = 0
355
+ #: How the date order was settled, and the evidence for it (#2926).
356
+ date_order: DateConvention = field(default_factory=DateConvention)
357
+ #: Per-row date rendering for the wizard's preview table (#2926): the raw
358
+ #: cell beside what it was read as, and the duration that produces. The
359
+ #: duration is the column that makes a wrong order impossible to miss.
360
+ date_preview: list[dict[str, Any]] = field(default_factory=list)
361
+
362
+ @property
363
+ def error_count(self) -> int:
364
+ """Diagnostics whose row did not become a plan task."""
365
+ return sum(1 for e in self.row_errors if e.severity == "error")
366
+
367
+ @property
368
+ def warning_count(self) -> int:
369
+ """Diagnostics whose row imported with a field dropped or defaulted."""
370
+ return sum(1 for e in self.row_errors if e.severity == "warning")
371
+
372
+ @property
373
+ def review_task_count(self) -> int:
374
+ """Tasks the Import review branch adds: its summary row plus one per row.
375
+
376
+ Zero when nothing was parked — an import with no unresolvable rows must
377
+ not leave an empty review branch behind for someone to wonder about.
378
+ """
379
+ return len(self.unresolved_rows) + 1 if self.unresolved_rows else 0
380
+
381
+ @property
382
+ def plan_task_count(self) -> int:
383
+ """Tasks that are the operator's actual plan, excluding the review branch.
384
+
385
+ ``len(project_data.tasks)`` is the number of rows that will be *created*;
386
+ this is the number that answers "how much of my plan arrived?", which is
387
+ the one the wizard's counts and the confirm step's button are about.
388
+ """
389
+ return len(self.project_data.tasks) - self.review_task_count
390
+
391
+
392
+ # --- File readers --------------------------------------------------------
393
+
394
+
395
+ def _extension(filename: str) -> str:
396
+ return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
397
+
398
+
399
+ def _decode(content: bytes) -> str:
400
+ """Decode an uploaded delimited-text file, refusing a decode that went wrong.
401
+
402
+ Thin translation over :func:`trueppm_api.core.text_decode.decode_uploaded_text`.
403
+ The strategy lives there because the identical defect reached a second importer
404
+ while this copy was private to this module (#2892 -> #2937).
405
+
406
+ Raises:
407
+ CsvImportError: No codec produced usable spreadsheet text.
408
+ """
409
+ try:
410
+ return decode_uploaded_text(content)
411
+ except TextDecodeError as exc:
412
+ raise CsvImportError(str(exc)) from exc
413
+
414
+
415
+ def _read_csv_rows(content: bytes) -> tuple[list[list[str]], list[str]]:
416
+ """Decode and split a delimited text upload into rows of raw cell text."""
417
+ warnings: list[str] = []
418
+ text = _decode(content)
419
+ if not text.strip():
420
+ raise CsvImportError("The file is empty.")
421
+
422
+ # Sniff the delimiter from the first non-comment lines so tab- and
423
+ # semicolon-separated exports (common from European Excel) work unchanged.
424
+ sample = "\n".join(
425
+ line
426
+ for line in text.splitlines()[:20]
427
+ if line.strip() and not line.lstrip().startswith("#")
428
+ )
429
+ delimiter = ","
430
+ # A single-column file gives the sniffer nothing to work with; comma is the
431
+ # correct fallback because it degrades to one column either way.
432
+ with contextlib.suppress(csv.Error):
433
+ delimiter = csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
434
+ if delimiter != ",":
435
+ warnings.append(f"Detected '{delimiter}' as the column separator.")
436
+
437
+ rows = [list(row) for row in csv.reader(io.StringIO(text), delimiter=delimiter)]
438
+ return rows, warnings
439
+
440
+
441
+ def _read_xlsx_rows(
442
+ content: bytes, max_uncompressed_bytes: int
443
+ ) -> tuple[list[list[str]], list[str]]:
444
+ """Read the first worksheet of an .xlsx upload into rows of cell values.
445
+
446
+ Guards decompression bombs before parsing. openpyxl inherits XXE hardening
447
+ from ``defusedxml`` automatically (it is a direct dependency of this
448
+ package), but nothing in that stack bounds the *inflated* size of a zip
449
+ member -- a 1 MB upload can expand to gigabytes. Summing the central
450
+ directory's declared sizes is cheap, reads no member data, and happens
451
+ before any XML parser sees a byte.
452
+ """
453
+ warnings: list[str] = []
454
+ try:
455
+ archive = zipfile.ZipFile(io.BytesIO(content))
456
+ except zipfile.BadZipFile as exc:
457
+ raise CsvImportError(
458
+ "That .xlsx file could not be opened. Re-save it from Excel and try again."
459
+ ) from exc
460
+
461
+ declared = sum(info.file_size for info in archive.infolist())
462
+ if declared > max_uncompressed_bytes:
463
+ raise CsvImportError(
464
+ f"The workbook expands to {declared // (1024 * 1024)} MB, over the "
465
+ f"{max_uncompressed_bytes // (1024 * 1024)} MB limit. "
466
+ "Export the sheet as CSV instead."
467
+ )
468
+
469
+ try:
470
+ import openpyxl
471
+ except ImportError as exc: # pragma: no cover - dependency is declared
472
+ raise CsvImportError("Excel import is unavailable on this server.") from exc
473
+
474
+ try:
475
+ workbook = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
476
+ except Exception as exc:
477
+ raise CsvImportError(
478
+ "That .xlsx file could not be read. Re-save it from Excel and try again."
479
+ ) from exc
480
+
481
+ try:
482
+ sheet_names = list(workbook.sheetnames)
483
+ if len(sheet_names) > 1:
484
+ warnings.append(
485
+ f"Only the first sheet ('{sheet_names[0]}') was imported. "
486
+ f"{len(sheet_names) - 1} other sheet(s) were ignored."
487
+ )
488
+ worksheet = workbook[sheet_names[0]]
489
+ rows = [[_cell_text(cell) for cell in row] for row in worksheet.iter_rows(values_only=True)]
490
+ finally:
491
+ workbook.close()
492
+
493
+ return rows, warnings
494
+
495
+
496
+ def _cell_text(value: Any) -> str:
497
+ """Render one openpyxl cell value as the text the CSV path would have seen."""
498
+ if value is None:
499
+ return ""
500
+ if isinstance(value, datetime):
501
+ return value.date().isoformat()
502
+ if isinstance(value, date):
503
+ return value.isoformat()
504
+ if isinstance(value, float) and value.is_integer():
505
+ return str(int(value))
506
+ return str(value)
507
+
508
+
509
+ # --- Value coercion ------------------------------------------------------
510
+
511
+
512
+ def _slash_date_evidence(
513
+ cells: list[tuple[int, str, str]],
514
+ ) -> tuple[str | None, dict[str, Any] | None]:
515
+ """Find the first value that can only be read one way, and say why.
516
+
517
+ A slash date is self-identifying only when one component exceeds 12
518
+ (13/04/2026 can only be D/M). We scan **every** date cell before parsing any
519
+ of them, so one unambiguous row settles the whole file rather than letting
520
+ consecutive rows be read under different conventions.
521
+
522
+ Returns ``(order, evidence)`` where evidence is the ``{row, column, value,
523
+ reason}`` the wizard quotes back at the operator. ``(None, None)`` means the
524
+ file identifies nothing — the ambiguous case, which the caller must announce
525
+ rather than quietly resolve.
526
+
527
+ Args:
528
+ cells: ``(row_number, column_header, raw_text)`` for every date cell.
529
+
530
+ Returns:
531
+ The settled order (``"dmy"``/``"mdy"``) and its evidence, or two Nones.
532
+ """
533
+ for row_number, column, raw in cells:
534
+ match = _SLASH_DATE_RE.match(raw.strip())
535
+ if not match:
536
+ continue
537
+ first, second = int(match.group(1)), int(match.group(2))
538
+ if first > 12 and second <= 12:
539
+ return "dmy", {
540
+ "row": row_number,
541
+ "column": column,
542
+ "value": raw.strip(),
543
+ "reason": "no_thirteenth_month",
544
+ }
545
+ if second > 12 and first <= 12:
546
+ return "mdy", {
547
+ "row": row_number,
548
+ "column": column,
549
+ "value": raw.strip(),
550
+ "reason": "second_part_exceeds_twelve",
551
+ }
552
+ return None, None
553
+
554
+
555
+ def _parse_date(raw: str, order: str) -> date | None:
556
+ """Read one cell under a settled date order.
557
+
558
+ ``order`` is ``mdy``/``dmy``/``iso``, never ``auto`` — resolution happens
559
+ once per file, not once per cell. Under ``iso`` a slash date is deliberately
560
+ *not* reinterpreted: an operator who asserts ISO is telling us their export
561
+ is unambiguous, so a ``03/04/2026`` in it is a malformed row to be reported,
562
+ not a value to guess at under a convention they just ruled out.
563
+ """
564
+ text = (raw or "").strip()
565
+ if not text:
566
+ return None
567
+
568
+ match = _SLASH_DATE_RE.match(text)
569
+ if match:
570
+ return _slash_date(match, order)
571
+ return _formatted_date(text)
572
+
573
+
574
+ def _slash_date(match: re.Match[str], order: str) -> date | None:
575
+ """Read a ``a/b/yyyy`` cell under a settled ``mdy``/``dmy`` order.
576
+
577
+ Under ``iso`` this returns ``None`` rather than guessing: an operator who
578
+ asserts ISO has ruled out the slash convention, so such a row is malformed
579
+ and should be reported as such.
580
+ """
581
+ if order == "iso":
582
+ return None
583
+ a, b, year = int(match.group(1)), int(match.group(2)), int(match.group(3))
584
+ if year < 100:
585
+ year += 2000 if year < 70 else 1900
586
+ day, month = (a, b) if order == "dmy" else (b, a)
587
+ try:
588
+ return date(year, month, day)
589
+ except ValueError:
590
+ return None
591
+
592
+
593
+ def _formatted_date(text: str) -> date | None:
594
+ """Read a cell against each known non-slash format in turn."""
595
+ # Excel sometimes hands back a full timestamp for a date-formatted cell.
596
+ text = text.split("T")[0].split(" 00:00:00")[0]
597
+ for fmt in _DATE_FORMATS:
598
+ try:
599
+ return datetime.strptime(text, fmt).date()
600
+ except ValueError:
601
+ continue
602
+ return None
603
+
604
+
605
+ def _decimal_mark(digits: str) -> str | None:
606
+ """Which mark in ``digits`` is the decimal separator, or ``None`` for neither.
607
+
608
+ Both ``.`` and ``,`` group as well as separate, so a bare
609
+ ``str.replace(",", ".")`` corrupts one convention to fix the other. The rules:
610
+
611
+ - **Both marks present** — the *last* is the decimal mark. Unambiguous in
612
+ every locale: ``1.234,56`` and ``1,234.56`` both mean 1234.56.
613
+ - **One mark, repeated** — grouping. ``1.234.567`` is not a number with two
614
+ decimal points.
615
+ - **One comma, exactly three trailing digits, integer part not a bare zero**
616
+ — grouping. ``1,500`` reads as fifteen hundred. The leading-zero carve-out
617
+ matters: ``0,500`` is not ambiguous, because no locale writes five hundred
618
+ with a leading zero, so it can only be a decimal mark. Without it a German
619
+ 3-decimal column turned a half-day task into a 500-day one.
620
+ - **One period, exactly three trailing digits** — decimal, so ``1.500`` stays
621
+ 1.5. The asymmetry with the comma rule is the point: every previously
622
+ correct US file keeps parsing identically, so fixing the European case
623
+ regresses nothing.
624
+ - **Any other single mark** — decimal. ``3,5`` is three and a half.
625
+ """
626
+ dots = digits.count(".")
627
+ commas = digits.count(",")
628
+ if dots and commas:
629
+ return "." if digits.rindex(".") > digits.rindex(",") else ","
630
+ if dots > 1 or commas > 1:
631
+ return None
632
+ if commas == 1:
633
+ integer_part, fraction_part = digits.split(",")
634
+ # A three-digit tail reads as thousands grouping — unless the integer part
635
+ # is a bare zero, in which case grouping is impossible and it is a decimal.
636
+ groups_thousands = len(fraction_part) == 3 and integer_part.lstrip("0") != ""
637
+ return None if groups_thousands else ","
638
+ if dots == 1:
639
+ return "."
640
+ return None
641
+
642
+
643
+ def _parse_decimal(text: str) -> tuple[float, bool] | None:
644
+ """Read the first number in a cell under either decimal convention.
645
+
646
+ Returns ``(value, had_fraction)``, or ``None`` when the cell holds no number.
647
+ ``had_fraction`` reports whether a decimal mark was actually present, which is
648
+ what ``_parse_percent`` needs to tell Excel's native 0..1 fraction ("0,5",
649
+ "0.5") from an already-scaled percentage ("50").
650
+
651
+ Why this is not a ``str.replace(",", ".")``: both marks group as well as
652
+ separate, so the rules below resolve which role each one is playing.
653
+
654
+ - **Both marks present** — the *last* one is the decimal mark and the other is
655
+ grouping. This is unambiguous in every locale: ``1.234,56`` and ``1,234.56``
656
+ both mean 1234.56.
657
+ - **One mark, repeated** — grouping. ``1.234.567`` is not a number with two
658
+ decimal points.
659
+ - **One comma, exactly three trailing digits, and a leading integer part that
660
+ is not a bare zero** — grouping. ``1,500`` reads as fifteen hundred, the US
661
+ convention this project writes in. The leading-zero carve-out matters:
662
+ ``0,500`` is *not* ambiguous, because no locale and no tool writes five
663
+ hundred with a leading zero, so it can only be a decimal mark. Without the
664
+ carve-out a German 3-decimal column turned a half-day task into a 500-day
665
+ one and read 50 % complete as 100 % — the same silent corruption this
666
+ function exists to end, in the opposite direction.
667
+ - **One period, exactly three trailing digits** — decimal, so ``1.500`` stays
668
+ 1.5 exactly as it parsed before this function existed. The asymmetry with
669
+ the comma rule is the point: it keeps every previously-correct US file
670
+ parsing identically, so fixing the European case regresses nothing.
671
+ - **Any other single mark** — decimal. ``3,5`` is three and a half.
672
+
673
+ A genuinely ambiguous cell is therefore resolved by convention rather than
674
+ left to truncate in silence, which is what ``_DURATION_RE`` used to do (#2892).
675
+ """
676
+ match = _NUMBER_RE.search(text)
677
+ if not match:
678
+ return None
679
+ # The pattern guarantees a leading digit (after an optional sign), so the
680
+ # rstrip below can only remove trailing separators — "5," becomes "5".
681
+ token = match.group(0).rstrip(".,")
682
+ negative = token.startswith("-")
683
+ digits = token.lstrip("-")
684
+ decimal_mark = _decimal_mark(digits)
685
+
686
+ if decimal_mark is None:
687
+ normalized = digits.replace(".", "").replace(",", "")
688
+ had_fraction = False
689
+ else:
690
+ grouping = "," if decimal_mark == "." else "."
691
+ normalized = digits.replace(grouping, "").replace(decimal_mark, ".")
692
+ had_fraction = True
693
+
694
+ try:
695
+ value = float(normalized)
696
+ except ValueError:
697
+ return None
698
+ return (-value if negative else value), had_fraction
699
+
700
+
701
+ def _parse_duration(raw: str) -> float | None:
702
+ """Read a duration cell as working days ("5", "5d", "5 days", "3.5", "3,5")."""
703
+ text = (raw or "").strip()
704
+ if not text:
705
+ return None
706
+ parsed = _parse_decimal(text)
707
+ if parsed is None:
708
+ return None
709
+ value, _ = parsed
710
+ # "8h"/"16 hrs" is an effort column in hours, not days. Convert on the
711
+ # standard 8-hour day rather than importing an 8-day task.
712
+ if re.search(r"\b(h|hr|hrs|hour|hours)\b", text, re.IGNORECASE):
713
+ value = value / 8.0
714
+ if re.search(r"\b(w|wk|wks|week|weeks)\b", text, re.IGNORECASE):
715
+ value = value * 5.0
716
+ return value
717
+
718
+
719
+ def _parse_percent(raw: str) -> float | None:
720
+ """Read a %-complete cell, normalizing a 0..1 fraction to 0..100."""
721
+ text = (raw or "").strip()
722
+ if not text:
723
+ return None
724
+ had_sign = "%" in text
725
+ parsed = _parse_decimal(text)
726
+ if parsed is None:
727
+ return None
728
+ value, had_fraction = parsed
729
+ # A bare "0.5" (or "0,5") with a decimal mark and no % sign is a fraction —
730
+ # Excel's native percent storage. "50%" and "50" are already percentages.
731
+ # The test is `had_fraction`, not `"." in <matched text>`: keying it on the
732
+ # literal period meant a European "0,5" could never take this branch, so a
733
+ # half-finished task imported as 0 % complete (#2892).
734
+ if not had_sign and 0.0 < value <= 1.0 and had_fraction:
735
+ value *= 100.0
736
+ return max(0.0, min(100.0, value))
737
+
738
+
739
+ def _parse_bool(raw: str) -> bool:
740
+ return (raw or "").strip().lower() in _TRUEY
741
+
742
+
743
+ def _indent_level(raw_name: str) -> int:
744
+ """Infer outline depth from leading whitespace or dot-leaders in a name.
745
+
746
+ Two spaces or one tab is one level (the common Excel convention); leading
747
+ dots ("...Design") are treated the same way, one level per dot.
748
+ """
749
+ leading = len(raw_name) - len(raw_name.lstrip(" \t."))
750
+ if leading == 0:
751
+ return 0
752
+ prefix = raw_name[:leading]
753
+ tabs = prefix.count("\t")
754
+ dots = prefix.count(".")
755
+ spaces = prefix.count(" ")
756
+ return tabs + dots + spaces // 2
757
+
758
+
759
+ def _clamp_outline_level(
760
+ level: int,
761
+ result: ParseResult,
762
+ row_number: int,
763
+ column: str,
764
+ ) -> int:
765
+ """Cap an inferred or declared outline depth at ``MAX_OUTLINE_DEPTH``.
766
+
767
+ Called from both hierarchy sources (name-column indentation and a
768
+ bare-digit WBS column) right where each produces a depth, so neither can
769
+ hand an unbounded value to the shared WBS-path builder. See
770
+ ``MAX_OUTLINE_DEPTH`` for why the ceiling exists.
771
+ """
772
+ if level <= MAX_OUTLINE_DEPTH:
773
+ return level
774
+ result.row_errors.append(
775
+ RowError(
776
+ row_number,
777
+ column,
778
+ "excessive_indent_depth",
779
+ f"Indent depth {level} exceeds the maximum of {MAX_OUTLINE_DEPTH}; "
780
+ f"clamped to {MAX_OUTLINE_DEPTH}.",
781
+ )
782
+ )
783
+ return MAX_OUTLINE_DEPTH
784
+
785
+
786
+ # --- Main entry point ----------------------------------------------------
787
+
788
+
789
+ def parse_spreadsheet(
790
+ content: bytes,
791
+ filename: str,
792
+ *,
793
+ column_map: dict[str, str] | None = None,
794
+ max_rows: int = 5000,
795
+ max_uncompressed_bytes: int = 100 * 1024 * 1024,
796
+ project_name: str = "",
797
+ date_order: str = "auto",
798
+ ) -> ParseResult:
799
+ """Parse an uploaded CSV/XLSX into ``ProjectData`` plus preview metadata.
800
+
801
+ ``project_data.tasks`` holds the operator's plan **plus** the Import review
802
+ branch, when any row could not be resolved. ``plan_task_count`` is the former
803
+ on its own; anything reporting "how much of my plan arrived" wants that one.
804
+
805
+ Args:
806
+ content: Raw uploaded bytes.
807
+ filename: Sanitized filename; used to pick a reader, and named on the
808
+ Import review branch so a second import's parked rows are traceable
809
+ to the file that produced them.
810
+ column_map: Optional ``{header: field}`` overrides from the wizard's
811
+ mapping step. Wins over auto-detection, field by field.
812
+ max_rows: Hard cap on data rows; the remainder is reported in
813
+ ``truncated_rows`` rather than silently dropped.
814
+ max_uncompressed_bytes: Zip-bomb ceiling for .xlsx uploads.
815
+ project_name: Fallback name for the ``ProjectData`` header.
816
+ date_order: ``auto``/``mdy``/``dmy``/``iso`` (#2926). ``auto`` keeps the
817
+ file-wide inference; the other three are the operator asserting what
818
+ their own export uses. Date order is the last locale decision that
819
+ was inferred and unaddressable — encoding, delimiter and decimal
820
+ separator are all settled from the file's own evidence — and an
821
+ inference nothing can override makes the endpoint non-deterministic
822
+ for a scripted caller, since the same rows import differently
823
+ depending on which values happen to disambiguate the column.
824
+
825
+ Raises:
826
+ CsvImportError: The file is unreadable, empty, has no header row, or has
827
+ no column that can supply ``Task.name``.
828
+ """
829
+ if date_order not in DATE_ORDERS:
830
+ raise CsvImportError(
831
+ f"Unknown date order: {date_order}. Use one of {', '.join(DATE_ORDERS)}."
832
+ )
833
+
834
+ ext = _extension(filename)
835
+ if ext in _XLSX_EXTENSIONS:
836
+ raw_rows, warnings = _read_xlsx_rows(content, max_uncompressed_bytes)
837
+ elif ext in _CSV_EXTENSIONS:
838
+ raw_rows, warnings = _read_csv_rows(content)
839
+ else:
840
+ raise CsvImportError(f"Unsupported file type: .{ext}. Upload a .csv or .xlsx file.")
841
+
842
+ rows = [r for r in raw_rows if not _is_skippable(r)]
843
+ if not rows:
844
+ raise CsvImportError("The file has no readable rows.")
845
+
846
+ headers = [str(cell).strip() for cell in rows[0]]
847
+ if not any(headers):
848
+ raise CsvImportError("The first row must be a header row naming each column.")
849
+
850
+ mapping = detect_mapping(headers, overrides=column_map)
851
+ missing = missing_required(mapping)
852
+ if missing:
853
+ raise CsvImportError(
854
+ "No column could be matched to the task name. "
855
+ "Rename a column to 'Name' or 'Task', or map one explicitly."
856
+ )
857
+
858
+ by_field = indexes_by_field(mapping)
859
+ data_rows = rows[1:]
860
+
861
+ truncated = 0
862
+ if len(data_rows) > max_rows:
863
+ truncated = len(data_rows) - max_rows
864
+ warnings.append(
865
+ f"Only the first {max_rows:,} rows were imported; {truncated:,} were skipped."
866
+ )
867
+ data_rows = data_rows[:max_rows]
868
+
869
+ result = ParseResult(
870
+ project_data=ProjectData(name=project_name[:255]),
871
+ headers=headers,
872
+ mapping=mapping,
873
+ row_errors=[],
874
+ warnings=warnings,
875
+ total_rows=len(data_rows),
876
+ truncated_rows=truncated,
877
+ )
878
+ result.sample_rows = [
879
+ [_cell(row, i) for i in range(len(headers))] for row in data_rows[:SAMPLE_ROW_COUNT]
880
+ ]
881
+
882
+ _build_tasks(result, data_rows, by_field, date_order)
883
+ _resolve_predecessors(result, data_rows, by_field)
884
+ _build_resources(result, data_rows, by_field)
885
+ _build_labels(result, data_rows, by_field)
886
+ # Last, and deliberately so — see _append_review_branch.
887
+ _append_review_branch(result, filename)
888
+
889
+ result.project_data.warnings = list(result.warnings)
890
+ return result
891
+
892
+
893
+ def _is_skippable(row: list[Any]) -> bool:
894
+ """True for a blank row or a ``#``-prefixed comment row."""
895
+ cells = [str(c).strip() for c in row if c is not None]
896
+ if not any(cells):
897
+ return True
898
+ return bool(cells) and cells[0].startswith("#")
899
+
900
+
901
+ def _cell(row: list[Any], index: int | None) -> str:
902
+ if index is None or index >= len(row) or row[index] is None:
903
+ return ""
904
+ return str(row[index])
905
+
906
+
907
+ def _one(by_field: dict[str, list[int]], field: str) -> int:
908
+ """The single column supplying ``field``.
909
+
910
+ ``by_field`` is a list per field so that a ``multi`` field's extra columns
911
+ cannot be silently dropped by a scalar lookup. Every single-valued read goes
912
+ through here, which makes "this field is one-to-one" an explicit claim at
913
+ the call site rather than an assumption baked into the container's shape.
914
+
915
+ Only call for a field known to be present — callers gate on
916
+ ``field in by_field`` exactly as they did when this was a scalar map.
917
+ """
918
+ return by_field[field][0]
919
+
920
+
921
+ def _split_values(raw: str) -> list[str]:
922
+ """Split one cell into its values on the shared ``, ; /`` convention.
923
+
924
+ Same separators the resource column has always used, so a sheet author who
925
+ learned the convention in one place does not have to relearn it.
926
+ """
927
+ return [part.strip() for part in re.split(r"[,;/]", raw) if part.strip()]
928
+
929
+
930
+ def _wbs_column_holds_codes(data_rows: list[list[Any]], by_field: dict[str, list[int]]) -> bool:
931
+ """Decide once, for the whole column, whether its cells are outline codes or depths.
932
+
933
+ Whether a WBS column holds *codes* (``1``, ``1.1``, ``2``) or *depths*
934
+ (``0``, ``1``, ``1``) is a property of the column, not of the individual cell — but
935
+ ``_apply_wbs`` used to decide per row, on whether that one value contained a dot.
936
+ A real-world WBS column mixes both spellings, because a phase's own code is bare and
937
+ only its children are dotted. Every dotted row was then read as a code and every
938
+ phase row as a depth, inside one file (#3082).
939
+
940
+ The phase rows are the ones that break. A bare cell taken as a depth leaves
941
+ ``outline_number`` at the positional fallback ``_build_task`` assigned, so the second
942
+ phase of a file lands at its row index instead of its code — and since ``wbs_path``
943
+ is the only record of parenthood, its children are left pointing at a number no live
944
+ row holds. They render at project root, and the WBS has a hole where the phase was.
945
+
946
+ The first phase always looks correct, which is why this went unnoticed: it is row 1
947
+ and its code is ``1``, so the fallback and the intended code agree by coincidence.
948
+
949
+ One dotted cell anywhere settles the file, for the same reason one unambiguous slash
950
+ date does in :func:`_slash_date_evidence`: a convention that consecutive rows are
951
+ allowed to disagree about is not a convention. A column with no dotted cell at all is
952
+ the ``Level``/``Outline`` depth convention ``_apply_wbs`` was written for, and keeps
953
+ being read that way.
954
+ """
955
+ if "wbs" not in by_field:
956
+ return False
957
+ wbs_index = _one(by_field, "wbs")
958
+ return any("." in _cell(row, wbs_index) for row in data_rows)
959
+
960
+
961
+ def _build_tasks(
962
+ result: ParseResult,
963
+ data_rows: list[list[Any]],
964
+ by_field: dict[str, list[int]],
965
+ date_order: str,
966
+ ) -> None:
967
+ """Turn each data row into a ``TaskData``, recording per-field failures."""
968
+ name_index = _one(by_field, "name")
969
+ result.date_order = _resolve_date_convention(result, data_rows, by_field, date_order)
970
+ order = result.date_order.resolved
971
+ # Settled across every row before any is read, not per cell — see the function's
972
+ # docstring for why the per-cell call orphaned every phase but the first (#3082).
973
+ wbs_is_codes = _wbs_column_holds_codes(data_rows, by_field)
974
+ tasks: list[TaskData] = []
975
+
976
+ for offset, row in enumerate(data_rows):
977
+ # Row numbers are 1-based and count the header, so they line up with
978
+ # what the operator sees in Excel's row gutter.
979
+ row_number = offset + 2
980
+ raw_name = _cell(row, name_index)
981
+ if not raw_name.strip():
982
+ result.row_errors.append(
983
+ RowError(
984
+ row_number,
985
+ result.headers[name_index],
986
+ "missing_name",
987
+ "Row has no task name; parked in the Import review branch.",
988
+ )
989
+ )
990
+ result.unresolved_rows.append(
991
+ UnresolvedRow(row_number, "missing_name", _raw_values(result.headers, row))
992
+ )
993
+ continue
994
+ tasks.append(
995
+ _build_task(
996
+ offset,
997
+ raw_name,
998
+ row,
999
+ by_field,
1000
+ result,
1001
+ row_number,
1002
+ order,
1003
+ len(tasks),
1004
+ wbs_is_codes=wbs_is_codes,
1005
+ )
1006
+ )
1007
+
1008
+ result.project_data.tasks = tasks
1009
+ _build_date_preview(result, data_rows, by_field, order)
1010
+
1011
+
1012
+ def _optional_index(by_field: dict[str, list[int]], field: str) -> int | None:
1013
+ """Column index for ``field``, or ``None`` when the mapping has no such column."""
1014
+ return _one(by_field, field) if field in by_field else None
1015
+
1016
+
1017
+ def _build_date_preview(
1018
+ result: ParseResult,
1019
+ data_rows: list[list[Any]],
1020
+ by_field: dict[str, list[int]],
1021
+ order: str,
1022
+ ) -> None:
1023
+ """Render the first rows' dates as the wizard's preview table shows them.
1024
+
1025
+ Built server-side beside the parse rather than re-derived in the client,
1026
+ for the same reason the mapping is: the operator is confirming what the
1027
+ parser will actually do, and a second implementation of "read this cell as
1028
+ a date" is a second thing that can disagree with the import.
1029
+ """
1030
+ if not result.date_order.has_date_columns:
1031
+ return
1032
+ start_idx = _optional_index(by_field, "planned_start")
1033
+ finish_idx = _optional_index(by_field, "planned_finish")
1034
+ name_idx = _optional_index(by_field, "name")
1035
+
1036
+ result.date_preview = [
1037
+ _preview_row(row, offset, start_idx, finish_idx, name_idx, order)
1038
+ for offset, row in enumerate(data_rows[:SAMPLE_ROW_COUNT])
1039
+ ]
1040
+
1041
+
1042
+ def _preview_row(
1043
+ row: list[Any],
1044
+ offset: int,
1045
+ start_idx: int | None,
1046
+ finish_idx: int | None,
1047
+ name_idx: int | None,
1048
+ order: str,
1049
+ ) -> dict[str, Any]:
1050
+ """One rendered preview row, exactly as the wizard's table shows it."""
1051
+ raw_start = _cell(row, start_idx).strip() if start_idx is not None else ""
1052
+ raw_finish = _cell(row, finish_idx).strip() if finish_idx is not None else ""
1053
+ start = _parse_date(raw_start, order) if raw_start else None
1054
+ finish = _parse_date(raw_finish, order) if raw_finish else None
1055
+ return {
1056
+ "row": offset + 2,
1057
+ "name": _cell(row, name_idx).strip() if name_idx is not None else "",
1058
+ "raw_start": raw_start,
1059
+ "raw_finish": raw_finish,
1060
+ "start": start.isoformat() if start else None,
1061
+ "finish": finish.isoformat() if finish else None,
1062
+ # Inclusive of both endpoints, matching _apply_derived_duration.
1063
+ "duration_days": (finish - start).days + 1 if start and finish else None,
1064
+ # A cell that was present and would not parse — the state the
1065
+ # "{k} of {n} rows cannot be read as {order}" copy counts.
1066
+ "unreadable": bool((raw_start and not start) or (raw_finish and not finish)),
1067
+ }
1068
+
1069
+
1070
+ # --- Import review branch (#2732) ----------------------------------------
1071
+
1072
+
1073
+ def _raw_values(headers: list[str], row: list[Any]) -> dict[str, str]:
1074
+ """Snapshot one spreadsheet row's non-empty cells as ``{header: text}``.
1075
+
1076
+ Taken before any coercion so the parked task shows what the operator wrote,
1077
+ not what the parser made of it — the point of parking a row is that the
1078
+ parser's reading of it is the thing in question.
1079
+
1080
+ The **key** is truncated as well as the value. Header cells carry no length
1081
+ cap of their own, and this dict is materialized once per parked row, so an
1082
+ uncapped key is the one term here that multiplies: a workbook of huge headers
1083
+ plus thousands of nameless rows would otherwise expand a sub-megabyte upload
1084
+ into hundreds of gigabytes of strings in the worker.
1085
+ """
1086
+ values: dict[str, str] = {}
1087
+ for index, header in enumerate(headers[:MAX_PARKED_VALUE_CELLS]):
1088
+ text = _cell(row, index).strip()
1089
+ if text:
1090
+ key = (header or f"Column {index + 1}")[:MAX_PARKED_VALUE_LENGTH]
1091
+ values[key] = text[:MAX_PARKED_VALUE_LENGTH]
1092
+ return values
1093
+
1094
+
1095
+ def _next_top_level_number(tasks: list[TaskData]) -> int:
1096
+ """The first top-level outline number no imported task has claimed.
1097
+
1098
+ Read from the *first* segment of each outline number so a file using dotted
1099
+ WBS codes ("1", "1.1", "2.3") yields the next whole-numbered phase. The
1100
+ length gate mirrors ``_apply_wbs``: an absurdly long digit string is a
1101
+ malformed cell, not a real outline position, and must not reach ``int()``.
1102
+ """
1103
+ highest = 0
1104
+ for task in tasks:
1105
+ head = (task.outline_number or "").split(".")[0].strip()
1106
+ if head.isdigit() and len(head) <= 10:
1107
+ highest = max(highest, int(head))
1108
+ return highest + 1
1109
+
1110
+
1111
+ def _append_review_branch(result: ParseResult, filename: str) -> None:
1112
+ """Park every unresolvable row under an "Import review" summary task.
1113
+
1114
+ An unresolvable row used to be counted and then dropped, which meant the
1115
+ operator's only record of it was a line number in a summary they had to be
1116
+ looking at when the import finished. Parking the row keeps the data in the
1117
+ plan, where a normal task edit fixes it — undoable, and broadcast to
1118
+ co-authors like any other edit, with no special repair surface to build.
1119
+
1120
+ **Appended last, after predecessor / resource / label resolution.** Those
1121
+ passes address ``data_rows`` by ``task.uid``, so a synthetic uid in the task
1122
+ list would index out of range. Running after them also means the parked rows
1123
+ carry no predecessor links, so the dependency graph the caller hands to
1124
+ ``validate_task_graph`` (ADR-0259) is exactly the one the real rows produced.
1125
+
1126
+ Hierarchy is expressed in whichever of ``_build_wbs_paths``' two modes the
1127
+ real rows already use. Writing a dotted outline number onto a file whose
1128
+ hierarchy lives in indentation would flip that function's ``has_dotted``
1129
+ branch for the *whole* import and re-derive every real task's path from a
1130
+ number the parser only ever meant as a sequence — so the dotted form is used
1131
+ only when the file was already dotted, and levels carry it otherwise.
1132
+
1133
+ ``_build_wbs_paths`` has a **second** discriminator, though, and this branch
1134
+ unavoidably trips it: a genuinely flat file has ``len(set(levels)) <= 1`` and
1135
+ derives paths from the outline number, and adding a summary plus a child
1136
+ makes the level set non-trivial, so it switches to the level sequence. That
1137
+ is safe here only because of an invariant elsewhere in this module — when the
1138
+ WBS column is not dotted, ``_apply_wbs`` reads a bare integer as a *depth*
1139
+ and leaves ``outline_number`` as the sequence ``_build_task`` assigned, so
1140
+ both modes produce the same 1..N paths for the real rows. If ``_apply_wbs``
1141
+ ever preserves a non-sequential flat code, one parked row would silently
1142
+ renumber the whole import; a test pins the indentation case.
1143
+ """
1144
+ if not result.unresolved_rows:
1145
+ return
1146
+
1147
+ tasks = result.project_data.tasks
1148
+ has_dotted = any("." in (t.outline_number or "") for t in tasks)
1149
+ base_level = min((t.outline_level for t in tasks), default=0)
1150
+ # Only meaningful in dotted mode; left empty otherwise rather than written
1151
+ # and ignored, so the field never disagrees with the path actually derived.
1152
+ next_top = str(_next_top_level_number(tasks)) if has_dotted else ""
1153
+ next_uid = max((t.uid for t in tasks), default=0) + 1
1154
+
1155
+ tasks.append(
1156
+ TaskData(
1157
+ uid=next_uid,
1158
+ name=REVIEW_BRANCH_NAME,
1159
+ outline_number=next_top,
1160
+ outline_level=base_level,
1161
+ notes=(
1162
+ f"Rows from {filename} that could not be imported as tasks. "
1163
+ "Each one below keeps the values you wrote. Fix or delete them, "
1164
+ "and this branch can go."
1165
+ ),
1166
+ )
1167
+ )
1168
+ for offset, unresolved in enumerate(result.unresolved_rows, start=1):
1169
+ tasks.append(
1170
+ TaskData(
1171
+ uid=next_uid + offset,
1172
+ name=f"Row {unresolved.row} — {unresolved.reason}"[:255],
1173
+ # Dotted only when the file was: see the docstring.
1174
+ outline_number=f"{next_top}.{offset}" if next_top else "",
1175
+ outline_level=base_level + 1,
1176
+ notes=_parked_notes(unresolved),
1177
+ )
1178
+ )
1179
+
1180
+
1181
+ def _parked_notes(unresolved: UnresolvedRow) -> str:
1182
+ """Render one parked row's raw values as the task's notes.
1183
+
1184
+ ``Header: value`` lines rather than the original delimited text: the row is
1185
+ here to be read and fixed by a person in the outline, and a re-serialized CSV
1186
+ line would make them count commas to find the cell that is wrong.
1187
+
1188
+ Truncated at ``MAX_PARKED_NOTES_LENGTH`` as a backstop. The per-cell caps
1189
+ already bound this, but ``Task.notes`` is an unbounded TextField and this
1190
+ string is written once per parked row, so the ceiling is stated here too
1191
+ rather than left as a consequence of two other constants.
1192
+ """
1193
+ lines = [
1194
+ f"Spreadsheet row {unresolved.row} — {unresolved.reason}.",
1195
+ "",
1196
+ ]
1197
+ if unresolved.values:
1198
+ lines += [f"{header}: {value}" for header, value in unresolved.values.items()]
1199
+ else:
1200
+ lines.append("(the row was empty in every mapped column)")
1201
+ return "\n".join(lines)[:MAX_PARKED_NOTES_LENGTH]
1202
+
1203
+
1204
+ def _resolve_date_convention(
1205
+ result: ParseResult,
1206
+ data_rows: list[list[Any]],
1207
+ by_field: dict[str, list[int]],
1208
+ requested: str,
1209
+ ) -> DateConvention:
1210
+ """Settle the date order once, over every date cell, and record the evidence.
1211
+
1212
+ Resolved file-wide rather than per row so that a single unambiguous value
1213
+ (13/04 can only be day-first) settles the whole import — a per-row guess
1214
+ would read consecutive rows under different conventions.
1215
+
1216
+ An explicit ``requested`` order always wins: the operator knows what their
1217
+ own export contains, and a heuristic that overrode them would make the
1218
+ control decorative. ``auto`` keeps the shipped behavior, and additionally
1219
+ reports *why* it chose what it chose, plus both readings when it could not
1220
+ tell (which is the state this whole feature exists for — see #2926).
1221
+
1222
+ Args:
1223
+ result: Parse result; its ``headers`` name the columns in the evidence.
1224
+ data_rows: Every data row, so one late unambiguous value still counts.
1225
+ by_field: Column indexes per mapped field.
1226
+ requested: One of :data:`DATE_ORDERS`.
1227
+
1228
+ Returns:
1229
+ The convention, its evidence, and the per-order tallies the wizard needs.
1230
+ """
1231
+ date_indices = [_one(by_field, f) for f in ("planned_start", "planned_finish") if f in by_field]
1232
+ convention = DateConvention(requested=requested, has_date_columns=bool(date_indices))
1233
+ if not date_indices:
1234
+ # Nothing is mapped to a date field, so there is no convention to settle
1235
+ # and nothing for the control to act on. Reported rather than guessed:
1236
+ # the wizard renders an inert block instead of a confident-looking one.
1237
+ return convention
1238
+
1239
+ cells = [
1240
+ (offset + 2, result.headers[i], _cell(row, i))
1241
+ for offset, row in enumerate(data_rows)
1242
+ for i in date_indices
1243
+ if _cell(row, i).strip()
1244
+ ]
1245
+ has_slash = any(_SLASH_DATE_RE.match(raw.strip()) for _, _, raw in cells)
1246
+
1247
+ auto_order, evidence = _slash_date_evidence(cells)
1248
+ if auto_order is None and not has_slash and cells:
1249
+ # Every value is a non-slash layout, so the file is self-describing and
1250
+ # there was never a decision to make. Named as ISO because that is what
1251
+ # the operator sees offered, and what the vast majority of such files are.
1252
+ auto_order, evidence = (
1253
+ "iso",
1254
+ {
1255
+ "row": cells[0][0],
1256
+ "column": cells[0][1],
1257
+ "value": cells[0][2].strip(),
1258
+ "reason": "non_slash_layout",
1259
+ },
1260
+ )
1261
+ convention.auto_resolved = auto_order or AMBIGUOUS_FALLBACK_ORDER
1262
+
1263
+ if requested == "auto":
1264
+ convention.resolved = convention.auto_resolved
1265
+ convention.evidence = evidence
1266
+ # Ambiguous only when auto had a real decision to make and no evidence
1267
+ # for it. A file with no slash dates at all is not ambiguous; it is
1268
+ # simply unambiguous in a way that needed no argument.
1269
+ convention.ambiguous = auto_order is None and has_slash
1270
+ else:
1271
+ convention.resolved = requested
1272
+ convention.evidence = evidence
1273
+ convention.ambiguous = False
1274
+
1275
+ convention.values_matched, convention.values_failed = _tally(cells, convention.resolved)
1276
+
1277
+ if convention.ambiguous:
1278
+ convention.readings = [
1279
+ _reading(order, cells, data_rows, by_field, result) for order in ("mdy", "dmy")
1280
+ ]
1281
+ return convention
1282
+
1283
+
1284
+ def _tally(cells: list[tuple[int, str, str]], order: str) -> tuple[int, int]:
1285
+ """Date cells that parse under ``order``, and those that do not."""
1286
+ matched = sum(1 for _, _, raw in cells if _parse_date(raw, order) is not None)
1287
+ return matched, len(cells) - matched
1288
+
1289
+
1290
+ def _reading(
1291
+ order: str,
1292
+ cells: list[tuple[int, str, str]],
1293
+ data_rows: list[list[Any]],
1294
+ by_field: dict[str, list[int]],
1295
+ result: ParseResult,
1296
+ ) -> DateReading:
1297
+ """What one convention does to this file — tallies plus one worked example.
1298
+
1299
+ The sample is the first row that has both a start and a finish under this
1300
+ order, because a duration is the only rendering of the choice that is
1301
+ self-evidently right or wrong to a reader.
1302
+ """
1303
+ matched, failed = _tally(cells, order)
1304
+ reading = DateReading(order=order, values_matched=matched, values_failed=failed)
1305
+
1306
+ start_idx = _optional_index(by_field, "planned_start")
1307
+ finish_idx = _optional_index(by_field, "planned_finish")
1308
+ name_idx = _optional_index(by_field, "name")
1309
+
1310
+ for offset, row in enumerate(data_rows):
1311
+ raws = [_cell(row, i) for i in (start_idx, finish_idx) if i is not None]
1312
+ if any(raw.strip() and _parse_date(raw, order) is None for raw in raws):
1313
+ reading.rows_unparseable += 1
1314
+ if reading.sample_row is None:
1315
+ _capture_sample(reading, row, offset, start_idx, finish_idx, name_idx, order)
1316
+ return reading
1317
+
1318
+
1319
+ def _capture_sample(
1320
+ reading: DateReading,
1321
+ row: list[Any],
1322
+ offset: int,
1323
+ start_idx: int | None,
1324
+ finish_idx: int | None,
1325
+ name_idx: int | None,
1326
+ order: str,
1327
+ ) -> None:
1328
+ """Record this row as the worked example, if it has both ends under ``order``.
1329
+
1330
+ A no-op unless the row parses to both a start and a finish: a duration is the
1331
+ only rendering of the choice that is self-evidently right or wrong to a
1332
+ reader, so a half-parsed row is not worth showing.
1333
+ """
1334
+ start = _parse_date(_cell(row, start_idx), order) if start_idx is not None else None
1335
+ finish = _parse_date(_cell(row, finish_idx), order) if finish_idx is not None else None
1336
+ if not (start and finish):
1337
+ return
1338
+ reading.sample_row = offset + 2
1339
+ reading.sample_name = _cell(row, name_idx).strip() if name_idx is not None else ""
1340
+ reading.sample_raw_start = _cell(row, start_idx).strip()
1341
+ reading.start = start.isoformat()
1342
+ reading.finish = finish.isoformat()
1343
+ # Inclusive of both endpoints, matching _apply_derived_duration — the
1344
+ # comparison is worthless if it disagrees with the duration the import will
1345
+ # actually write.
1346
+ reading.duration_days = (finish - start).days + 1
1347
+
1348
+
1349
+ def _build_task(
1350
+ offset: int,
1351
+ raw_name: str,
1352
+ row: list[Any],
1353
+ by_field: dict[str, list[int]],
1354
+ result: ParseResult,
1355
+ row_number: int,
1356
+ order: str,
1357
+ task_count: int,
1358
+ *,
1359
+ wbs_is_codes: bool,
1360
+ ) -> TaskData:
1361
+ """Build one ``TaskData`` from a named row, applying every mapped column."""
1362
+ task = TaskData(uid=offset + 1, name=raw_name.strip()[:255])
1363
+ task.outline_number = str(task_count + 1)
1364
+ task.outline_level = _clamp_outline_level(
1365
+ _indent_level(raw_name), result, row_number, result.headers[_one(by_field, "name")]
1366
+ )
1367
+
1368
+ if "wbs" in by_field:
1369
+ wbs_index = _one(by_field, "wbs")
1370
+ wbs_raw = _cell(row, wbs_index).strip()
1371
+ if wbs_raw:
1372
+ _apply_wbs(
1373
+ task,
1374
+ wbs_raw,
1375
+ result,
1376
+ row_number,
1377
+ result.headers[wbs_index],
1378
+ is_code=wbs_is_codes,
1379
+ )
1380
+
1381
+ _apply_duration(task, row, by_field, result, row_number)
1382
+ _apply_dates(task, row, by_field, result, row_number, order)
1383
+ _apply_percent(task, row, by_field, result, row_number)
1384
+
1385
+ if "milestone" in by_field and _parse_bool(_cell(row, _one(by_field, "milestone"))):
1386
+ task.is_milestone = True
1387
+ # A zero-day task is a milestone whatever the Milestone column says — the
1388
+ # duration is the stronger signal because it is what the schedule computes on.
1389
+ if task.duration_days == 0:
1390
+ task.is_milestone = True
1391
+ if "notes" in by_field:
1392
+ task.notes = _cell(row, _one(by_field, "notes")).strip()[:2000]
1393
+ return task
1394
+
1395
+
1396
+ def _apply_wbs(
1397
+ task: TaskData,
1398
+ wbs_raw: str,
1399
+ result: ParseResult,
1400
+ row_number: int,
1401
+ column: str,
1402
+ *,
1403
+ is_code: bool,
1404
+ ) -> None:
1405
+ """Apply one WBS cell under the convention settled for its whole column.
1406
+
1407
+ ``is_code`` comes from :func:`_wbs_column_holds_codes`, which reads every cell in
1408
+ the column before any of them is applied. It is not re-derived here: deciding per
1409
+ cell is the defect this argument exists to remove (#3082), and a bare integer is a
1410
+ perfectly ordinary *code* in a column whose other rows are dotted.
1411
+ """
1412
+ if is_code and all(p.strip().isdigit() for p in wbs_raw.split(".")):
1413
+ # An outline code carries the hierarchy directly; the importer's
1414
+ # _build_wbs_paths prefers this over the level sequence. A bare integer here is
1415
+ # a top-level code (a phase's own number), not a depth.
1416
+ task.outline_number = wbs_raw.strip()
1417
+ task.outline_level = wbs_raw.count(".")
1418
+ elif not is_code and wbs_raw.isdigit():
1419
+ # A bare integer in a "Level"/"Outline" column is a depth, not a code.
1420
+ # The length gate avoids handing an enormous digit string straight to
1421
+ # int(): Python's int-from-str conversion limit would raise before
1422
+ # _clamp_outline_level ever runs, and no legitimate depth is remotely
1423
+ # this long (#2761).
1424
+ level = int(wbs_raw) if len(wbs_raw) <= 10 else MAX_OUTLINE_DEPTH + 1
1425
+ task.outline_level = _clamp_outline_level(level, result, row_number, column)
1426
+
1427
+
1428
+ def _apply_duration(
1429
+ task: TaskData,
1430
+ row: list[Any],
1431
+ by_field: dict[str, list[int]],
1432
+ result: ParseResult,
1433
+ row_number: int,
1434
+ ) -> None:
1435
+ if "duration" not in by_field:
1436
+ return
1437
+ raw = _cell(row, _one(by_field, "duration"))
1438
+ if not raw.strip():
1439
+ return
1440
+ value = _parse_duration(raw)
1441
+ if value is None:
1442
+ result.row_errors.append(
1443
+ RowError(
1444
+ row_number,
1445
+ result.headers[_one(by_field, "duration")],
1446
+ "bad_duration",
1447
+ f"Could not read '{raw.strip()}' as a duration; defaulted to 1 day.",
1448
+ )
1449
+ )
1450
+ return
1451
+ if value < 0:
1452
+ result.row_errors.append(
1453
+ RowError(
1454
+ row_number,
1455
+ result.headers[_one(by_field, "duration")],
1456
+ "bad_duration",
1457
+ f"Negative duration '{raw.strip()}'; defaulted to 1 day.",
1458
+ )
1459
+ )
1460
+ return
1461
+ task.duration_days = round(value)
1462
+
1463
+
1464
+ def _apply_dates(
1465
+ task: TaskData,
1466
+ row: list[Any],
1467
+ by_field: dict[str, list[int]],
1468
+ result: ParseResult,
1469
+ row_number: int,
1470
+ order: str,
1471
+ ) -> None:
1472
+ start = _read_date_cell("planned_start", "start", row, by_field, result, row_number, order)
1473
+ finish = _read_date_cell("planned_finish", "finish", row, by_field, result, row_number, order)
1474
+
1475
+ if start:
1476
+ task.start = start.isoformat()
1477
+
1478
+ # ProjectData carries a start + duration, not a finish, so a file that gives
1479
+ # both has its duration derived from the span (inclusive of both endpoints).
1480
+ # An explicit Duration column already read above wins, because it is the
1481
+ # operator's stated intent rather than something inferred.
1482
+ if start and finish and "duration" not in by_field:
1483
+ _apply_derived_duration(task, start, finish, by_field, result, row_number)
1484
+
1485
+
1486
+ def _read_date_cell(
1487
+ field_name: str,
1488
+ what: str,
1489
+ row: list[Any],
1490
+ by_field: dict[str, list[int]],
1491
+ result: ParseResult,
1492
+ row_number: int,
1493
+ order: str,
1494
+ ) -> date | None:
1495
+ """Read one date column, recording a row error when the cell will not parse.
1496
+
1497
+ An unreadable date is data, not an exception: the row still imports with that
1498
+ one field dropped, so a single malformed cell cannot cost the operator the
1499
+ whole file.
1500
+ """
1501
+ if field_name not in by_field:
1502
+ return None
1503
+ raw = _cell(row, _one(by_field, field_name))
1504
+ if not raw.strip():
1505
+ return None
1506
+ value = _parse_date(raw, order)
1507
+ if value is None:
1508
+ result.row_errors.append(
1509
+ RowError(
1510
+ row_number,
1511
+ result.headers[_one(by_field, field_name)],
1512
+ "bad_date",
1513
+ f"Could not read '{raw.strip()}' as a date; the {what} was left unset.",
1514
+ )
1515
+ )
1516
+ return value
1517
+
1518
+
1519
+ def _apply_derived_duration(
1520
+ task: TaskData,
1521
+ start: date,
1522
+ finish: date,
1523
+ by_field: dict[str, list[int]],
1524
+ result: ParseResult,
1525
+ row_number: int,
1526
+ ) -> None:
1527
+ """Set duration from an inclusive ``start``..``finish`` span."""
1528
+ span = (finish - start).days + 1
1529
+ if span < 1:
1530
+ result.row_errors.append(
1531
+ RowError(
1532
+ row_number,
1533
+ result.headers[_one(by_field, "planned_finish")],
1534
+ "finish_before_start",
1535
+ "Finish is before start; duration defaulted to 1 day.",
1536
+ )
1537
+ )
1538
+ else:
1539
+ task.duration_days = span
1540
+
1541
+
1542
+ def _apply_percent(
1543
+ task: TaskData,
1544
+ row: list[Any],
1545
+ by_field: dict[str, list[int]],
1546
+ result: ParseResult,
1547
+ row_number: int,
1548
+ ) -> None:
1549
+ if "percent_complete" not in by_field:
1550
+ return
1551
+ raw = _cell(row, _one(by_field, "percent_complete"))
1552
+ if not raw.strip():
1553
+ return
1554
+ value = _parse_percent(raw)
1555
+ if value is None:
1556
+ result.row_errors.append(
1557
+ RowError(
1558
+ row_number,
1559
+ result.headers[_one(by_field, "percent_complete")],
1560
+ "bad_percent",
1561
+ f"Could not read '{raw.strip()}' as a percentage; treated as 0%.",
1562
+ )
1563
+ )
1564
+ return
1565
+ task.percent_complete = value
1566
+
1567
+
1568
+ def _resolve_predecessors(
1569
+ result: ParseResult,
1570
+ data_rows: list[list[Any]],
1571
+ by_field: dict[str, list[int]],
1572
+ ) -> None:
1573
+ """Attach FS/SS/FF/SF links, quarantining refs that do not resolve.
1574
+
1575
+ References are matched against the source file's own ID column when it has
1576
+ one (how MS Project and Asana CSV exports encode dependencies), and against
1577
+ 1-based row position otherwise. Both are tried for every reference, so a
1578
+ file that mixes the two still resolves.
1579
+
1580
+ ``predecessors`` is a ``multi`` field (#2406): an MS Project export spreads
1581
+ dependencies across ``Predecessor 1``, ``Predecessor 2``, … and every one of
1582
+ them is a real link. Each mapped column is read, and a row error names the
1583
+ column the bad token actually came from rather than the first one.
1584
+ """
1585
+ indexes = by_field.get("predecessors", [])
1586
+ if not indexes:
1587
+ return
1588
+
1589
+ tasks = result.project_data.tasks
1590
+ by_handle = _predecessor_handle_map(tasks, data_rows, by_field)
1591
+ for task in tasks:
1592
+ _attach_row_predecessors(task, indexes, by_handle, data_rows, result)
1593
+
1594
+
1595
+ def _predecessor_handle_map(
1596
+ tasks: list[TaskData], data_rows: list[list[Any]], by_field: dict[str, list[int]]
1597
+ ) -> dict[str, int]:
1598
+ """Map every usable predecessor handle → task uid.
1599
+
1600
+ Row position is registered first so an explicit ID column overrides it on
1601
+ collision — the ID is the stronger claim.
1602
+ """
1603
+ by_handle: dict[str, int] = {
1604
+ str(position): task.uid for position, task in enumerate(tasks, start=1)
1605
+ }
1606
+ if "external_id" not in by_field:
1607
+ return by_handle
1608
+
1609
+ id_index = _one(by_field, "external_id")
1610
+ # data_rows and tasks diverge when a row was dropped for a missing name,
1611
+ # so walk the tasks and re-read the row each one came from via its uid.
1612
+ for task in tasks:
1613
+ raw_id = _cell(data_rows[task.uid - 1], id_index).strip()
1614
+ if raw_id:
1615
+ by_handle[raw_id] = task.uid
1616
+ return by_handle
1617
+
1618
+
1619
+ def _resolved_link(
1620
+ token: str,
1621
+ by_handle: dict[str, int],
1622
+ task: TaskData,
1623
+ *,
1624
+ row_number: int,
1625
+ column: str,
1626
+ result: ParseResult,
1627
+ ) -> PredecessorLinkData | None:
1628
+ """Resolve one reference token, or record why it was skipped and return ``None``.
1629
+
1630
+ A bad token never fails the import — it quarantines as a row error naming the
1631
+ column it actually came from, and the rest of the row still links.
1632
+ """
1633
+ link = _parse_predecessor(token, by_handle)
1634
+ if link is None:
1635
+ result.row_errors.append(
1636
+ RowError(
1637
+ row_number,
1638
+ column,
1639
+ "unknown_predecessor",
1640
+ f"Predecessor '{token.strip()}' does not match any row; the link was skipped.",
1641
+ )
1642
+ )
1643
+ return None
1644
+ if link.predecessor_uid == task.uid:
1645
+ result.row_errors.append(
1646
+ RowError(
1647
+ row_number,
1648
+ column,
1649
+ "self_dependency",
1650
+ "A task cannot depend on itself; the link was skipped.",
1651
+ )
1652
+ )
1653
+ return None
1654
+ return link
1655
+
1656
+
1657
+ def _attach_row_predecessors(
1658
+ task: TaskData,
1659
+ indexes: list[int],
1660
+ by_handle: dict[str, int],
1661
+ data_rows: list[list[Any]],
1662
+ result: ParseResult,
1663
+ ) -> None:
1664
+ """Read every mapped predecessor column for one task and attach its links."""
1665
+ row_number = task.uid + 1
1666
+ seen: set[tuple[int, str, int]] = set()
1667
+ for index in indexes:
1668
+ column = result.headers[index]
1669
+ raw = _cell(data_rows[task.uid - 1], index).strip()
1670
+ if not raw:
1671
+ continue
1672
+ for token in re.split(r"[,;]", raw):
1673
+ if not token.strip():
1674
+ continue
1675
+ link = _resolved_link(
1676
+ token, by_handle, task, row_number=row_number, column=column, result=result
1677
+ )
1678
+ if link is None:
1679
+ continue
1680
+ # Two columns naming the same predecessor is a union, not two
1681
+ # edges — the importer would otherwise create a duplicate
1682
+ # Dependency row for one real relationship.
1683
+ key = (link.predecessor_uid, link.dep_type, link.lag_days)
1684
+ if key in seen:
1685
+ continue
1686
+ seen.add(key)
1687
+ task.predecessor_links.append(link)
1688
+
1689
+
1690
+ def _parse_predecessor(token: str, by_handle: dict[str, int]) -> PredecessorLinkData | None:
1691
+ match = _PREDECESSOR_RE.match(token)
1692
+ if not match:
1693
+ return None
1694
+ uid = by_handle.get(match.group("ref").strip())
1695
+ if uid is None:
1696
+ return None
1697
+ lag = float(match.group("lag") or 0)
1698
+ if match.group("sign") == "-":
1699
+ lag = -lag
1700
+ return PredecessorLinkData(
1701
+ predecessor_uid=uid,
1702
+ dep_type=(match.group("type") or "FS").upper(),
1703
+ lag_days=round(lag),
1704
+ )
1705
+
1706
+
1707
+ def _build_labels(
1708
+ result: ParseResult,
1709
+ data_rows: list[list[Any]],
1710
+ by_field: dict[str, list[int]],
1711
+ ) -> None:
1712
+ """Attach label names to each task, unioned across every mapped column.
1713
+
1714
+ ``labels`` is a ``multi`` field (#2406): a sheet routinely carries `Tags`,
1715
+ `Component` and `Team`, all of which are labels. Every mapped column is
1716
+ read and the values unioned onto the task.
1717
+
1718
+ Names are deduplicated case-insensitively — first-seen spelling wins, both
1719
+ within a task and across the file — so an import cannot mint `Safety`
1720
+ alongside an existing `safety`. The parser only produces *names*; matching
1721
+ them against the project's existing catalog is the importer's job, since
1722
+ only it can see the database.
1723
+ """
1724
+ indexes = by_field.get("labels", [])
1725
+ if not indexes:
1726
+ return
1727
+
1728
+ # File-wide canonical spelling per lowercased name, so two tasks spelling a
1729
+ # label differently still land on one catalog entry.
1730
+ canonical: dict[str, str] = {}
1731
+ truncated = False
1732
+
1733
+ for task in result.project_data.tasks:
1734
+ dropped, row_truncated = _collect_task_labels(
1735
+ task, data_rows[task.uid - 1], indexes, canonical
1736
+ )
1737
+ truncated = truncated or row_truncated
1738
+ if dropped:
1739
+ result.row_errors.append(
1740
+ RowError(
1741
+ task.uid + 1,
1742
+ result.headers[indexes[0]],
1743
+ "too_many_labels",
1744
+ f"{dropped} label(s) on this row were skipped; an import is capped at "
1745
+ f"{MAX_IMPORT_LABELS} distinct labels and {MAX_LABELS_PER_TASK} per task.",
1746
+ )
1747
+ )
1748
+
1749
+ if truncated:
1750
+ result.warnings.append(
1751
+ f"Some label names were longer than {LABEL_NAME_MAX_LENGTH} characters "
1752
+ "and were shortened."
1753
+ )
1754
+
1755
+
1756
+ def _collect_task_labels(
1757
+ task: TaskData,
1758
+ row: list[Any],
1759
+ indexes: list[int],
1760
+ canonical: dict[str, str],
1761
+ ) -> tuple[int, bool]:
1762
+ """Union every mapped label column onto one task, honoring the import caps.
1763
+
1764
+ ``canonical`` is threaded in and mutated so the first-seen spelling of a name
1765
+ wins file-wide, not merely within this row.
1766
+
1767
+ Returns ``(dropped, truncated)`` — how many names the caps skipped, and
1768
+ whether any name had to be shortened.
1769
+ """
1770
+ seen: set[str] = set()
1771
+ dropped = 0
1772
+ truncated = False
1773
+ for index in indexes:
1774
+ for raw_name in _split_values(_cell(row, index)):
1775
+ name = raw_name[:LABEL_NAME_MAX_LENGTH]
1776
+ truncated = truncated or len(raw_name) > LABEL_NAME_MAX_LENGTH
1777
+ key = name.lower()
1778
+ if key in seen:
1779
+ continue
1780
+ if key not in canonical and len(canonical) >= MAX_IMPORT_LABELS:
1781
+ dropped += 1
1782
+ continue
1783
+ if len(seen) >= MAX_LABELS_PER_TASK:
1784
+ dropped += 1
1785
+ continue
1786
+ seen.add(key)
1787
+ task.labels.append(canonical.setdefault(key, name))
1788
+ return dropped, truncated
1789
+
1790
+
1791
+ def _build_resources(
1792
+ result: ParseResult,
1793
+ data_rows: list[list[Any]],
1794
+ by_field: dict[str, list[int]],
1795
+ ) -> None:
1796
+ """Collect distinct assignee names and attach one assignment per task.
1797
+
1798
+ Names are deduplicated case-insensitively but the first-seen spelling is
1799
+ kept, so "J. Smith" and "j. smith" become one resource named as first
1800
+ written. ``import_project`` match-or-creates these against the project's
1801
+ resource pool.
1802
+ """
1803
+ if "resource" not in by_field:
1804
+ return
1805
+
1806
+ resources: list[ResourceData] = []
1807
+ uid_by_key: dict[str, int] = {}
1808
+
1809
+ for task in result.project_data.tasks:
1810
+ raw = _cell(data_rows[task.uid - 1], _one(by_field, "resource")).strip()
1811
+ if not raw:
1812
+ continue
1813
+ for part in re.split(r"[,;/]", raw):
1814
+ name = part.strip()
1815
+ if not name:
1816
+ continue
1817
+ key = name.lower()
1818
+ if key not in uid_by_key:
1819
+ uid_by_key[key] = len(resources) + 1
1820
+ resources.append(ResourceData(uid=uid_by_key[key], name=name[:255]))
1821
+ task.resource_assignments.append(
1822
+ AssignmentData(task_uid=task.uid, resource_uid=uid_by_key[key], units=1.0)
1823
+ )
1824
+
1825
+ result.project_data.resources = resources