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.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- 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
|