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,1231 @@
|
|
|
1
|
+
"""Parsers for MS Project .xml and .mpp files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import date
|
|
15
|
+
|
|
16
|
+
from defusedxml.ElementTree import fromstring as _safe_fromstring
|
|
17
|
+
from django.conf import settings
|
|
18
|
+
|
|
19
|
+
from trueppm_api.apps.msproject.dataclasses import (
|
|
20
|
+
CONSTRAINT_TYPES_APPLIED_AS_SNET,
|
|
21
|
+
AssignmentData,
|
|
22
|
+
CalendarData,
|
|
23
|
+
CalendarExceptionData,
|
|
24
|
+
PredecessorLinkData,
|
|
25
|
+
ProjectData,
|
|
26
|
+
ResourceData,
|
|
27
|
+
TaskData,
|
|
28
|
+
)
|
|
29
|
+
from trueppm_api.apps.msproject.extended_attributes import (
|
|
30
|
+
DURATION1_FIELD_ID,
|
|
31
|
+
DURATION2_FIELD_ID,
|
|
32
|
+
DURATION3_FIELD_ID,
|
|
33
|
+
PERT_ALIAS_TOKENS,
|
|
34
|
+
PERT_ROLE_BY_FIELD_ID,
|
|
35
|
+
)
|
|
36
|
+
from trueppm_api.apps.scheduling.units import (
|
|
37
|
+
HOURS_PER_WORKING_DAY,
|
|
38
|
+
MSPDI_LAG_TENTHS_PER_WORKING_DAY,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# MS Project PredecessorLink/Type values -> TruePPM dep_type.
|
|
44
|
+
_LINK_TYPE_MAP = {
|
|
45
|
+
"0": "FF",
|
|
46
|
+
"1": "FS",
|
|
47
|
+
"2": "SF",
|
|
48
|
+
"3": "SS",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# MSPDI WeekDay/DayType (1=Sunday … 7=Saturday) -> TruePPM Calendar.working_days
|
|
52
|
+
# bit (Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64). DayType 0 is the
|
|
53
|
+
# legacy (2003-style) per-date exception form, handled separately.
|
|
54
|
+
_DAY_TYPE_TO_BIT = {
|
|
55
|
+
"1": 64, # Sunday
|
|
56
|
+
"2": 1, # Monday
|
|
57
|
+
"3": 2, # Tuesday
|
|
58
|
+
"4": 4, # Wednesday
|
|
59
|
+
"5": 8, # Thursday
|
|
60
|
+
"6": 16, # Friday
|
|
61
|
+
"7": 32, # Saturday
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Subprocess timeout for MPXJ (seconds).
|
|
65
|
+
_MPXJ_TIMEOUT = 120
|
|
66
|
+
|
|
67
|
+
# Sane upper bound for resource MaxUnits / assignment Units (#1720). MS Project
|
|
68
|
+
# expresses these as fractions of a resource's capacity (1.0 = 100%); a value of
|
|
69
|
+
# 10 already means ten full-time equivalents. Anything past this is either a
|
|
70
|
+
# corrupt/hostile file or a non-finite value we clamp — the value flows into
|
|
71
|
+
# resource-leveling and allocation math, so we bound it rather than let inf/NaN
|
|
72
|
+
# or an astronomical figure poison those computations.
|
|
73
|
+
_MAX_UNITS = 1_000.0
|
|
74
|
+
|
|
75
|
+
# MSPDI <ConstraintType> codes and their MS Project names (#2891). Used in the
|
|
76
|
+
# import warnings, so an operator reading "12 tasks carry Finish No Later Than"
|
|
77
|
+
# sees the term their own plan uses.
|
|
78
|
+
MSPDI_CONSTRAINT_NAMES: dict[int, str] = {
|
|
79
|
+
0: "As Soon As Possible",
|
|
80
|
+
1: "As Late As Possible",
|
|
81
|
+
2: "Must Start On",
|
|
82
|
+
3: "Must Finish On",
|
|
83
|
+
4: "Start No Earlier Than",
|
|
84
|
+
5: "Start No Later Than",
|
|
85
|
+
6: "Finish No Earlier Than",
|
|
86
|
+
7: "Finish No Later Than",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#: The one code whose partial mapping needs saying out loud (see
|
|
90
|
+
#: ``CONSTRAINT_TYPES_APPLIED_AS_SNET`` in ``dataclasses``).
|
|
91
|
+
_CONSTRAINT_TYPE_PARTIALLY_APPLIED = 2
|
|
92
|
+
|
|
93
|
+
#: Codes that cost the operator nothing when dropped: MSPDI's default, and any
|
|
94
|
+
#: code we actually apply.
|
|
95
|
+
_CONSTRAINT_TYPES_NOT_A_LOSS: frozenset[int] = frozenset({0}) | CONSTRAINT_TYPES_APPLIED_AS_SNET
|
|
96
|
+
|
|
97
|
+
#: MS Project's default task priority. MSPDI writes ``<Priority>`` on *every*
|
|
98
|
+
#: task, so the warning has to key on a value the PM actually changed — otherwise
|
|
99
|
+
#: every import reports a loss that did not happen.
|
|
100
|
+
_MSPDI_DEFAULT_PRIORITY = 500
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _int_or_none(raw: str) -> int | None:
|
|
104
|
+
"""Read an integer element's text, or ``None`` when it is absent/unparseable."""
|
|
105
|
+
try:
|
|
106
|
+
return int((raw or "").strip())
|
|
107
|
+
except ValueError:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_nonzero_msp_amount(raw: str) -> bool:
|
|
112
|
+
"""True when an MSPDI duration or currency value carries a real quantity.
|
|
113
|
+
|
|
114
|
+
MS Project writes ``<Work>PT0H0M0S</Work>`` and ``<Cost>0</Cost>`` on tasks
|
|
115
|
+
that have neither, so "the element is present" is not evidence the PM entered
|
|
116
|
+
anything. Any digit other than zero is.
|
|
117
|
+
"""
|
|
118
|
+
text = (raw or "").strip()
|
|
119
|
+
if not text:
|
|
120
|
+
return False
|
|
121
|
+
return any(ch.isdigit() and ch != "0" for ch in text)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
#: MSPDI task elements TruePPM has no column for, as
|
|
125
|
+
#: ``(element name, warning phrase, "is this value meaningful?")``. Each entry is
|
|
126
|
+
#: a **deliberate** non-import, and the warning is the entire mechanism by which
|
|
127
|
+
#: the operator learns that — the importer previously dropped all of them with
|
|
128
|
+
#: ``warnings == []``, so a plan that round-tripped came back subtly different
|
|
129
|
+
#: with nothing said (#2891).
|
|
130
|
+
#:
|
|
131
|
+
#: The third element exists because a full MS Project export writes ``Priority``,
|
|
132
|
+
#: ``Work`` and ``Cost`` on every row whether or not the PM set them. Counting
|
|
133
|
+
#: those defaults as dropped data would bury the families that *were* set under
|
|
134
|
+
#: noise, and a warning nobody can act on trains people to ignore the list. So
|
|
135
|
+
#: each predicate answers "did this file actually carry something here?".
|
|
136
|
+
#:
|
|
137
|
+
#: Order is the order the warnings appear in the summary: schedule commitments
|
|
138
|
+
#: first, then the estimating fields.
|
|
139
|
+
_UNMAPPED_TASK_FIELDS: tuple[tuple[str, str, Callable[[str], bool]], ...] = (
|
|
140
|
+
("Deadline", "deadline dates (TruePPM has no deadline field)", bool),
|
|
141
|
+
(
|
|
142
|
+
"Baseline",
|
|
143
|
+
"baselines (capture a TruePPM baseline after the import lands instead)",
|
|
144
|
+
bool,
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"Priority",
|
|
148
|
+
"task priority (MS Project's 0-1000 weight has no TruePPM equivalent)",
|
|
149
|
+
lambda raw: _int_or_none(raw) not in (None, _MSPDI_DEFAULT_PRIORITY),
|
|
150
|
+
),
|
|
151
|
+
(
|
|
152
|
+
"Work",
|
|
153
|
+
"work / effort hours (TruePPM schedules on duration, not effort)",
|
|
154
|
+
_is_nonzero_msp_amount,
|
|
155
|
+
),
|
|
156
|
+
("Cost", "cost figures (TruePPM tracks no task cost)", _is_nonzero_msp_amount),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MsProjectImportError(ValueError):
|
|
161
|
+
"""The uploaded MS Project file is too large to import (row-count cap, #1721).
|
|
162
|
+
|
|
163
|
+
A ``ValueError`` subclass so the import task's ``except Exception`` marks the
|
|
164
|
+
outbox row DEAD (deterministic failure — the same bytes always exceed the
|
|
165
|
+
cap) and surfaces ``str(exc)`` to the UI.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _finite_float(raw: str, default: float, *, low: float, high: float) -> float:
|
|
170
|
+
"""Parse ``raw`` to a finite float clamped to ``[low, high]`` (#1720).
|
|
171
|
+
|
|
172
|
+
``bulk_create`` bypasses the model field validators, so a crafted MSPDI file
|
|
173
|
+
could otherwise smuggle ``nan``, ``inf``/``-inf``, or ``1e999`` (which
|
|
174
|
+
Python parses to ``inf``) straight into a ``FloatField``. Non-finite floats
|
|
175
|
+
then poison CPM / Monte Carlo math and — because ``json.dumps`` emits bare
|
|
176
|
+
``NaN``/``Infinity`` tokens — produce invalid JSON in API, sync, and webhook
|
|
177
|
+
bodies. We reject non-finite values by falling back to ``default`` and clamp
|
|
178
|
+
the finite range so the same choke point also caps absurd magnitudes.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
value = float(raw)
|
|
182
|
+
except (TypeError, ValueError):
|
|
183
|
+
return default
|
|
184
|
+
if not math.isfinite(value):
|
|
185
|
+
return default
|
|
186
|
+
return max(low, min(value, high))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Upper bound matches Task.duration's MaxValueValidator(36_525) (~100 years).
|
|
190
|
+
# bulk_create() bypasses the model validators, so a crafted MSPDI file could
|
|
191
|
+
# otherwise smuggle astronomical integer values into the database (e.g.
|
|
192
|
+
# PT9999999999H gets us 1.25 billion days). Clamping in the helper is a single
|
|
193
|
+
# choke point that covers every parser caller — primary Duration, PERT
|
|
194
|
+
# Duration1/2/3, and any future Duration-typed ExtendedAttribute.
|
|
195
|
+
_MAX_DURATION_DAYS = 36_525
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _enforce_row_cap(kind: str, count: int, limit: int) -> None:
|
|
199
|
+
"""Reject an import whose ``kind`` element count exceeds ``limit`` (#1721)."""
|
|
200
|
+
if count > limit:
|
|
201
|
+
raise MsProjectImportError(
|
|
202
|
+
f"MS Project file has too many {kind} ({count}); the import limit is "
|
|
203
|
+
f"{limit}. Split the file and import in batches."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_duration_to_days(duration_str: str) -> int:
|
|
208
|
+
"""Parse MS Project ISO 8601 duration string to working days.
|
|
209
|
+
|
|
210
|
+
MS Project uses PT<hours>H<minutes>M<seconds>S for task durations where
|
|
211
|
+
hours represent calendar hours. We convert to working days using 8h/day.
|
|
212
|
+
Also handles P<n>D and P<n>DT<n>H formats. The result is clamped to
|
|
213
|
+
``[0, _MAX_DURATION_DAYS]`` because ``bulk_create`` skips the model's
|
|
214
|
+
``MaxValueValidator(36_525)`` — see the constant above for rationale.
|
|
215
|
+
"""
|
|
216
|
+
if not duration_str:
|
|
217
|
+
return 1
|
|
218
|
+
|
|
219
|
+
s = duration_str.strip()
|
|
220
|
+
if not s.startswith("P"):
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
s = s[1:]
|
|
224
|
+
days = 0
|
|
225
|
+
hours = 0
|
|
226
|
+
|
|
227
|
+
if "T" in s:
|
|
228
|
+
day_part, time_part = s.split("T", 1)
|
|
229
|
+
else:
|
|
230
|
+
day_part = s
|
|
231
|
+
time_part = ""
|
|
232
|
+
|
|
233
|
+
if day_part and day_part.endswith("D"):
|
|
234
|
+
with contextlib.suppress(ValueError):
|
|
235
|
+
days = int(day_part[:-1])
|
|
236
|
+
|
|
237
|
+
if time_part:
|
|
238
|
+
h_idx = time_part.find("H")
|
|
239
|
+
if h_idx > 0:
|
|
240
|
+
with contextlib.suppress(ValueError):
|
|
241
|
+
hours = int(time_part[:h_idx])
|
|
242
|
+
|
|
243
|
+
total_days = days + (hours // HOURS_PER_WORKING_DAY)
|
|
244
|
+
return max(0, min(total_days, _MAX_DURATION_DAYS))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_lag_to_days(lag_tenths_of_minutes: str) -> int:
|
|
248
|
+
"""Convert MS Project LinkLag (tenths of minutes) to working days.
|
|
249
|
+
|
|
250
|
+
MS Project stores lag in tenths of minutes, so one working day is
|
|
251
|
+
``MSPDI_LAG_TENTHS_PER_WORKING_DAY`` (4800 = 480 min = 8h; #2290 unit seam).
|
|
252
|
+
"""
|
|
253
|
+
if not lag_tenths_of_minutes:
|
|
254
|
+
return 0
|
|
255
|
+
try:
|
|
256
|
+
tenths = int(lag_tenths_of_minutes)
|
|
257
|
+
except ValueError:
|
|
258
|
+
return 0
|
|
259
|
+
return tenths // MSPDI_LAG_TENTHS_PER_WORKING_DAY
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _positive_int_or_none(raw: str) -> int | None:
|
|
263
|
+
"""Parse a calendar UID reference, mapping absent/none sentinels to ``None``.
|
|
264
|
+
|
|
265
|
+
Real calendar UIDs are positive; MSPDI uses ``-1`` for "no calendar"
|
|
266
|
+
(task-level) and ``0`` never refers to a calendar.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
value = int(raw)
|
|
270
|
+
except (TypeError, ValueError):
|
|
271
|
+
return None
|
|
272
|
+
return value if value > 0 else None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _time_to_minutes(raw: str) -> int | None:
|
|
276
|
+
"""Parse an MSPDI time-of-day string (``HH:MM:SS``) to minutes since midnight."""
|
|
277
|
+
parts = raw.strip().split(":")
|
|
278
|
+
if len(parts) < 2:
|
|
279
|
+
return None
|
|
280
|
+
try:
|
|
281
|
+
hours, minutes = int(parts[0]), int(parts[1])
|
|
282
|
+
except ValueError:
|
|
283
|
+
return None
|
|
284
|
+
if not (0 <= hours <= 24 and 0 <= minutes < 60):
|
|
285
|
+
return None
|
|
286
|
+
return hours * 60 + minutes
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _iso_date_or_none(raw: str) -> str | None:
|
|
290
|
+
"""Take the ``YYYY-MM-DD`` prefix of an MSPDI datetime, or None if malformed.
|
|
291
|
+
|
|
292
|
+
Also the gate on MS Project's "no value" sentinels — ``NA`` and an empty
|
|
293
|
+
element both fail ``fromisoformat`` and come back as None, which is what lets
|
|
294
|
+
``<ActualStart>NA</ActualStart>`` stay unset rather than landing in
|
|
295
|
+
``Task.actual_start`` as a fabricated date (#2891).
|
|
296
|
+
"""
|
|
297
|
+
candidate = raw.strip()[:10]
|
|
298
|
+
try:
|
|
299
|
+
date.fromisoformat(candidate)
|
|
300
|
+
except ValueError:
|
|
301
|
+
return None
|
|
302
|
+
return candidate
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _parse_calendar_exception_dates(exc_el: ET.Element, ns: str) -> tuple[str | None, str | None]:
|
|
306
|
+
"""Extract the (start, end) ISO dates from an ``<Exception>`` element.
|
|
307
|
+
|
|
308
|
+
MSPDI has two encodings in the wild: the schema's ``<TimePeriod>``
|
|
309
|
+
(``FromDate``/``ToDate``, written by MS Project 2007+ and MPXJ) and the
|
|
310
|
+
``EnteredStartDate``/``EnteredFinishDate`` pair some exporters emit instead.
|
|
311
|
+
Prefer TimePeriod, fall back to the Entered* pair.
|
|
312
|
+
"""
|
|
313
|
+
period = exc_el.find(f"{ns}TimePeriod")
|
|
314
|
+
if period is not None:
|
|
315
|
+
start = period.findtext(f"{ns}FromDate") or ""
|
|
316
|
+
end = period.findtext(f"{ns}ToDate") or ""
|
|
317
|
+
else:
|
|
318
|
+
start = exc_el.findtext(f"{ns}EnteredStartDate") or ""
|
|
319
|
+
end = exc_el.findtext(f"{ns}EnteredFinishDate") or ""
|
|
320
|
+
return _iso_date_or_none(start), _iso_date_or_none(end)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _weekday_working_hours(day_el: ET.Element, ns: str) -> float:
|
|
324
|
+
"""Sum a working ``<WeekDay>``'s ``<WorkingTime>`` shifts into hours.
|
|
325
|
+
|
|
326
|
+
Zero-length and reversed (``To <= From``) shifts are ignored so a malformed
|
|
327
|
+
entry contributes nothing rather than a negative total.
|
|
328
|
+
"""
|
|
329
|
+
total = 0.0
|
|
330
|
+
times_el = day_el.find(f"{ns}WorkingTimes")
|
|
331
|
+
for wt in times_el.findall(f"{ns}WorkingTime") if times_el is not None else []:
|
|
332
|
+
frm = _time_to_minutes(wt.findtext(f"{ns}FromTime") or "")
|
|
333
|
+
to = _time_to_minutes(wt.findtext(f"{ns}ToTime") or "")
|
|
334
|
+
if frm is not None and to is not None and to > frm:
|
|
335
|
+
total += (to - frm) / 60.0
|
|
336
|
+
return total
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _legacy_weekday_exception(
|
|
340
|
+
day_el: ET.Element, ns: str, name: str, *, working: bool, warnings: list[str]
|
|
341
|
+
) -> CalendarExceptionData | None:
|
|
342
|
+
"""Parse a legacy (2003-style) dated ``<WeekDay DayType=0>`` exception.
|
|
343
|
+
|
|
344
|
+
These encode an exception as a dated WeekDay entry rather than an
|
|
345
|
+
``<Exceptions>`` child but share the non-working-only constraint: a working
|
|
346
|
+
exception is unrepresentable and warned+skipped, and an undated one is
|
|
347
|
+
dropped silently. Returns ``None`` when nothing usable was found.
|
|
348
|
+
"""
|
|
349
|
+
start, end = _parse_calendar_exception_dates(day_el, ns)
|
|
350
|
+
if working:
|
|
351
|
+
warnings.append(
|
|
352
|
+
f"Calendar '{name}': working-time exception "
|
|
353
|
+
f"{start or '?'} to {end or '?'} is not supported and was skipped"
|
|
354
|
+
)
|
|
355
|
+
return None
|
|
356
|
+
if start and end:
|
|
357
|
+
return CalendarExceptionData(start=start, end=end)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _parse_weekday_definitions(
|
|
362
|
+
cal_el: ET.Element, ns: str, name: str, warnings: list[str]
|
|
363
|
+
) -> tuple[int, list[float], list[CalendarExceptionData]]:
|
|
364
|
+
"""Walk ``<WeekDays>`` into (working-day mask, per-day hours, legacy exceptions).
|
|
365
|
+
|
|
366
|
+
The mask starts from the Mon-Fri default and each listed ``<WeekDay>``
|
|
367
|
+
overrides its own day; a resulting all-non-working week is guarded back to
|
|
368
|
+
Mon-Fri (the model's ``validate_working_day_mask`` invariant — a maskless
|
|
369
|
+
calendar would spin the engine's calendar walk, and the importer writes rows
|
|
370
|
+
without running field validators). Legacy dated exceptions are collected here
|
|
371
|
+
so they precede the ``<Exceptions>`` block's rows in the final list.
|
|
372
|
+
"""
|
|
373
|
+
mask = 31 # Mon–Fri baseline; listed WeekDay entries override per day.
|
|
374
|
+
day_hours: list[float] = []
|
|
375
|
+
exceptions: list[CalendarExceptionData] = []
|
|
376
|
+
|
|
377
|
+
weekdays_el = cal_el.find(f"{ns}WeekDays")
|
|
378
|
+
day_els = weekdays_el.findall(f"{ns}WeekDay") if weekdays_el is not None else []
|
|
379
|
+
for day_el in day_els:
|
|
380
|
+
mask = _apply_weekday_entry(
|
|
381
|
+
day_el, ns, name, mask, day_hours=day_hours, exceptions=exceptions, warnings=warnings
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if mask & 0b111_1111 == 0:
|
|
385
|
+
warnings.append(
|
|
386
|
+
f"Calendar '{name}': no working weekday defined; defaulted to Monday-Friday"
|
|
387
|
+
)
|
|
388
|
+
mask = 31
|
|
389
|
+
|
|
390
|
+
return mask, day_hours, exceptions
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _apply_weekday_entry(
|
|
394
|
+
day_el: ET.Element,
|
|
395
|
+
ns: str,
|
|
396
|
+
name: str,
|
|
397
|
+
mask: int,
|
|
398
|
+
*,
|
|
399
|
+
day_hours: list[float],
|
|
400
|
+
exceptions: list[CalendarExceptionData],
|
|
401
|
+
warnings: list[str],
|
|
402
|
+
) -> int:
|
|
403
|
+
"""Fold one ``<WeekDay>`` entry into the mask, appending any hours/exception.
|
|
404
|
+
|
|
405
|
+
Returns the updated mask. ``day_hours`` and ``exceptions`` are appended in
|
|
406
|
+
place (the same accumulate-in-place convention the surrounding parser uses
|
|
407
|
+
for ``warnings``). A ``DayType=0`` entry is a legacy dated exception; an
|
|
408
|
+
unrecognized DayType is ignored.
|
|
409
|
+
"""
|
|
410
|
+
day_type = (day_el.findtext(f"{ns}DayType") or "").strip()
|
|
411
|
+
working = (day_el.findtext(f"{ns}DayWorking") or "").strip() == "1"
|
|
412
|
+
if day_type == "0":
|
|
413
|
+
exc = _legacy_weekday_exception(day_el, ns, name, working=working, warnings=warnings)
|
|
414
|
+
if exc is not None:
|
|
415
|
+
exceptions.append(exc)
|
|
416
|
+
return mask
|
|
417
|
+
bit = _DAY_TYPE_TO_BIT.get(day_type)
|
|
418
|
+
if bit is None:
|
|
419
|
+
return mask
|
|
420
|
+
if working:
|
|
421
|
+
mask |= bit
|
|
422
|
+
hours = _weekday_working_hours(day_el, ns)
|
|
423
|
+
if hours > 0:
|
|
424
|
+
day_hours.append(hours)
|
|
425
|
+
else:
|
|
426
|
+
mask &= ~bit
|
|
427
|
+
return mask
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _parse_calendar_exceptions(
|
|
431
|
+
cal_el: ET.Element, ns: str, name: str, warnings: list[str]
|
|
432
|
+
) -> list[CalendarExceptionData]:
|
|
433
|
+
"""Parse the ``<Exceptions>`` block into non-working ``CalendarExceptionData`` rows.
|
|
434
|
+
|
|
435
|
+
Working exceptions (``DayWorking=1``, e.g. a make-up Saturday) cannot be
|
|
436
|
+
expressed by ``CalendarException`` (non-working only) and are warned+skipped,
|
|
437
|
+
as are entries whose date range is missing or reversed.
|
|
438
|
+
"""
|
|
439
|
+
exceptions: list[CalendarExceptionData] = []
|
|
440
|
+
exceptions_el = cal_el.find(f"{ns}Exceptions")
|
|
441
|
+
for exc_el in exceptions_el.findall(f"{ns}Exception") if exceptions_el is not None else []:
|
|
442
|
+
exc_name = (exc_el.findtext(f"{ns}Name") or "").strip()
|
|
443
|
+
start, end = _parse_calendar_exception_dates(exc_el, ns)
|
|
444
|
+
working = (exc_el.findtext(f"{ns}DayWorking") or "0").strip() == "1"
|
|
445
|
+
if working:
|
|
446
|
+
warnings.append(
|
|
447
|
+
f"Calendar '{name}': working-time exception "
|
|
448
|
+
f"'{exc_name or (start or '?')}' is not supported and was skipped"
|
|
449
|
+
)
|
|
450
|
+
continue
|
|
451
|
+
if start is None or end is None or end < start:
|
|
452
|
+
warnings.append(
|
|
453
|
+
f"Calendar '{name}': exception '{exc_name or '?'}' has an "
|
|
454
|
+
f"unparseable date range and was skipped"
|
|
455
|
+
)
|
|
456
|
+
continue
|
|
457
|
+
exceptions.append(CalendarExceptionData(start=start, end=end, name=exc_name))
|
|
458
|
+
return exceptions
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _modal_hours_per_day(day_hours: list[float]) -> float:
|
|
462
|
+
"""Collapse per-day working totals to the single ``Calendar.hours_per_day`` scalar.
|
|
463
|
+
|
|
464
|
+
Most common daily total wins (ties break toward the first seen): one scalar
|
|
465
|
+
has to represent the week, and the modal day length is what duration
|
|
466
|
+
conversion (days -> real dates) should assume. Defaults to 8.0 with no data
|
|
467
|
+
and is clamped to a sane [1, 24] range.
|
|
468
|
+
"""
|
|
469
|
+
if not day_hours:
|
|
470
|
+
return 8.0
|
|
471
|
+
hours = max(day_hours, key=day_hours.count)
|
|
472
|
+
return max(1.0, min(hours, 24.0))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _parse_one_calendar(cal_el: ET.Element, ns: str, warnings: list[str]) -> CalendarData | None:
|
|
476
|
+
"""Parse a single ``<Calendar>`` element, or ``None`` to skip it.
|
|
477
|
+
|
|
478
|
+
Non-integer UIDs and non-base calendars (``IsBaseCalendar != 1``) are
|
|
479
|
+
skipped; per-resource calendars have no TruePPM equivalent and importing
|
|
480
|
+
them would only pollute the shared calendar library.
|
|
481
|
+
"""
|
|
482
|
+
uid_str = (cal_el.findtext(f"{ns}UID") or "").strip()
|
|
483
|
+
try:
|
|
484
|
+
uid = int(uid_str)
|
|
485
|
+
except ValueError:
|
|
486
|
+
return None
|
|
487
|
+
if (cal_el.findtext(f"{ns}IsBaseCalendar") or "").strip() != "1":
|
|
488
|
+
return None
|
|
489
|
+
name = (cal_el.findtext(f"{ns}Name") or "").strip() or f"Imported calendar {uid}"
|
|
490
|
+
|
|
491
|
+
mask, day_hours, exceptions = _parse_weekday_definitions(cal_el, ns, name, warnings)
|
|
492
|
+
# Legacy dated exceptions (from the WeekDays walk) precede the <Exceptions>
|
|
493
|
+
# block's rows, matching the original single-pass append order.
|
|
494
|
+
exceptions.extend(_parse_calendar_exceptions(cal_el, ns, name, warnings))
|
|
495
|
+
|
|
496
|
+
return CalendarData(
|
|
497
|
+
uid=uid,
|
|
498
|
+
name=name,
|
|
499
|
+
working_days=mask,
|
|
500
|
+
hours_per_day=_modal_hours_per_day(day_hours),
|
|
501
|
+
exceptions=exceptions,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _parse_calendars(root: ET.Element, ns: str, warnings: list[str]) -> list[CalendarData]:
|
|
506
|
+
"""Parse the ``<Calendars>`` block into ``CalendarData`` rows (#1769).
|
|
507
|
+
|
|
508
|
+
Only *base* calendars are returned: MS Project requires project and task
|
|
509
|
+
calendars to be base calendars, and per-resource calendars
|
|
510
|
+
(``IsBaseCalendar=0``, one auto-generated per resource) have no TruePPM
|
|
511
|
+
equivalent — importing them would only pollute the shared calendar library.
|
|
512
|
+
|
|
513
|
+
Fidelity notes (each degradation is deliberate; see the field-coverage
|
|
514
|
+
matrix in the docs):
|
|
515
|
+
|
|
516
|
+
- The weekday mask starts from the Mon-Fri default and applies whatever
|
|
517
|
+
``<WeekDay>`` entries the file lists. Genuine MS Project exports list all
|
|
518
|
+
seven days, fully overwriting the default; sparse third-party files only
|
|
519
|
+
shift the days they mention instead of losing the whole week.
|
|
520
|
+
- ``Calendar.hours_per_day`` is a single scalar, so per-day
|
|
521
|
+
``<WorkingTimes>`` collapse to the most common daily total across working
|
|
522
|
+
days (ties break toward the first seen). Shift start/end times are not
|
|
523
|
+
preserved — TruePPM schedules in whole working days.
|
|
524
|
+
- Exceptions that *add* working time (``DayWorking=1``, e.g. a make-up
|
|
525
|
+
Saturday) cannot be expressed by ``CalendarException`` (non-working only)
|
|
526
|
+
and are skipped with a warning.
|
|
527
|
+
"""
|
|
528
|
+
calendars_el = root.find(f"{ns}Calendars")
|
|
529
|
+
if calendars_el is None:
|
|
530
|
+
return []
|
|
531
|
+
|
|
532
|
+
calendars: list[CalendarData] = []
|
|
533
|
+
for cal_el in calendars_el.findall(f"{ns}Calendar"):
|
|
534
|
+
cal = _parse_one_calendar(cal_el, ns, warnings)
|
|
535
|
+
if cal is not None:
|
|
536
|
+
calendars.append(cal)
|
|
537
|
+
return calendars
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _child_text(el: ET.Element, ns: str, tag: str) -> str:
|
|
541
|
+
"""Return a namespaced child's text, or ``""`` when absent or empty."""
|
|
542
|
+
child = el.find(f"{ns}{tag}")
|
|
543
|
+
return child.text if child is not None and child.text else ""
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _enforce_row_caps(root: ET.Element, ns: str) -> None:
|
|
547
|
+
"""Reject an oversized import before any objects are materialized (#1721).
|
|
548
|
+
|
|
549
|
+
The upload SIZE is bounded but the row count is not. A 50 MB MSPDI encodes
|
|
550
|
+
~1M tasks; without this cap the importer builds ~1M Task objects, computes
|
|
551
|
+
WBS over all of them, and bulk-creates the lot — exhausting worker memory
|
|
552
|
+
and holding a giant transaction open. Reject outright (like the risk-CSV
|
|
553
|
+
importer's MAX_ROWS) before building anything, counting directly off the
|
|
554
|
+
element tree so we never materialize the objects.
|
|
555
|
+
"""
|
|
556
|
+
max_rows = getattr(settings, "MSPROJECT_MAX_ROWS", 20_000)
|
|
557
|
+
_enforce_row_cap("tasks", len(root.findall(f".//{ns}Task")), max_rows)
|
|
558
|
+
_enforce_row_cap("resources", len(root.findall(f".//{ns}Resource")), max_rows)
|
|
559
|
+
_enforce_row_cap("dependencies", len(root.findall(f".//{ns}PredecessorLink")), max_rows)
|
|
560
|
+
_enforce_row_cap("calendars", len(root.findall(f".//{ns}Calendar")), max_rows)
|
|
561
|
+
_enforce_row_cap("calendar exceptions", len(root.findall(f".//{ns}Exception")), max_rows)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _parse_resources(
|
|
565
|
+
root: ET.Element, ns: str
|
|
566
|
+
) -> tuple[list[ResourceData], dict[int, ResourceData]]:
|
|
567
|
+
"""Parse ``<Resources>`` into (ordered list, ``{uid: ResourceData}`` lookup)."""
|
|
568
|
+
resources: list[ResourceData] = []
|
|
569
|
+
resource_map: dict[int, ResourceData] = {}
|
|
570
|
+
resources_el = root.find(f"{ns}Resources")
|
|
571
|
+
if resources_el is None:
|
|
572
|
+
return resources, resource_map
|
|
573
|
+
for res_el in resources_el.findall(f"{ns}Resource"):
|
|
574
|
+
uid_str = _child_text(res_el, ns, "UID")
|
|
575
|
+
name = _child_text(res_el, ns, "Name")
|
|
576
|
+
if not uid_str or not name:
|
|
577
|
+
continue
|
|
578
|
+
uid = int(uid_str)
|
|
579
|
+
if uid == 0:
|
|
580
|
+
continue
|
|
581
|
+
max_units_str = _child_text(res_el, ns, "MaxUnits")
|
|
582
|
+
# Finite-guard + clamp (#1720): reject nan/inf/1e999 and cap absurd
|
|
583
|
+
# capacities before the value reaches a bulk_create'd FloatField.
|
|
584
|
+
max_units = (
|
|
585
|
+
_finite_float(max_units_str, 1.0, low=0.0, high=_MAX_UNITS) if max_units_str else 1.0
|
|
586
|
+
)
|
|
587
|
+
rd = ResourceData(uid=uid, name=name, max_units=max_units)
|
|
588
|
+
resource_map[uid] = rd
|
|
589
|
+
resources.append(rd)
|
|
590
|
+
return resources, resource_map
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _parse_assignments(
|
|
594
|
+
root: ET.Element, ns: str, resource_map: dict[int, ResourceData]
|
|
595
|
+
) -> dict[int, list[AssignmentData]]:
|
|
596
|
+
"""Parse ``<Assignments>`` into ``{task_uid: [AssignmentData]}`` (indexed by task UID)."""
|
|
597
|
+
task_assignments: dict[int, list[AssignmentData]] = {}
|
|
598
|
+
assignments_el = root.find(f"{ns}Assignments")
|
|
599
|
+
if assignments_el is None:
|
|
600
|
+
return task_assignments
|
|
601
|
+
for asgn_el in assignments_el.findall(f"{ns}Assignment"):
|
|
602
|
+
task_uid_str = _child_text(asgn_el, ns, "TaskUID")
|
|
603
|
+
res_uid_str = _child_text(asgn_el, ns, "ResourceUID")
|
|
604
|
+
if not task_uid_str or not res_uid_str:
|
|
605
|
+
continue
|
|
606
|
+
task_uid = int(task_uid_str)
|
|
607
|
+
res_uid = int(res_uid_str)
|
|
608
|
+
if res_uid == 0 or res_uid not in resource_map:
|
|
609
|
+
continue
|
|
610
|
+
units_str = _child_text(asgn_el, ns, "Units")
|
|
611
|
+
# Finite-guard + clamp (#1720), same rationale as MaxUnits above.
|
|
612
|
+
units = _finite_float(units_str, 1.0, low=0.0, high=_MAX_UNITS) if units_str else 1.0
|
|
613
|
+
task_assignments.setdefault(task_uid, []).append(
|
|
614
|
+
AssignmentData(task_uid=task_uid, resource_uid=res_uid, units=units)
|
|
615
|
+
)
|
|
616
|
+
return task_assignments
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _parse_predecessor_links(task_el: ET.Element, ns: str) -> list[PredecessorLinkData]:
|
|
620
|
+
"""Parse a task's ``<PredecessorLink>`` children into dependency rows."""
|
|
621
|
+
pred_links: list[PredecessorLinkData] = []
|
|
622
|
+
for pred_el in task_el.findall(f"{ns}PredecessorLink"):
|
|
623
|
+
pred_uid_str = _child_text(pred_el, ns, "PredecessorUID")
|
|
624
|
+
if not pred_uid_str:
|
|
625
|
+
continue
|
|
626
|
+
link_type_str = _child_text(pred_el, ns, "Type") or "1"
|
|
627
|
+
link_lag_str = _child_text(pred_el, ns, "LinkLag") or "0"
|
|
628
|
+
pred_links.append(
|
|
629
|
+
PredecessorLinkData(
|
|
630
|
+
predecessor_uid=int(pred_uid_str),
|
|
631
|
+
dep_type=_LINK_TYPE_MAP.get(link_type_str, "FS"),
|
|
632
|
+
lag_days=_parse_lag_to_days(link_lag_str),
|
|
633
|
+
)
|
|
634
|
+
)
|
|
635
|
+
return pred_links
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _resolve_task_pert_estimates(
|
|
639
|
+
task_el: ET.Element,
|
|
640
|
+
ns: str,
|
|
641
|
+
pert_role_to_field_id: dict[str, str],
|
|
642
|
+
task_name: str,
|
|
643
|
+
*,
|
|
644
|
+
is_milestone: bool,
|
|
645
|
+
warnings: list[str],
|
|
646
|
+
) -> tuple[int | None, int | None, int | None]:
|
|
647
|
+
"""Resolve a task's ``(optimistic, most_likely, pessimistic)`` PERT triple.
|
|
648
|
+
|
|
649
|
+
PERT three-point values come in as per-task ``<ExtendedAttribute>`` children
|
|
650
|
+
matching the FieldIDs detected at project level. Values are ISO-8601
|
|
651
|
+
durations parsed via the same ``_parse_duration_to_days`` helper used for the
|
|
652
|
+
primary Duration field. The triple is dropped (all three set to ``None``)
|
|
653
|
+
when it cannot be safely imported:
|
|
654
|
+
|
|
655
|
+
- Milestones (ADR-0093 Q5): MS Project leaves these empty in practice; if a
|
|
656
|
+
file violates that we still drop — silently, since milestones with
|
|
657
|
+
three-point values are common file noise from PMs who blanket-applied
|
|
658
|
+
estimates and there's nothing the user needs to do.
|
|
659
|
+
- All-or-none (Q3): the scheduler engine requires all three for PERT-Beta
|
|
660
|
+
sampling (engine.py:892), so a partial triple would surface as a
|
|
661
|
+
half-populated UI with no Monte Carlo effect — drop all three and warn.
|
|
662
|
+
- Ordering (#2002): even a complete triple is unusable if it violates
|
|
663
|
+
optimistic <= most_likely <= pessimistic. The engine rejects it
|
|
664
|
+
(engine._validate_project) and, because the importer marks estimates
|
|
665
|
+
accepted, the invalid row would detonate the first CPM/Monte-Carlo run
|
|
666
|
+
after import — drop all three and warn.
|
|
667
|
+
"""
|
|
668
|
+
opt_days, ml_days, pess_days = (
|
|
669
|
+
_extract_pert_task_values(task_el, ns, pert_role_to_field_id)
|
|
670
|
+
if pert_role_to_field_id
|
|
671
|
+
else (None, None, None)
|
|
672
|
+
)
|
|
673
|
+
if is_milestone and (opt_days, ml_days, pess_days) != (None, None, None):
|
|
674
|
+
opt_days = ml_days = pess_days = None
|
|
675
|
+
|
|
676
|
+
present = sum(v is not None for v in (opt_days, ml_days, pess_days))
|
|
677
|
+
if 0 < present < 3:
|
|
678
|
+
missing = []
|
|
679
|
+
if opt_days is None:
|
|
680
|
+
missing.append("Optimistic")
|
|
681
|
+
if ml_days is None:
|
|
682
|
+
missing.append("Most Likely")
|
|
683
|
+
if pess_days is None:
|
|
684
|
+
missing.append("Pessimistic")
|
|
685
|
+
warnings.append(
|
|
686
|
+
f"Task '{task_name}': partial three-point estimate "
|
|
687
|
+
f"(missing {', '.join(missing)}), all three values skipped"
|
|
688
|
+
)
|
|
689
|
+
opt_days = ml_days = pess_days = None
|
|
690
|
+
|
|
691
|
+
if (
|
|
692
|
+
opt_days is not None
|
|
693
|
+
and ml_days is not None
|
|
694
|
+
and pess_days is not None
|
|
695
|
+
and not opt_days <= ml_days <= pess_days
|
|
696
|
+
):
|
|
697
|
+
warnings.append(
|
|
698
|
+
f"Task '{task_name}': three-point estimate out of order "
|
|
699
|
+
f"(optimistic ≤ most likely ≤ pessimistic required; got "
|
|
700
|
+
f"{opt_days} ≤ {ml_days} ≤ {pess_days}), all three values skipped"
|
|
701
|
+
)
|
|
702
|
+
opt_days = ml_days = pess_days = None
|
|
703
|
+
|
|
704
|
+
return opt_days, ml_days, pess_days
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _derive_task_status(start_str: str, raw_percent: float) -> str | None:
|
|
708
|
+
"""Derive task status from the RAW file percent, gated on having a start (#1768).
|
|
709
|
+
|
|
710
|
+
Keyed on the raw 0-100 value so it is independent of how percent is stored
|
|
711
|
+
downstream (the 0-1-vs-0-100 question, #1759). A task with no start has its
|
|
712
|
+
percent clamped to 0 by the importer (ADR-0057 Q5), so it must stay
|
|
713
|
+
NOT_STARTED — return ``None`` (the importer's default) rather than
|
|
714
|
+
IN_PROGRESS, so status and progress can never disagree.
|
|
715
|
+
"""
|
|
716
|
+
from trueppm_api.apps.projects.models import TaskStatus
|
|
717
|
+
|
|
718
|
+
if not start_str:
|
|
719
|
+
return None
|
|
720
|
+
if raw_percent >= 100.0:
|
|
721
|
+
return TaskStatus.COMPLETE.value
|
|
722
|
+
if raw_percent > 0.0:
|
|
723
|
+
return TaskStatus.IN_PROGRESS.value
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _parse_one_task(
|
|
728
|
+
task_el: ET.Element,
|
|
729
|
+
ns: str,
|
|
730
|
+
pert_role_to_field_id: dict[str, str],
|
|
731
|
+
task_assignments: dict[int, list[AssignmentData]],
|
|
732
|
+
warnings: list[str],
|
|
733
|
+
) -> TaskData | None:
|
|
734
|
+
"""Parse a single ``<Task>`` element into ``TaskData``, or ``None`` to skip it.
|
|
735
|
+
|
|
736
|
+
Skips UID-less / UID-0 rows and warns on a missing name — the same guards as
|
|
737
|
+
the original single-pass loop.
|
|
738
|
+
"""
|
|
739
|
+
uid_str = _child_text(task_el, ns, "UID")
|
|
740
|
+
name = _child_text(task_el, ns, "Name")
|
|
741
|
+
if not uid_str:
|
|
742
|
+
return None
|
|
743
|
+
uid = int(uid_str)
|
|
744
|
+
if uid == 0:
|
|
745
|
+
return None
|
|
746
|
+
if not name:
|
|
747
|
+
warnings.append(f"Task UID {uid}: missing name, skipped")
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
outline_level_str = _child_text(task_el, ns, "OutlineLevel")
|
|
751
|
+
pct_str = _child_text(task_el, ns, "PercentComplete")
|
|
752
|
+
start_str = _child_text(task_el, ns, "Start")
|
|
753
|
+
# Summary detection happens later (the importer rebuilds WBS), so we only
|
|
754
|
+
# know milestones here; summary filtering is left to the importer.
|
|
755
|
+
is_milestone = _child_text(task_el, ns, "Milestone") == "1"
|
|
756
|
+
|
|
757
|
+
opt_days, ml_days, pess_days = _resolve_task_pert_estimates(
|
|
758
|
+
task_el,
|
|
759
|
+
ns,
|
|
760
|
+
pert_role_to_field_id,
|
|
761
|
+
name,
|
|
762
|
+
is_milestone=is_milestone,
|
|
763
|
+
warnings=warnings,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# Finite-guard + clamp to [0, 100] (#1720): MS Project PercentComplete is
|
|
767
|
+
# 0-100. nan/inf/1e999 (and any out-of-range figure) is rejected/clamped
|
|
768
|
+
# before it reaches progress + EVM math. Task.percent_complete is the same
|
|
769
|
+
# 0-100 scale, so we keep the value on that scale (an earlier /100 divided it
|
|
770
|
+
# into a 0-1 fraction that the importer then wrote straight into the 0-100
|
|
771
|
+
# field — a 75% task landed as 0.75%). The same value drives status inference.
|
|
772
|
+
raw_percent = _finite_float(pct_str, 0.0, low=0.0, high=100.0) if pct_str else 0.0
|
|
773
|
+
|
|
774
|
+
return TaskData(
|
|
775
|
+
uid=uid,
|
|
776
|
+
name=name,
|
|
777
|
+
duration_days=_parse_duration_to_days(_child_text(task_el, ns, "Duration")),
|
|
778
|
+
outline_number=_child_text(task_el, ns, "OutlineNumber"),
|
|
779
|
+
outline_level=int(outline_level_str) if outline_level_str else 0,
|
|
780
|
+
is_milestone=is_milestone,
|
|
781
|
+
percent_complete=raw_percent,
|
|
782
|
+
status=_derive_task_status(start_str, raw_percent),
|
|
783
|
+
notes=_child_text(task_el, ns, "Notes"),
|
|
784
|
+
start=start_str[:10] if start_str else None,
|
|
785
|
+
calendar_uid=_positive_int_or_none(_child_text(task_el, ns, "CalendarUID")),
|
|
786
|
+
optimistic_duration_days=opt_days,
|
|
787
|
+
most_likely_duration_days=ml_days,
|
|
788
|
+
pessimistic_duration_days=pess_days,
|
|
789
|
+
constraint_type=_int_or_none(_child_text(task_el, ns, "ConstraintType")),
|
|
790
|
+
constraint_date=_iso_date_or_none(_child_text(task_el, ns, "ConstraintDate")),
|
|
791
|
+
actual_start=_iso_date_or_none(_child_text(task_el, ns, "ActualStart")),
|
|
792
|
+
actual_finish=_iso_date_or_none(_child_text(task_el, ns, "ActualFinish")),
|
|
793
|
+
predecessor_links=_parse_predecessor_links(task_el, ns),
|
|
794
|
+
resource_assignments=task_assignments.get(uid, []),
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _warn_unmapped_task_fields(tasks_el: ET.Element, ns: str, warnings: list[str]) -> None:
|
|
799
|
+
"""Report every field family the import does **not** carry over, with counts.
|
|
800
|
+
|
|
801
|
+
This is the whole of #2891's "minimum for beta": before it, an MSPDI carrying
|
|
802
|
+
constraints, deadlines, baselines, priorities, work and cost imported with
|
|
803
|
+
``ProjectData.warnings == []``. A PM migrating a plan got dates TruePPM had
|
|
804
|
+
recomputed from scratch, believed they were the dates they had committed to,
|
|
805
|
+
and found out at the next governance review.
|
|
806
|
+
|
|
807
|
+
Counts rather than a flat list of names, because "3 of 480 tasks" and "412 of
|
|
808
|
+
480 tasks" are different decisions for the operator: the first is a note, the
|
|
809
|
+
second means the migration needs a plan.
|
|
810
|
+
|
|
811
|
+
Scans the raw XML rather than the parsed ``TaskData`` deliberately — most of
|
|
812
|
+
these families have no dataclass field to inspect, and adding one per family
|
|
813
|
+
purely to count it would imply the importer does something with it.
|
|
814
|
+
"""
|
|
815
|
+
task_elements = tasks_el.findall(f"{ns}Task")
|
|
816
|
+
total = len(task_elements)
|
|
817
|
+
if not total:
|
|
818
|
+
return
|
|
819
|
+
|
|
820
|
+
for element_name, phrase, is_meaningful in _UNMAPPED_TASK_FIELDS:
|
|
821
|
+
count = sum(
|
|
822
|
+
1
|
|
823
|
+
for task_el in task_elements
|
|
824
|
+
if _carries_value(task_el, ns, element_name, is_meaningful)
|
|
825
|
+
)
|
|
826
|
+
if count:
|
|
827
|
+
warnings.append(f"Not imported: {phrase} — set on {count} of {total} tasks.")
|
|
828
|
+
|
|
829
|
+
_warn_unmapped_constraints(task_elements, ns, total, warnings)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _carries_value(
|
|
833
|
+
task_el: ET.Element, ns: str, element_name: str, is_meaningful: Callable[[str], bool]
|
|
834
|
+
) -> bool:
|
|
835
|
+
"""True when a task element carries a value worth reporting as dropped.
|
|
836
|
+
|
|
837
|
+
``<Baseline>`` is a container (its data lives in child ``<Start>`` /
|
|
838
|
+
``<Finish>`` elements, not in its own text), so presence-with-children counts
|
|
839
|
+
as a value; every other family is judged on its text by its own predicate.
|
|
840
|
+
"""
|
|
841
|
+
child = task_el.find(f"{ns}{element_name}")
|
|
842
|
+
if child is None:
|
|
843
|
+
return False
|
|
844
|
+
if len(child):
|
|
845
|
+
return True
|
|
846
|
+
return is_meaningful(child.text or "")
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _warn_unmapped_constraints(
|
|
850
|
+
task_elements: list[ET.Element], ns: str, total: int, warnings: list[str]
|
|
851
|
+
) -> None:
|
|
852
|
+
"""Report constraint codes TruePPM cannot express, grouped by MS Project name.
|
|
853
|
+
|
|
854
|
+
Split out from the table-driven families above because a constraint is not
|
|
855
|
+
all-or-nothing: two of the eight codes land on ``Task.planned_start`` and the
|
|
856
|
+
rest have no home, so the warning has to name *which* ones were lost. A
|
|
857
|
+
single "constraints were dropped" line would be wrong for the file whose
|
|
858
|
+
constraints were all SNET and carried across perfectly.
|
|
859
|
+
"""
|
|
860
|
+
unmapped: dict[int, int] = {}
|
|
861
|
+
partially_applied = 0
|
|
862
|
+
# Codes outside MSPDI's defined 0-7 are collapsed into ONE bucket rather than
|
|
863
|
+
# getting a line each. Two reasons, both structural: the code is read straight
|
|
864
|
+
# off the file, so a per-code line makes the *number of warnings* attacker-
|
|
865
|
+
# chosen (20,000 tasks each declaring a different code = 20,000 lines, ~3.4 MB
|
|
866
|
+
# in one `TaskRun.result_summary` JSONField), and this list is now served over
|
|
867
|
+
# the API rather than being write-only — so its length needs a bound at the
|
|
868
|
+
# point of writing, not at the point of reading. The same reasoning already
|
|
869
|
+
# applies per-string to the PERT `<Alias>` warning below, which truncates
|
|
870
|
+
# reflected text to 100 chars; length is the axis that became reachable.
|
|
871
|
+
unrecognized_tasks = 0
|
|
872
|
+
unrecognized_codes: set[int] = set()
|
|
873
|
+
for task_el in task_elements:
|
|
874
|
+
code = _int_or_none(_child_text(task_el, ns, "ConstraintType"))
|
|
875
|
+
if code is None:
|
|
876
|
+
continue
|
|
877
|
+
if code == _CONSTRAINT_TYPE_PARTIALLY_APPLIED:
|
|
878
|
+
partially_applied += 1
|
|
879
|
+
elif code in MSPDI_CONSTRAINT_NAMES:
|
|
880
|
+
if code not in _CONSTRAINT_TYPES_NOT_A_LOSS:
|
|
881
|
+
unmapped[code] = unmapped.get(code, 0) + 1
|
|
882
|
+
else:
|
|
883
|
+
unrecognized_tasks += 1
|
|
884
|
+
unrecognized_codes.add(code)
|
|
885
|
+
|
|
886
|
+
# Bounded by construction: at most one line per *defined* MSPDI code.
|
|
887
|
+
for code in sorted(unmapped):
|
|
888
|
+
warnings.append(
|
|
889
|
+
f"Not imported: '{MSPDI_CONSTRAINT_NAMES[code]}' constraints — set on "
|
|
890
|
+
f"{unmapped[code]} of {total} tasks. TruePPM models only "
|
|
891
|
+
"start-no-earlier-than, so these tasks are scheduled from their "
|
|
892
|
+
"dependencies alone."
|
|
893
|
+
)
|
|
894
|
+
if unrecognized_tasks:
|
|
895
|
+
# Deliberately reports how *many* distinct codes, never the codes
|
|
896
|
+
# themselves: a `<ConstraintType>` integer is unbounded up to Python's
|
|
897
|
+
# 4,300-digit int limit, so echoing it hands the file control of this
|
|
898
|
+
# string's length.
|
|
899
|
+
warnings.append(
|
|
900
|
+
f"Not imported: {len(unrecognized_codes)} constraint type(s) this file "
|
|
901
|
+
f"declares that are not defined by MS Project — found on "
|
|
902
|
+
f"{unrecognized_tasks} of {total} tasks. Those tasks are scheduled from "
|
|
903
|
+
"their dependencies alone."
|
|
904
|
+
)
|
|
905
|
+
if partially_applied:
|
|
906
|
+
name = MSPDI_CONSTRAINT_NAMES[_CONSTRAINT_TYPE_PARTIALLY_APPLIED]
|
|
907
|
+
warnings.append(
|
|
908
|
+
f"Partially imported: '{name}' constraints on {partially_applied} of "
|
|
909
|
+
f"{total} tasks became start-no-earlier-than dates. The start is "
|
|
910
|
+
"pinned; TruePPM cannot also stop the task starting later."
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _parse_tasks(
|
|
915
|
+
root: ET.Element,
|
|
916
|
+
ns: str,
|
|
917
|
+
pert_role_to_field_id: dict[str, str],
|
|
918
|
+
task_assignments: dict[int, list[AssignmentData]],
|
|
919
|
+
warnings: list[str],
|
|
920
|
+
) -> list[TaskData]:
|
|
921
|
+
"""Parse ``<Tasks>`` into ``TaskData`` rows (per-task skips/warnings applied)."""
|
|
922
|
+
tasks: list[TaskData] = []
|
|
923
|
+
tasks_el = root.find(f"{ns}Tasks")
|
|
924
|
+
if tasks_el is None:
|
|
925
|
+
return tasks
|
|
926
|
+
# Before building anything: account for what this file carries that the
|
|
927
|
+
# import will not (#2891). Runs first so the "what was dropped" lines lead
|
|
928
|
+
# the summary rather than trailing the per-task notes.
|
|
929
|
+
_warn_unmapped_task_fields(tasks_el, ns, warnings)
|
|
930
|
+
for task_el in tasks_el.findall(f"{ns}Task"):
|
|
931
|
+
td = _parse_one_task(task_el, ns, pert_role_to_field_id, task_assignments, warnings)
|
|
932
|
+
if td is not None:
|
|
933
|
+
tasks.append(td)
|
|
934
|
+
_reconcile_actuals(tasks, warnings)
|
|
935
|
+
return tasks
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _reconcile_actuals(tasks: list[TaskData], warnings: list[str]) -> None:
|
|
939
|
+
"""Drop an actual date the file's own progress contradicts, and say how many.
|
|
940
|
+
|
|
941
|
+
On the REST path an actual date only ever appears as a *consequence* of a
|
|
942
|
+
status transition — ``TaskSerializer`` sets ``actual_start`` on the move to
|
|
943
|
+
IN_PROGRESS and ``actual_finish`` on COMPLETE, and clears the latter on
|
|
944
|
+
reopen. The importer writes through ``bulk_create``, which runs no validation
|
|
945
|
+
and no model ``clean()``, so importing an actual date straight from the file
|
|
946
|
+
creates states the API itself cannot reach: an ``<ActualFinish>`` on a task
|
|
947
|
+
the same file declares 0% complete lands as a finished-but-not-started task.
|
|
948
|
+
|
|
949
|
+
That is not a cosmetic inconsistency. ``actual_finish`` is read
|
|
950
|
+
unconditionally downstream — schedule variance, and the sprint forecast
|
|
951
|
+
resolver, which returns it as the authoritative date the moment it is
|
|
952
|
+
non-null — so an unreconciled actual silently corrupts variance and forecast
|
|
953
|
+
reporting rather than looking wrong on the task.
|
|
954
|
+
|
|
955
|
+
So the file's *progress* stays authoritative and the contradicting actual is
|
|
956
|
+
dropped, matching this module's standing contract everywhere else: keep the
|
|
957
|
+
row, drop the offending field, and report it. Reported as two aggregate lines
|
|
958
|
+
rather than one per task, so a 20,000-row file cannot turn this into 20,000
|
|
959
|
+
warnings.
|
|
960
|
+
"""
|
|
961
|
+
# Imported lazily, exactly as `_derive_task_status` does and for the same
|
|
962
|
+
# reason: this module stays ORM-free at import time. Reading the enum rather
|
|
963
|
+
# than hardcoding its two values also means the pair cannot silently drift out
|
|
964
|
+
# of lockstep with the model.
|
|
965
|
+
from trueppm_api.apps.projects.models import TaskStatus
|
|
966
|
+
|
|
967
|
+
terminal_statuses = {TaskStatus.REVIEW.value, TaskStatus.COMPLETE.value}
|
|
968
|
+
|
|
969
|
+
dropped_starts = 0
|
|
970
|
+
dropped_finishes = 0
|
|
971
|
+
for td in tasks:
|
|
972
|
+
# A start with no progress at all is the contradiction; any progress, or
|
|
973
|
+
# an explicitly terminal status from the source, makes it coherent.
|
|
974
|
+
terminal = td.status in terminal_statuses
|
|
975
|
+
if td.actual_start is not None and td.percent_complete <= 0.0 and not terminal:
|
|
976
|
+
td.actual_start = None
|
|
977
|
+
dropped_starts += 1
|
|
978
|
+
# A finish only makes sense on work the file says is done.
|
|
979
|
+
if td.actual_finish is not None and td.percent_complete < 100.0 and not terminal:
|
|
980
|
+
td.actual_finish = None
|
|
981
|
+
dropped_finishes += 1
|
|
982
|
+
# And a finish can never precede the start it belongs to.
|
|
983
|
+
if (
|
|
984
|
+
td.actual_start is not None
|
|
985
|
+
and td.actual_finish is not None
|
|
986
|
+
and td.actual_finish < td.actual_start
|
|
987
|
+
):
|
|
988
|
+
td.actual_finish = None
|
|
989
|
+
dropped_finishes += 1
|
|
990
|
+
|
|
991
|
+
if dropped_starts:
|
|
992
|
+
warnings.append(
|
|
993
|
+
f"Not imported: actual start dates on {dropped_starts} task(s) — the file "
|
|
994
|
+
"reports no progress on them, and TruePPM cannot record work as started "
|
|
995
|
+
"on a task that has not started."
|
|
996
|
+
)
|
|
997
|
+
if dropped_finishes:
|
|
998
|
+
warnings.append(
|
|
999
|
+
f"Not imported: actual finish dates on {dropped_finishes} task(s) — the "
|
|
1000
|
+
"file does not report them as complete, or the finish preceded the start."
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def parse_xml(xml_content: bytes) -> ProjectData:
|
|
1005
|
+
"""Parse MS Project XML content into a ProjectData structure.
|
|
1006
|
+
|
|
1007
|
+
Handles both namespaced (Project 2003+) and non-namespaced XML.
|
|
1008
|
+
"""
|
|
1009
|
+
# Parse with defusedxml (#771): it forbids entity expansion and external-
|
|
1010
|
+
# entity resolution by default, defending against billion-laughs / XXE on the
|
|
1011
|
+
# user-uploaded file. The 10 MB upload cap in MsProjectImportView bounds size
|
|
1012
|
+
# but does NOT stop entity expansion — that was the prior, incorrect rationale.
|
|
1013
|
+
root = _safe_fromstring(xml_content)
|
|
1014
|
+
|
|
1015
|
+
# Detect namespace
|
|
1016
|
+
ns = ""
|
|
1017
|
+
if root.tag.startswith("{"):
|
|
1018
|
+
ns = root.tag.split("}")[0] + "}"
|
|
1019
|
+
|
|
1020
|
+
_enforce_row_caps(root, ns)
|
|
1021
|
+
|
|
1022
|
+
project_data = ProjectData(
|
|
1023
|
+
name=_child_text(root, ns, "Name"),
|
|
1024
|
+
start_date=(
|
|
1025
|
+
_child_text(root, ns, "StartDate")[:10] if _child_text(root, ns, "StartDate") else None
|
|
1026
|
+
),
|
|
1027
|
+
calendar_uid=_positive_int_or_none(_child_text(root, ns, "CalendarUID")),
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
# --- Parse calendars (#1769) ---
|
|
1031
|
+
project_data.calendars = _parse_calendars(root, ns, project_data.warnings)
|
|
1032
|
+
|
|
1033
|
+
# --- Parse project-level ExtendedAttribute definitions (#798, ADR-0093) ---
|
|
1034
|
+
# Build a {role: field_id} map (e.g. {"optimistic": "188743783"}) for the
|
|
1035
|
+
# three PERT roles. Files with no ExtendedAttributes block produce an empty
|
|
1036
|
+
# map and three-point import is silently skipped.
|
|
1037
|
+
pert_role_to_field_id = _parse_pert_extended_attribute_defs(root, ns, project_data.warnings)
|
|
1038
|
+
|
|
1039
|
+
# --- Parse resources, assignments, and tasks ---
|
|
1040
|
+
project_data.resources, resource_map = _parse_resources(root, ns)
|
|
1041
|
+
task_assignments = _parse_assignments(root, ns, resource_map)
|
|
1042
|
+
project_data.tasks = _parse_tasks(
|
|
1043
|
+
root, ns, pert_role_to_field_id, task_assignments, project_data.warnings
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
return project_data
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _parse_pert_extended_attribute_defs(
|
|
1050
|
+
root: ET.Element, ns: str, warnings: list[str]
|
|
1051
|
+
) -> dict[str, str]:
|
|
1052
|
+
"""Detect which FieldIDs the file uses for the three PERT roles.
|
|
1053
|
+
|
|
1054
|
+
Walks ``<ExtendedAttributes>/<ExtendedAttribute>`` and returns
|
|
1055
|
+
``{role: field_id}`` for roles (optimistic / most_likely / pessimistic)
|
|
1056
|
+
backed by the canonical Duration1/2/3 FieldIDs. Duration4 is informational
|
|
1057
|
+
(formula slot) and ignored.
|
|
1058
|
+
|
|
1059
|
+
Refuses to bind a role when the FieldID matches but the alias text
|
|
1060
|
+
contradicts (e.g. someone repurposed Duration1 with alias 'Risk Score') —
|
|
1061
|
+
the FieldID is the interchange contract, but a contradicting alias is
|
|
1062
|
+
strong evidence the slot has been reused.
|
|
1063
|
+
"""
|
|
1064
|
+
ea_block = root.find(f"{ns}ExtendedAttributes")
|
|
1065
|
+
if ea_block is None:
|
|
1066
|
+
return {}
|
|
1067
|
+
bound: dict[str, str] = {}
|
|
1068
|
+
for ea in ea_block.findall(f"{ns}ExtendedAttribute"):
|
|
1069
|
+
binding = _bind_pert_role(ea, ns, warnings)
|
|
1070
|
+
if binding is not None:
|
|
1071
|
+
role, field_id = binding
|
|
1072
|
+
bound[role] = field_id
|
|
1073
|
+
return bound
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _bind_pert_role(ea: ET.Element, ns: str, warnings: list[str]) -> tuple[str, str] | None:
|
|
1077
|
+
"""Resolve one ``<ExtendedAttribute>`` to a ``(role, field_id)`` PERT binding.
|
|
1078
|
+
|
|
1079
|
+
Returns ``None`` when the element is not a bindable PERT slot: missing/empty
|
|
1080
|
+
FieldID, a non-PERT or formula (Duration4) FieldID, or a FieldID whose alias
|
|
1081
|
+
text contradicts the slot (evidence the slot has been repurposed — warned).
|
|
1082
|
+
"""
|
|
1083
|
+
fid_el = ea.find(f"{ns}FieldID")
|
|
1084
|
+
if fid_el is None:
|
|
1085
|
+
return None
|
|
1086
|
+
field_id = (fid_el.text or "").strip()
|
|
1087
|
+
if not field_id:
|
|
1088
|
+
return None
|
|
1089
|
+
role = PERT_ROLE_BY_FIELD_ID.get(field_id)
|
|
1090
|
+
if role is None or role == "expected":
|
|
1091
|
+
# Either not a PERT slot, or the formula slot (Duration4) we never import.
|
|
1092
|
+
return None
|
|
1093
|
+
alias_el = ea.find(f"{ns}Alias")
|
|
1094
|
+
alias_raw = alias_el.text if alias_el is not None else None
|
|
1095
|
+
alias_text = (alias_raw or "").strip().lower()
|
|
1096
|
+
if alias_text:
|
|
1097
|
+
expected_tokens = PERT_ALIAS_TOKENS.get(field_id, ())
|
|
1098
|
+
if not any(tok in alias_text for tok in expected_tokens):
|
|
1099
|
+
# Truncate the reflected alias before surfacing it in the import
|
|
1100
|
+
# summary — a multi-megabyte <Alias> would bloat the warnings list
|
|
1101
|
+
# (and any frontend rendering it). 100 chars is enough to diagnose.
|
|
1102
|
+
alias_display = (alias_raw or "")[:100]
|
|
1103
|
+
warnings.append(
|
|
1104
|
+
f"Project ExtendedAttribute FieldID {field_id} has "
|
|
1105
|
+
f"non-standard alias '{alias_display}'; three-point "
|
|
1106
|
+
f"estimate ({role}) skipped"
|
|
1107
|
+
)
|
|
1108
|
+
return None
|
|
1109
|
+
return role, field_id
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _extract_pert_task_values(
|
|
1113
|
+
task_el: ET.Element, ns: str, pert_role_to_field_id: dict[str, str]
|
|
1114
|
+
) -> tuple[int | None, int | None, int | None]:
|
|
1115
|
+
"""Pull a task's Duration1/2/3 ExtendedAttribute values, in working days.
|
|
1116
|
+
|
|
1117
|
+
Returns ``(optimistic, most_likely, pessimistic)`` with ``None`` for any
|
|
1118
|
+
role whose FieldID was either not defined at project level or not present
|
|
1119
|
+
on this task. Uses the same ``_parse_duration_to_days`` helper as the
|
|
1120
|
+
primary Duration field so working-day semantics (8h/day floor) match.
|
|
1121
|
+
"""
|
|
1122
|
+
opt_fid = pert_role_to_field_id.get("optimistic")
|
|
1123
|
+
ml_fid = pert_role_to_field_id.get("most_likely")
|
|
1124
|
+
pess_fid = pert_role_to_field_id.get("pessimistic")
|
|
1125
|
+
values: dict[str, int | None] = {
|
|
1126
|
+
DURATION1_FIELD_ID: None,
|
|
1127
|
+
DURATION2_FIELD_ID: None,
|
|
1128
|
+
DURATION3_FIELD_ID: None,
|
|
1129
|
+
}
|
|
1130
|
+
for ea in task_el.findall(f"{ns}ExtendedAttribute"):
|
|
1131
|
+
fid_el = ea.find(f"{ns}FieldID")
|
|
1132
|
+
val_el = ea.find(f"{ns}Value")
|
|
1133
|
+
if fid_el is None or val_el is None:
|
|
1134
|
+
continue
|
|
1135
|
+
fid = (fid_el.text or "").strip()
|
|
1136
|
+
if fid not in values:
|
|
1137
|
+
continue
|
|
1138
|
+
raw = (val_el.text or "").strip()
|
|
1139
|
+
if not raw:
|
|
1140
|
+
continue
|
|
1141
|
+
values[fid] = _parse_duration_to_days(raw)
|
|
1142
|
+
return (
|
|
1143
|
+
values.get(opt_fid) if opt_fid else None,
|
|
1144
|
+
values.get(ml_fid) if ml_fid else None,
|
|
1145
|
+
values.get(pess_fid) if pess_fid else None,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def parse_mpp(mpp_content: bytes) -> ProjectData:
|
|
1150
|
+
"""Parse MS Project .mpp binary file via MPXJ subprocess.
|
|
1151
|
+
|
|
1152
|
+
Raises:
|
|
1153
|
+
RuntimeError: If MPXJ JAR is not available or conversion fails.
|
|
1154
|
+
"""
|
|
1155
|
+
jar_path = getattr(settings, "MPXJ_JAR_PATH", "/opt/mpxj/mpxj-cli.jar")
|
|
1156
|
+
if not os.path.isfile(jar_path):
|
|
1157
|
+
raise RuntimeError(
|
|
1158
|
+
"MPXJ JAR not found. .mpp import requires Java and the MPXJ CLI JAR. "
|
|
1159
|
+
f"Expected at: {jar_path}. Set MPXJ_JAR_PATH in settings to override."
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
max_output = getattr(settings, "MPXJ_MAX_OUTPUT_MB", 512) * 1024 * 1024
|
|
1163
|
+
max_heap = getattr(settings, "MPXJ_MAX_HEAP_MB", 512)
|
|
1164
|
+
|
|
1165
|
+
with tempfile.NamedTemporaryFile(suffix=".mpp", delete=False) as tmp:
|
|
1166
|
+
tmp.write(mpp_content)
|
|
1167
|
+
tmp_path = tmp.name
|
|
1168
|
+
|
|
1169
|
+
try:
|
|
1170
|
+
# Bounded stdout streaming (#1722). The old capture_output=True buffered
|
|
1171
|
+
# MPXJ's entire stdout with no limit, so a decompression-bomb .mpp — small
|
|
1172
|
+
# enough to pass the 50 MB upload cap — could expand to multi-GB XML and
|
|
1173
|
+
# OOM the worker. We stream stdout in chunks and abort the moment the total
|
|
1174
|
+
# crosses MPXJ_MAX_OUTPUT_MB, killing the JVM. `-Xmx` is a second bound:
|
|
1175
|
+
# even a bomb that balloons MPXJ's in-memory model dies with a JVM OOM
|
|
1176
|
+
# (non-zero exit) rather than driving the host into swap. stderr is small
|
|
1177
|
+
# (error text only) and read after EOF, so it cannot deadlock the pipe.
|
|
1178
|
+
proc = subprocess.Popen(
|
|
1179
|
+
["java", f"-Xmx{max_heap}m", "-jar", jar_path, tmp_path, "-o", "xml"],
|
|
1180
|
+
stdout=subprocess.PIPE,
|
|
1181
|
+
stderr=subprocess.PIPE,
|
|
1182
|
+
)
|
|
1183
|
+
# Watchdog: preserve the original hard timeout even though we no longer use
|
|
1184
|
+
# subprocess.run's timeout= (a blocking read on a hung, silent JVM would
|
|
1185
|
+
# otherwise never return).
|
|
1186
|
+
timed_out = threading.Event()
|
|
1187
|
+
|
|
1188
|
+
def _kill_on_timeout() -> None:
|
|
1189
|
+
timed_out.set()
|
|
1190
|
+
proc.kill()
|
|
1191
|
+
|
|
1192
|
+
timer = threading.Timer(_MPXJ_TIMEOUT, _kill_on_timeout)
|
|
1193
|
+
timer.start()
|
|
1194
|
+
|
|
1195
|
+
chunks: list[bytes] = []
|
|
1196
|
+
total = 0
|
|
1197
|
+
try:
|
|
1198
|
+
assert proc.stdout is not None
|
|
1199
|
+
while True:
|
|
1200
|
+
chunk = proc.stdout.read(65536)
|
|
1201
|
+
if not chunk:
|
|
1202
|
+
break
|
|
1203
|
+
total += len(chunk)
|
|
1204
|
+
if total > max_output:
|
|
1205
|
+
proc.kill()
|
|
1206
|
+
raise RuntimeError(
|
|
1207
|
+
"MPXJ produced more than "
|
|
1208
|
+
f"{max_output // (1024 * 1024)} MB of XML — aborting as a "
|
|
1209
|
+
"likely decompression bomb."
|
|
1210
|
+
)
|
|
1211
|
+
chunks.append(chunk)
|
|
1212
|
+
proc.wait()
|
|
1213
|
+
finally:
|
|
1214
|
+
timer.cancel()
|
|
1215
|
+
|
|
1216
|
+
if timed_out.is_set():
|
|
1217
|
+
raise RuntimeError(f"MPXJ conversion timed out after {_MPXJ_TIMEOUT}s")
|
|
1218
|
+
|
|
1219
|
+
if proc.returncode != 0:
|
|
1220
|
+
stderr_bytes = proc.stderr.read() if proc.stderr is not None else b""
|
|
1221
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
|
1222
|
+
raise RuntimeError(f"MPXJ conversion failed (exit {proc.returncode}): {stderr[:500]}")
|
|
1223
|
+
|
|
1224
|
+
xml_output = b"".join(chunks)
|
|
1225
|
+
if not xml_output:
|
|
1226
|
+
raise RuntimeError("MPXJ produced no output")
|
|
1227
|
+
|
|
1228
|
+
return parse_xml(xml_output)
|
|
1229
|
+
finally:
|
|
1230
|
+
with contextlib.suppress(OSError):
|
|
1231
|
+
os.unlink(tmp_path)
|