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,1095 @@
|
|
|
1
|
+
"""Import parsed MS Project data into TruePPM models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from trueppm_api.apps.msproject.dataclasses import (
|
|
9
|
+
CONSTRAINT_TYPES_APPLIED_AS_SNET,
|
|
10
|
+
CalendarData,
|
|
11
|
+
ProjectData,
|
|
12
|
+
TaskData,
|
|
13
|
+
)
|
|
14
|
+
from trueppm_api.apps.sync.sequence import allocate_for_projects
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from trueppm_api.apps.scheduling.graph_guard import InfeasibleGraphError
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def import_project(
|
|
23
|
+
project_id: str,
|
|
24
|
+
data: ProjectData,
|
|
25
|
+
tracker: Any = None,
|
|
26
|
+
wipe_existing: bool = False,
|
|
27
|
+
*,
|
|
28
|
+
source_kind: str | None = None,
|
|
29
|
+
source_id: Any = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Import parsed MS Project data into a TruePPM project.
|
|
32
|
+
|
|
33
|
+
Creates tasks, dependencies, resources, and assignments using bulk operations
|
|
34
|
+
to bypass django-simple-history (per ADR-0011).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
project_id: UUID string of the target Project.
|
|
38
|
+
data: Parsed ProjectData from the MS Project file.
|
|
39
|
+
tracker: Optional TaskRunTracker for progress reporting.
|
|
40
|
+
wipe_existing: When True (create-from-import, ADR-0092), delete the
|
|
41
|
+
project's existing tasks before bulk-create so an orphan-drain
|
|
42
|
+
re-dispatch converges instead of duplicating. The default (False)
|
|
43
|
+
keeps the import-into-existing-project path additive — and, since
|
|
44
|
+
#3069, also shifts the file's WBS numbering past the rows already in
|
|
45
|
+
the project instead of writing onto them.
|
|
46
|
+
source_kind: Seed provenance stamped on every created row (ADR-0786,
|
|
47
|
+
#2730). Defaults to ``TaskSource.MSPROJECT_IMPORT``; the CSV/Excel
|
|
48
|
+
wizard reuses this whole path (``csvimport/tasks.py``) and passes
|
|
49
|
+
``TaskSource.CSV_IMPORT`` so the two are distinguishable afterwards.
|
|
50
|
+
source_id: Id of the import job that wrote these rows, for the divergence
|
|
51
|
+
digest. ``None`` for a path with no persisted job row.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Summary dict for result_summary. ``task_count`` and ``project_start_date``
|
|
55
|
+
feed the post-import summary the UI shows on the project landing.
|
|
56
|
+
"""
|
|
57
|
+
from django.conf import settings
|
|
58
|
+
|
|
59
|
+
from trueppm_api.apps.projects.models import Task
|
|
60
|
+
|
|
61
|
+
# Chunk every bulk_create (#1721) so a large import is not emitted as one
|
|
62
|
+
# unbounded multi-row INSERT (which pins the whole set in a single statement
|
|
63
|
+
# and can blow PostgreSQL's parameter limit). Paired with the parser row cap.
|
|
64
|
+
batch_size = getattr(settings, "IMPORT_BULK_BATCH_SIZE", 500)
|
|
65
|
+
|
|
66
|
+
def _update(pct: int, msg: str) -> None:
|
|
67
|
+
if tracker is not None:
|
|
68
|
+
tracker.update(pct, msg)
|
|
69
|
+
|
|
70
|
+
_update(5, "Preparing import...")
|
|
71
|
+
summary = _init_summary(data)
|
|
72
|
+
|
|
73
|
+
if wipe_existing:
|
|
74
|
+
# Safe because the project was created empty for this import (ADR-0092);
|
|
75
|
+
# only a partial prior attempt could leave rows. Dependency/TaskResource
|
|
76
|
+
# cascade off Task, so deleting tasks clears the prior attempt.
|
|
77
|
+
Task.objects.filter(project_id=project_id).delete()
|
|
78
|
+
|
|
79
|
+
# --- Step 0: Apply the file's calendar (#1769) ---
|
|
80
|
+
# Runs before the no-tasks early return: the calendar is header-level plan
|
|
81
|
+
# data (like name/start_date) and applies even to a file with no tasks.
|
|
82
|
+
_update(8, "Applying project calendar...")
|
|
83
|
+
_apply_calendar(project_id, data, summary, overwrite=wipe_existing, batch_size=batch_size)
|
|
84
|
+
|
|
85
|
+
if not data.tasks:
|
|
86
|
+
summary["warnings"].append("No tasks found in file")
|
|
87
|
+
return summary
|
|
88
|
+
|
|
89
|
+
# --- Step 1: Create or match resources ---
|
|
90
|
+
_update(10, "Matching resources...")
|
|
91
|
+
resource_uid_to_pk = _import_resources(data, summary, batch_size=batch_size)
|
|
92
|
+
|
|
93
|
+
# --- Step 2: Create tasks ---
|
|
94
|
+
_update(30, f"Creating {len(data.tasks)} tasks...")
|
|
95
|
+
task_objects = _create_tasks(
|
|
96
|
+
project_id,
|
|
97
|
+
data,
|
|
98
|
+
summary,
|
|
99
|
+
batch_size=batch_size,
|
|
100
|
+
source_kind=source_kind,
|
|
101
|
+
source_id=source_id,
|
|
102
|
+
wipe_existing=wipe_existing,
|
|
103
|
+
)
|
|
104
|
+
task_uid_to_pk = {td.uid: str(task_objects[i].pk) for i, td in enumerate(data.tasks)}
|
|
105
|
+
# ADR-0810 (#2756, CSV path only): every row this import wrote, so the caller
|
|
106
|
+
# can record the ⌘Z undo ledger. Harmless for the MS Project path, which has
|
|
107
|
+
# no undo affordance today and simply ignores the key.
|
|
108
|
+
summary["created_task_ids"] = [str(t.pk) for t in task_objects]
|
|
109
|
+
|
|
110
|
+
# --- Step 3: Create dependencies ---
|
|
111
|
+
_update(50, "Creating dependencies...")
|
|
112
|
+
_import_dependencies(
|
|
113
|
+
data, task_uid_to_pk, summary, project_id=project_id, batch_size=batch_size
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# --- Step 4: Create resource assignments ---
|
|
117
|
+
_update(70, "Creating resource assignments...")
|
|
118
|
+
_import_assignments(
|
|
119
|
+
project_id, data, task_uid_to_pk, resource_uid_to_pk, summary, batch_size=batch_size
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# --- Step 5: Attach labels ---
|
|
123
|
+
_update(80, "Attaching labels...")
|
|
124
|
+
_import_labels(project_id, data, task_uid_to_pk, summary, batch_size=batch_size)
|
|
125
|
+
|
|
126
|
+
_update(90, "Import complete, triggering schedule recalculation...")
|
|
127
|
+
return summary
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _init_summary(data: ProjectData) -> dict[str, Any]:
|
|
131
|
+
"""Build the zeroed result-summary dict, seeded from the parsed file."""
|
|
132
|
+
summary: dict[str, Any] = {
|
|
133
|
+
"tasks_created": 0,
|
|
134
|
+
"task_count": 0,
|
|
135
|
+
"dependencies_created": 0,
|
|
136
|
+
"resources_matched": 0,
|
|
137
|
+
"resources_created": 0,
|
|
138
|
+
"assignments_created": 0,
|
|
139
|
+
# Three-point / PERT counts (#798, ADR-0093). The UI uses these to
|
|
140
|
+
# confirm e.g. "3-point estimates imported for 17 of 23 work tasks".
|
|
141
|
+
# ``tasks_with_three_point_estimates`` includes only tasks that
|
|
142
|
+
# actually received all three values; ``tasks_skipped_partial_three_point``
|
|
143
|
+
# counts tasks the parser dropped to None because the file only
|
|
144
|
+
# supplied a subset.
|
|
145
|
+
"tasks_with_three_point_estimates": 0,
|
|
146
|
+
"tasks_skipped_partial_three_point": 0,
|
|
147
|
+
# ADR-1153 actual-date rules (#3709): counts rows where the file's
|
|
148
|
+
# actual_start/actual_finish would have violated ordering, the future
|
|
149
|
+
# bound, the span cap, or the sign-off gate. The offending field is
|
|
150
|
+
# dropped rather than the whole row — see ``_sanitize_actual_dates``.
|
|
151
|
+
"tasks_with_invalid_actuals_dropped": 0,
|
|
152
|
+
# Calendar mapping (#1769). ``calendar_applied`` is True when the file's
|
|
153
|
+
# calendar was wired as Project.calendar (created or matched);
|
|
154
|
+
# ``calendar_created``/``calendar_matched`` say which path produced it.
|
|
155
|
+
"calendar_applied": False,
|
|
156
|
+
"calendar_name": None,
|
|
157
|
+
"calendar_created": False,
|
|
158
|
+
"calendar_matched": False,
|
|
159
|
+
"project_start_date": data.start_date,
|
|
160
|
+
# #873/#867: set True when an imported task predated the project start and
|
|
161
|
+
# the start was pulled back. The import task broadcasts project_updated so
|
|
162
|
+
# live collaborators on an existing project re-fetch the moved boundary.
|
|
163
|
+
"project_start_shifted": False,
|
|
164
|
+
# Label attach counts (#2406, ADR-0400). ``labels_matched`` are catalog
|
|
165
|
+
# entries the file reused; ``labels_created`` are ones it minted.
|
|
166
|
+
"labels_matched": 0,
|
|
167
|
+
"labels_created": 0,
|
|
168
|
+
"label_assignments_created": 0,
|
|
169
|
+
"warnings": list(data.warnings),
|
|
170
|
+
}
|
|
171
|
+
# Count tasks the parser warned about for partial PERT data; the warning
|
|
172
|
+
# text is the canonical marker (the parser dropped values to None before
|
|
173
|
+
# we got here, so we can't tell from the dataclass alone).
|
|
174
|
+
summary["tasks_skipped_partial_three_point"] = sum(
|
|
175
|
+
1 for w in data.warnings if "partial three-point estimate" in w
|
|
176
|
+
)
|
|
177
|
+
return summary
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def broadcast_import_project_record_change(project_id: str, summary: dict[str, Any]) -> None:
|
|
181
|
+
"""Broadcast ``project_updated`` when an import moved project-record state.
|
|
182
|
+
|
|
183
|
+
Two things an import can change live outside the task tree: the project's
|
|
184
|
+
``start_date`` (pulled back when an imported task predates it, #867/#873) and
|
|
185
|
+
``Project.calendar`` (#1769). Neither is invalidated by the recalc's
|
|
186
|
+
``cpm_complete`` or by ``tasks_restructured`` — both of those speak about
|
|
187
|
+
tasks — so without this a collaborator watching the Gantt sees the whole
|
|
188
|
+
schedule shift under them with no signal and no attribution.
|
|
189
|
+
|
|
190
|
+
Deferred with ``transaction.on_commit`` so a rolled-back import never tells
|
|
191
|
+
peers a boundary moved. Callers invoke this *after* the import's ``atomic()``
|
|
192
|
+
block has committed, at which point no ambient transaction remains and the
|
|
193
|
+
callback fires immediately.
|
|
194
|
+
|
|
195
|
+
Shared by all three import wrappers on purpose. MS Project, CSV and Jira all
|
|
196
|
+
run this same ``import_project``, but each had its own task module, and the
|
|
197
|
+
#873 broadcast was added to one of them and never reached the other two —
|
|
198
|
+
CSV and Jira shifted the start silently for three releases (#2610). A fork
|
|
199
|
+
with three call sites and one behavior is a drift waiting to happen; there is
|
|
200
|
+
now one site.
|
|
201
|
+
"""
|
|
202
|
+
if not (summary.get("project_start_shifted") or summary.get("calendar_applied")):
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
from django.db import transaction
|
|
206
|
+
|
|
207
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
208
|
+
|
|
209
|
+
transaction.on_commit(
|
|
210
|
+
lambda: broadcast_board_event(project_id, "project_updated", {"id": project_id})
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _import_labels(
|
|
215
|
+
project_id: str,
|
|
216
|
+
data: ProjectData,
|
|
217
|
+
task_uid_to_pk: dict[int, str],
|
|
218
|
+
summary: dict[str, Any],
|
|
219
|
+
*,
|
|
220
|
+
batch_size: int,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Match-or-create the file's label names in the project catalog and attach them.
|
|
223
|
+
|
|
224
|
+
Matching is **case-insensitive against the project's existing catalog**
|
|
225
|
+
(ADR-0400 scopes ``Label`` to a project), so re-importing a sheet that says
|
|
226
|
+
``Safety`` into a project that already has ``safety`` reuses the existing
|
|
227
|
+
entry instead of minting a near-duplicate the operator then has to merge.
|
|
228
|
+
The existing spelling wins on a match — the catalog is curated, the
|
|
229
|
+
spreadsheet is not.
|
|
230
|
+
|
|
231
|
+
Idempotent across re-import in both directions: labels resolve by name, and
|
|
232
|
+
the ``TaskLabel`` rows use ``ignore_conflicts`` against the
|
|
233
|
+
``unique(task, label)`` constraint, so a second run of the same file creates
|
|
234
|
+
nothing new.
|
|
235
|
+
"""
|
|
236
|
+
from trueppm_api.apps.projects.models import Label, LabelColor, TaskLabel
|
|
237
|
+
|
|
238
|
+
wanted: dict[str, str] = {}
|
|
239
|
+
for td in data.tasks:
|
|
240
|
+
for name in td.labels:
|
|
241
|
+
wanted.setdefault(name.lower(), name)
|
|
242
|
+
if not wanted:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
existing = {
|
|
246
|
+
label.name.lower(): label
|
|
247
|
+
for label in Label.objects.filter(project_id=project_id, is_deleted=False)
|
|
248
|
+
}
|
|
249
|
+
summary["labels_matched"] = sum(1 for key in wanted if key in existing)
|
|
250
|
+
|
|
251
|
+
# Colors cycle the palette from the end of the current catalog, so an import
|
|
252
|
+
# of several labels produces a distinguishable set rather than a wall of the
|
|
253
|
+
# default. Position continues the catalog's order for the same reason.
|
|
254
|
+
palette = [choice[0] for choice in LabelColor.choices]
|
|
255
|
+
start = len(existing)
|
|
256
|
+
new_labels = [
|
|
257
|
+
Label(
|
|
258
|
+
project_id=project_id,
|
|
259
|
+
name=wanted[key],
|
|
260
|
+
color=palette[(start + offset) % len(palette)],
|
|
261
|
+
position=start + offset,
|
|
262
|
+
)
|
|
263
|
+
for offset, key in enumerate(k for k in wanted if k not in existing)
|
|
264
|
+
]
|
|
265
|
+
if new_labels:
|
|
266
|
+
# bulk_create bypasses VersionedModel.save(), so these rows draw no delta
|
|
267
|
+
# cursor of their own and would sit at sync_seq=0 — below every checkpoint,
|
|
268
|
+
# and so never delivered to an offline client. `labels` is its own sync
|
|
269
|
+
# collection, so it needs the same one-value-per-import treatment the task
|
|
270
|
+
# rows get above (ADR-0686). The post-import recalculation cannot stand in
|
|
271
|
+
# for this: it writes through bulk_update and allocates nothing.
|
|
272
|
+
seq = allocate_for_projects([project_id])
|
|
273
|
+
for label in new_labels:
|
|
274
|
+
label.sync_seq = seq
|
|
275
|
+
Label.objects.bulk_create(new_labels, batch_size=batch_size)
|
|
276
|
+
summary["labels_created"] = len(new_labels)
|
|
277
|
+
existing.update({label.name.lower(): label for label in new_labels})
|
|
278
|
+
|
|
279
|
+
assignments = [
|
|
280
|
+
TaskLabel(task_id=task_uid_to_pk[td.uid], label=existing[name.lower()])
|
|
281
|
+
for td in data.tasks
|
|
282
|
+
if td.uid in task_uid_to_pk
|
|
283
|
+
for name in td.labels
|
|
284
|
+
if name.lower() in existing
|
|
285
|
+
]
|
|
286
|
+
if assignments:
|
|
287
|
+
TaskLabel.objects.bulk_create(assignments, batch_size=batch_size, ignore_conflicts=True)
|
|
288
|
+
summary["label_assignments_created"] = len(assignments)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _import_resources(
|
|
292
|
+
data: ProjectData, summary: dict[str, Any], *, batch_size: int
|
|
293
|
+
) -> dict[int, str]:
|
|
294
|
+
"""Match parsed resources to the shared library by name, creating the rest.
|
|
295
|
+
|
|
296
|
+
Returns a map from the file's resource UID to the TruePPM Resource PK.
|
|
297
|
+
"""
|
|
298
|
+
from trueppm_api.apps.resources.models import Resource
|
|
299
|
+
|
|
300
|
+
resource_uid_to_pk: dict[int, str] = {}
|
|
301
|
+
if not data.resources:
|
|
302
|
+
return resource_uid_to_pk
|
|
303
|
+
|
|
304
|
+
existing = {r.name.lower(): r for r in Resource.objects.all()}
|
|
305
|
+
new_resources: list[Resource] = []
|
|
306
|
+
|
|
307
|
+
for rd in data.resources:
|
|
308
|
+
name_lower = rd.name.lower()
|
|
309
|
+
if name_lower in existing:
|
|
310
|
+
resource_uid_to_pk[rd.uid] = str(existing[name_lower].pk)
|
|
311
|
+
summary["resources_matched"] += 1
|
|
312
|
+
else:
|
|
313
|
+
new_resources.append(Resource(name=rd.name, max_units=rd.max_units))
|
|
314
|
+
|
|
315
|
+
if new_resources:
|
|
316
|
+
Resource.objects.bulk_create(new_resources, batch_size=batch_size)
|
|
317
|
+
summary["resources_created"] = len(new_resources)
|
|
318
|
+
_map_created_resource_pks(data, resource_uid_to_pk)
|
|
319
|
+
return resource_uid_to_pk
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _map_created_resource_pks(data: ProjectData, resource_uid_to_pk: dict[int, str]) -> None:
|
|
323
|
+
"""Fill in PKs for the just-created resources by re-reading the library by name.
|
|
324
|
+
|
|
325
|
+
bulk_create does not populate PKs on the passed instances for every backend, so
|
|
326
|
+
the newly created rows are re-fetched and matched to their file UIDs by name.
|
|
327
|
+
"""
|
|
328
|
+
from trueppm_api.apps.resources.models import Resource
|
|
329
|
+
|
|
330
|
+
all_resources = {r.name.lower(): r for r in Resource.objects.all()}
|
|
331
|
+
for rd in data.resources:
|
|
332
|
+
if rd.uid not in resource_uid_to_pk:
|
|
333
|
+
matched = all_resources.get(rd.name.lower())
|
|
334
|
+
if matched:
|
|
335
|
+
resource_uid_to_pk[rd.uid] = str(matched.pk)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _create_tasks(
|
|
339
|
+
project_id: str,
|
|
340
|
+
data: ProjectData,
|
|
341
|
+
summary: dict[str, Any],
|
|
342
|
+
*,
|
|
343
|
+
batch_size: int,
|
|
344
|
+
source_kind: str | None = None,
|
|
345
|
+
source_id: Any = None,
|
|
346
|
+
wipe_existing: bool = False,
|
|
347
|
+
) -> list[Any]:
|
|
348
|
+
"""Bulk-create Task rows for the parsed tasks and return them in file order.
|
|
349
|
+
|
|
350
|
+
Allocates a contiguous block of short_ids, computes WBS paths and summary-task
|
|
351
|
+
membership once, builds each row, pulls the project start back if a task
|
|
352
|
+
predates it, then bulk-creates. Returns the built Task objects so the caller
|
|
353
|
+
can map file UIDs to persisted PKs.
|
|
354
|
+
|
|
355
|
+
``wipe_existing`` decides whether the file's own numbering is authoritative.
|
|
356
|
+
On create-from-import the project was made empty for this file, so its paths
|
|
357
|
+
are written verbatim. On import-into-existing they are shifted past the rows
|
|
358
|
+
already there — see the offset block below.
|
|
359
|
+
"""
|
|
360
|
+
from django.db.models import F
|
|
361
|
+
from django.utils import timezone
|
|
362
|
+
|
|
363
|
+
from trueppm_api.apps.projects.models import Project, Task, TaskSource, bulk_create_tasks
|
|
364
|
+
from trueppm_api.apps.projects.wbs_paths import offset_wbs_path, root_ordinal_offset
|
|
365
|
+
|
|
366
|
+
# Allocate a batch of short_ids: increment object_sequence by len(tasks)
|
|
367
|
+
# in one UPDATE, then assign sequential hex IDs.
|
|
368
|
+
task_count = len(data.tasks)
|
|
369
|
+
Project.objects.filter(pk=project_id).update(object_sequence=F("object_sequence") + task_count)
|
|
370
|
+
end_seq: int = Project.objects.values_list("object_sequence", flat=True).get(pk=project_id)
|
|
371
|
+
start_seq = end_seq - task_count + 1
|
|
372
|
+
# Fetched once for the ADR-1153 actual-date check every row runs below
|
|
373
|
+
# (#3709) — needs project.start_date and .status_date, neither of which the
|
|
374
|
+
# object_sequence query above selects.
|
|
375
|
+
project = Project.objects.only("start_date", "status_date").get(pk=project_id)
|
|
376
|
+
|
|
377
|
+
wbs_paths = _build_wbs_paths(data.tasks)
|
|
378
|
+
if not wipe_existing:
|
|
379
|
+
# The file numbers its tasks from 1, which is only right for a project that
|
|
380
|
+
# started empty. Importing a revised .mpp / CSV / Jira pull into a project
|
|
381
|
+
# that already holds rows would otherwise write straight onto their paths
|
|
382
|
+
# (#3069) — the same defect template adoption had in #3061, and the reason
|
|
383
|
+
# `unique_task_wbs_path_per_project_live` now rejects it at COMMIT rather
|
|
384
|
+
# than letting it silently collapse the next rewrite_level pass.
|
|
385
|
+
#
|
|
386
|
+
# Shifted before summary detection, not after, so both work off one set of
|
|
387
|
+
# paths. The result is identical either way — `offset_wbs_path` moves only
|
|
388
|
+
# the leading segment, so ancestor/descendant relationships are preserved —
|
|
389
|
+
# but reading the same list twice is what keeps that true if either side
|
|
390
|
+
# changes.
|
|
391
|
+
offset = root_ordinal_offset(project_id, document_paths=wbs_paths)
|
|
392
|
+
if offset:
|
|
393
|
+
wbs_paths = [offset_wbs_path(path, offset) or path for path in wbs_paths]
|
|
394
|
+
# Summary-task detection (ADR-0093 Q5): a task is a summary if any later
|
|
395
|
+
# task's wbs_path is strictly descended from this one's. Computed once in
|
|
396
|
+
# O(N) by sweeping the WBS paths and marking each strict ancestor present
|
|
397
|
+
# in the prefix set. Three-point fields are NOT written on summary rows
|
|
398
|
+
# because MS Project never populates them there; round-tripping them
|
|
399
|
+
# would drift on re-export.
|
|
400
|
+
summary_indices = _summary_indices(wbs_paths)
|
|
401
|
+
|
|
402
|
+
task_objects: list[Task] = [
|
|
403
|
+
_build_task_object(
|
|
404
|
+
td, wbs_paths[i], i in summary_indices, project_id, start_seq + i, summary, project
|
|
405
|
+
)
|
|
406
|
+
for i, td in enumerate(data.tasks)
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
_maybe_shift_project_start(project_id, data, summary)
|
|
410
|
+
|
|
411
|
+
# bulk_create bypasses VersionedModel.save(), so nothing draws a delta cursor
|
|
412
|
+
# for these rows — they would sit at sync_seq=0, below every checkpoint, and
|
|
413
|
+
# never reach an offline client. One value for the whole import is right: the
|
|
414
|
+
# rows are created together, and the delta is `> since` (ADR-0686).
|
|
415
|
+
seq = allocate_for_projects([project_id])
|
|
416
|
+
# Seed provenance (ADR-0786, #2730). Stamped on the instances rather than
|
|
417
|
+
# after the fact because bulk_create never reaches Task.save() — which is also
|
|
418
|
+
# why edited_at stays null here: a machine wrote these rows and no person has
|
|
419
|
+
# touched them, which is precisely what makes them sweepable for seven days.
|
|
420
|
+
seeded_at = timezone.now()
|
|
421
|
+
kind = source_kind or TaskSource.MSPROJECT_IMPORT
|
|
422
|
+
for task in task_objects:
|
|
423
|
+
task.sync_seq = seq
|
|
424
|
+
task.source_kind = kind
|
|
425
|
+
task.source_id = source_id
|
|
426
|
+
task.seeded_at = seeded_at
|
|
427
|
+
|
|
428
|
+
# `bulk_create_tasks`, not `Task.objects.bulk_create`: a summary row here has
|
|
429
|
+
# children and must land DECLARED a container, or the Board renders every
|
|
430
|
+
# imported phase as a card (#3030). The summary indices computed above answer
|
|
431
|
+
# the same question for the three-point gate, but the declaration is derived
|
|
432
|
+
# inside the helper so this path and the restructure endpoints cannot drift.
|
|
433
|
+
bulk_create_tasks(task_objects, batch_size=batch_size)
|
|
434
|
+
summary["tasks_created"] = len(task_objects)
|
|
435
|
+
summary["task_count"] = len(task_objects)
|
|
436
|
+
return task_objects
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _build_task_object(
|
|
440
|
+
td: TaskData,
|
|
441
|
+
wbs_path: str,
|
|
442
|
+
is_summary: bool,
|
|
443
|
+
project_id: str,
|
|
444
|
+
short_id_seq: int,
|
|
445
|
+
summary: dict[str, Any],
|
|
446
|
+
project: Any,
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""Construct a single Task row from parsed TaskData, applying the import-time
|
|
449
|
+
invariants (three-point gating, milestone normalization, progress-anchor
|
|
450
|
+
clamp, ADR-1153 actual-date rules) that bulk_create bypasses by skipping the
|
|
451
|
+
TaskSerializer.
|
|
452
|
+
"""
|
|
453
|
+
from trueppm_api.apps.projects.models import DeliveryMode, Task, TaskStatus
|
|
454
|
+
|
|
455
|
+
# All-or-none gate (ADR-0093 Q3): the parser already enforces it, so
|
|
456
|
+
# if any one of the three fields is set we know the other two are too.
|
|
457
|
+
# Skip three-point write on summaries (Q5) and milestones (already
|
|
458
|
+
# nulled by the parser, but kept defensive here).
|
|
459
|
+
if is_summary or td.is_milestone:
|
|
460
|
+
opt = ml = pess = None
|
|
461
|
+
est_status = None
|
|
462
|
+
else:
|
|
463
|
+
opt = td.optimistic_duration_days
|
|
464
|
+
ml = td.most_likely_duration_days
|
|
465
|
+
pess = td.pessimistic_duration_days
|
|
466
|
+
# estimate_status="accepted" on import (ADR-0093 Q4): the uploader
|
|
467
|
+
# holds project-admin permission and the values are PM-authored
|
|
468
|
+
# migration data, not contributor suggestions. Setting "pending"
|
|
469
|
+
# would force re-approval per task under SUGGEST_APPROVE for no
|
|
470
|
+
# governance benefit (the PM chose to import them).
|
|
471
|
+
est_status = "accepted" if ml is not None else None
|
|
472
|
+
if ml is not None:
|
|
473
|
+
summary["tasks_with_three_point_estimates"] += 1
|
|
474
|
+
# #1768: carry the source status onto Task.status so completed / in-flight
|
|
475
|
+
# work does not re-import as NOT_STARTED (which the scheduler treats as
|
|
476
|
+
# unstarted future work, inflating every forecast). Both importers populate
|
|
477
|
+
# td.status upstream — the Jira parser from the issue status name, the MS
|
|
478
|
+
# Project parser from the raw PercentComplete gated on start — so this path
|
|
479
|
+
# is agnostic to the percent storage scale (#1759). Fall back to the model
|
|
480
|
+
# default when the source supplied no usable status.
|
|
481
|
+
task_status = td.status or TaskStatus.NOT_STARTED.value
|
|
482
|
+
# Clamp percent to 0 when no start date — preserves the progress-anchor
|
|
483
|
+
# invariant that bulk_create bypasses (ADR-0057 Q5). A .mpp file can encode
|
|
484
|
+
# PercentComplete > 0 on an *unstarted* task; importing that value would
|
|
485
|
+
# create a task with ghost progress and no schedule anchor. A task whose
|
|
486
|
+
# status is terminal (REVIEW/COMPLETE) is not "unstarted" — its progress is
|
|
487
|
+
# real completion, not ghost — so it keeps its percent even with no start
|
|
488
|
+
# (the Jira path, which carries no dates), otherwise a COMPLETE issue would
|
|
489
|
+
# persist at 0% and read as unstarted future work (#1768).
|
|
490
|
+
_terminal = task_status in (TaskStatus.REVIEW.value, TaskStatus.COMPLETE.value)
|
|
491
|
+
# "Has an anchor" is `_task_anchor(td)`, not `td.start`. Those were the same
|
|
492
|
+
# condition until #2891: `planned_start` came only from `<Start>`, so a truthy
|
|
493
|
+
# `td.start` was exactly "this task has a schedule anchor". Two new sources
|
|
494
|
+
# broke that equivalence — `planned_start` can now come from a
|
|
495
|
+
# `<ConstraintDate>`, and `actual_start` is imported — and an actual start is
|
|
496
|
+
# the *strongest* anchor there is. Reading `td.start` here would force a real
|
|
497
|
+
# in-progress task to 0% whenever the file pinned it by constraint instead of
|
|
498
|
+
# by `<Start>`, which is the inverse of the invariant this clamp exists for.
|
|
499
|
+
effective_percent = td.percent_complete if (_task_anchor(td) or _terminal) else 0
|
|
500
|
+
actual_start, actual_finish = _sanitize_actual_dates(td, task_status, project, summary)
|
|
501
|
+
return Task(
|
|
502
|
+
project_id=project_id,
|
|
503
|
+
name=td.name,
|
|
504
|
+
wbs_path=wbs_path if wbs_path else None,
|
|
505
|
+
# Milestone invariant (#1773): bulk_create bypasses the TaskSerializer
|
|
506
|
+
# coupling, so enforce the canonical milestone state here — a milestone
|
|
507
|
+
# is zero-duration and carries delivery_mode='milestone' so the phase
|
|
508
|
+
# rollup weights it at 0. A source .mpp can encode a non-zero duration
|
|
509
|
+
# on a <Milestone> task; importing it raw would violate the invariant
|
|
510
|
+
# and the next PATCH would silently re-zero the duration and shift dates.
|
|
511
|
+
duration=0 if td.is_milestone else td.duration_days,
|
|
512
|
+
is_milestone=td.is_milestone,
|
|
513
|
+
delivery_mode=DeliveryMode.MILESTONE if td.is_milestone else DeliveryMode.WATERFALL,
|
|
514
|
+
status=task_status,
|
|
515
|
+
percent_complete=effective_percent,
|
|
516
|
+
notes=td.notes,
|
|
517
|
+
planned_start=_resolve_planned_start(td),
|
|
518
|
+
actual_start=actual_start,
|
|
519
|
+
actual_finish=actual_finish,
|
|
520
|
+
optimistic_duration=opt,
|
|
521
|
+
most_likely_duration=ml,
|
|
522
|
+
pessimistic_duration=pess,
|
|
523
|
+
estimate_status=est_status,
|
|
524
|
+
short_id=f"{short_id_seq:08X}",
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _sanitize_actual_dates(
|
|
529
|
+
td: TaskData, task_status: str, project: Any, summary: dict[str, Any]
|
|
530
|
+
) -> tuple[Any, Any]:
|
|
531
|
+
"""Enforce ADR-1153 on an imported row instead of persisting whatever the file said (#3709).
|
|
532
|
+
|
|
533
|
+
This is untrusted input: an uploaded .mpp can encode an ``ActualFinish``
|
|
534
|
+
before its ``ActualStart``, or an actual date far outside any representable
|
|
535
|
+
span. ``bulk_create_tasks`` bypasses ``TaskSerializer`` entirely (per
|
|
536
|
+
ADR-0011, to skip django-simple-history on a bulk insert), so nothing else
|
|
537
|
+
on this path enforces the rule — the row would otherwise persist and detonate
|
|
538
|
+
the *project's* recompute at compute time (``models.py``'s
|
|
539
|
+
``MAX_PROJECT_SPAN_DAYS`` comment: "then every recalculate_schedule throws"),
|
|
540
|
+
not just fail its own import.
|
|
541
|
+
|
|
542
|
+
Mirrors the importer's existing partial-three-point precedent
|
|
543
|
+
(``tasks_skipped_partial_three_point``): drop only the offending field and
|
|
544
|
+
keep the row, rather than rejecting the whole task. A row disappearing from
|
|
545
|
+
the imported plan because one date was unparseable would be worse than
|
|
546
|
+
importing it with that one field left unset — the same "half-populated is
|
|
547
|
+
valid by design" policy ADR-0136/ADR-1153 already apply to a PM's own PATCH.
|
|
548
|
+
|
|
549
|
+
At most two passes: each pass clears exactly one of the two fields, and
|
|
550
|
+
:func:`check_actual_dates` returns only the first violation it finds, so a
|
|
551
|
+
file with both fields independently bad (e.g. two out-of-span dates) needs a
|
|
552
|
+
second pass to catch the one the first pass didn't reach.
|
|
553
|
+
|
|
554
|
+
``td.actual_start``/``td.actual_finish`` are already ``None`` or a
|
|
555
|
+
parser-validated ``YYYY-MM-DD`` string by the time they reach here
|
|
556
|
+
(``parser._iso_date_or_none`` filters MS Project's "NA"/malformed sentinels
|
|
557
|
+
before this function ever runs), so ``fromisoformat`` cannot raise on the
|
|
558
|
+
live MSP path today. The ``try/except`` is defense-in-depth against a future
|
|
559
|
+
``TaskData`` producer that populates these fields without that guarantee —
|
|
560
|
+
the CSV and Jira adapters currently leave them ``None`` — so an unparseable
|
|
561
|
+
value is dropped exactly like any other invalid actual, not a crash that
|
|
562
|
+
takes down the whole import (security-review, #3709).
|
|
563
|
+
"""
|
|
564
|
+
from datetime import date as _date
|
|
565
|
+
|
|
566
|
+
from trueppm_api.apps.projects.actual_date_rules import check_actual_dates
|
|
567
|
+
|
|
568
|
+
def _parse(raw: str | None) -> Any:
|
|
569
|
+
if not raw:
|
|
570
|
+
return None
|
|
571
|
+
try:
|
|
572
|
+
return _date.fromisoformat(raw)
|
|
573
|
+
except ValueError:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
start = _parse(td.actual_start)
|
|
577
|
+
finish = _parse(td.actual_finish)
|
|
578
|
+
for _ in range(2):
|
|
579
|
+
violation = check_actual_dates(
|
|
580
|
+
actual_start=start, actual_finish=finish, status=task_status, project=project
|
|
581
|
+
)
|
|
582
|
+
if violation is None:
|
|
583
|
+
break
|
|
584
|
+
summary["tasks_with_invalid_actuals_dropped"] += 1
|
|
585
|
+
summary["warnings"].append(
|
|
586
|
+
f"{td.name!r}: dropped {violation.field.replace('_', ' ')} — {violation.message}"
|
|
587
|
+
)
|
|
588
|
+
if violation.field == "actual_start":
|
|
589
|
+
start = None
|
|
590
|
+
else:
|
|
591
|
+
finish = None
|
|
592
|
+
return start, finish
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _resolve_planned_start(td: TaskData) -> str | None:
|
|
596
|
+
"""Choose the SNET floor for an imported task: its constraint date, else Start.
|
|
597
|
+
|
|
598
|
+
``Task.planned_start`` is documented as *start no earlier than*, applied by
|
|
599
|
+
the CPM forward pass — which is exactly what an MSPDI
|
|
600
|
+
``<ConstraintType>4</ConstraintType>`` plus ``<ConstraintDate>`` means. So
|
|
601
|
+
when the file carries one of the codes TruePPM can express, the **constraint
|
|
602
|
+
date wins over ``<Start>``** (#2891).
|
|
603
|
+
|
|
604
|
+
That precedence is the point of the fix rather than a detail. ``<Start>`` is
|
|
605
|
+
MS Project's *computed* schedule date; the constraint date is the commitment
|
|
606
|
+
the PM made. Reading only ``<Start>`` meant a task whose commitment differed
|
|
607
|
+
from its current computed start silently lost the commitment, and TruePPM then
|
|
608
|
+
rescheduled from CPM alone — producing dates the PM could not defend at the
|
|
609
|
+
next governance review.
|
|
610
|
+
|
|
611
|
+
In the overwhelmingly common case the two agree (MS Project sets SNET
|
|
612
|
+
automatically when a start is typed in), so this changes nothing; it matters
|
|
613
|
+
precisely when they diverge, which is when the commitment is load-bearing.
|
|
614
|
+
"""
|
|
615
|
+
if td.constraint_type in CONSTRAINT_TYPES_APPLIED_AS_SNET and td.constraint_date is not None:
|
|
616
|
+
return td.constraint_date
|
|
617
|
+
return td.start or None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _task_anchor(td: TaskData) -> str | None:
|
|
621
|
+
"""The earliest date that anchors this task in the schedule, or ``None``.
|
|
622
|
+
|
|
623
|
+
``_resolve_planned_start`` is the CPM floor; an ``actual_start`` is a *harder*
|
|
624
|
+
anchor than any planned date, and it can predate the floor on a task that
|
|
625
|
+
started early. Every "does this task sit somewhere on the calendar?" question
|
|
626
|
+
goes through here so the answer cannot drift between call sites again — which
|
|
627
|
+
is exactly what happened when `planned_start` gained a second source and two
|
|
628
|
+
of the three `td.start` reads were left behind.
|
|
629
|
+
"""
|
|
630
|
+
candidates = [d for d in (_resolve_planned_start(td), td.actual_start) if d]
|
|
631
|
+
return min(candidates) if candidates else None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _maybe_shift_project_start(project_id: str, data: ProjectData, summary: dict[str, Any]) -> None:
|
|
635
|
+
"""Pull the project start back to the earliest imported task anchor (#873/#867).
|
|
636
|
+
|
|
637
|
+
The importer bulk_creates tasks, bypassing the TaskSerializer auto-shift, so a
|
|
638
|
+
.mpp whose tasks predate the project start would otherwise persist sub-start
|
|
639
|
+
"ghost" planned_starts the CPM clamps. Mirrors the interactive path: the
|
|
640
|
+
project boundary is elastic earlier. Dates are ISO strings (which sort
|
|
641
|
+
chronologically), parsed to a date for the comparison/assignment.
|
|
642
|
+
|
|
643
|
+
Reads ``_task_anchor``, **not** ``td.start`` (#2891). Reading ``<Start>`` here
|
|
644
|
+
reopened the very bug this branch fixes: MS Project keeps a ``<ConstraintDate>``
|
|
645
|
+
that predates the project start while computing ``<Start>`` *at* the project
|
|
646
|
+
start, so `ConstraintDate < StartDate <= Start` is the canonical shape. Keyed
|
|
647
|
+
on ``<Start>``, no shift fires, the constraint-derived ``planned_start`` lands
|
|
648
|
+
below the project floor, and ``early_start = max(project_start, planned_start,
|
|
649
|
+
…)`` clamps it away — the commitment dropped silently a second time, one
|
|
650
|
+
function downstream of the fix. ``actual_start`` is included for the same
|
|
651
|
+
reason: work that demonstrably began before the project start still has to fit
|
|
652
|
+
inside the project's window.
|
|
653
|
+
"""
|
|
654
|
+
from datetime import date as _date
|
|
655
|
+
|
|
656
|
+
from trueppm_api.apps.projects.models import Project
|
|
657
|
+
from trueppm_api.apps.projects.services import shift_project_start_if_needed
|
|
658
|
+
|
|
659
|
+
task_start_strs = [anchor for td in data.tasks if (anchor := _task_anchor(td))]
|
|
660
|
+
if not task_start_strs:
|
|
661
|
+
return
|
|
662
|
+
earliest_start = _date.fromisoformat(min(task_start_strs))
|
|
663
|
+
project = Project.objects.filter(pk=project_id).first()
|
|
664
|
+
if project is not None and shift_project_start_if_needed(project, earliest_start):
|
|
665
|
+
summary["project_start_date"] = project.start_date.isoformat()
|
|
666
|
+
summary["project_start_shifted"] = True
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def collect_dependency_edges(data: ProjectData) -> list[tuple[str, str]]:
|
|
670
|
+
"""Return every predecessor link in the file's own uid space.
|
|
671
|
+
|
|
672
|
+
Exists so a caller can run the ADR-0259 graph guard *before* any row is
|
|
673
|
+
written. Cycle and self-reference structure is invariant under relabeling,
|
|
674
|
+
so validating uids rather than the PKs they become is what lets a cyclic
|
|
675
|
+
file be rejected while the project is still empty — ``bulk_create`` bypasses
|
|
676
|
+
``DependencySerializer._check_no_cycle``, so there is no later gate.
|
|
677
|
+
|
|
678
|
+
Links whose predecessor uid is absent from the file are included: a dangling
|
|
679
|
+
uid can only ever be a source node (successors are always file tasks), so it
|
|
680
|
+
cannot manufacture a cycle, and keeping them makes this edge set identical
|
|
681
|
+
in shape to the one the CSV importer validates.
|
|
682
|
+
"""
|
|
683
|
+
return [
|
|
684
|
+
(str(link.predecessor_uid), str(td.uid))
|
|
685
|
+
for td in data.tasks
|
|
686
|
+
for link in td.predecessor_links
|
|
687
|
+
]
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def describe_bad_graph(data: ProjectData, exc: InfeasibleGraphError) -> str:
|
|
691
|
+
"""Name the offending tasks of a rejected graph in the uploader's terms.
|
|
692
|
+
|
|
693
|
+
Built from ``exc.reason``/``exc.offending`` rather than ``str(exc)``, whose
|
|
694
|
+
message is a domain signal carrying an internal reason code and a list repr
|
|
695
|
+
in uid space ("Infeasible task graph (cyclic_dependency): ['2', '5', '2']").
|
|
696
|
+
The person who uploaded the file knows task names, not uids, and needs the
|
|
697
|
+
two reasons distinguished — a self-referencing task is a different edit from
|
|
698
|
+
a multi-task loop.
|
|
699
|
+
"""
|
|
700
|
+
names = {str(td.uid): td.name for td in data.tasks}
|
|
701
|
+
labelled = [f"{names.get(uid, 'unknown task')} (uid {uid})" for uid in exc.offending]
|
|
702
|
+
if exc.reason == "self_reference":
|
|
703
|
+
return f"Task {labelled[0]} lists itself as its own predecessor."
|
|
704
|
+
return "Circular dependency: " + " -> ".join(labelled)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _import_dependencies(
|
|
708
|
+
data: ProjectData,
|
|
709
|
+
task_uid_to_pk: dict[int, str],
|
|
710
|
+
summary: dict[str, Any],
|
|
711
|
+
*,
|
|
712
|
+
project_id: str,
|
|
713
|
+
batch_size: int,
|
|
714
|
+
) -> None:
|
|
715
|
+
"""Create Dependency rows from parsed predecessor links.
|
|
716
|
+
|
|
717
|
+
Warns (one entry per link) on any predecessor UID absent from the import.
|
|
718
|
+
"""
|
|
719
|
+
from trueppm_api.apps.projects.models import Dependency
|
|
720
|
+
|
|
721
|
+
dep_objects: list[Dependency] = []
|
|
722
|
+
for td in data.tasks:
|
|
723
|
+
successor_pk = task_uid_to_pk.get(td.uid)
|
|
724
|
+
if not successor_pk:
|
|
725
|
+
continue
|
|
726
|
+
for pl in td.predecessor_links:
|
|
727
|
+
predecessor_pk = task_uid_to_pk.get(pl.predecessor_uid)
|
|
728
|
+
if not predecessor_pk:
|
|
729
|
+
summary["warnings"].append(
|
|
730
|
+
f"Task '{td.name}': predecessor UID {pl.predecessor_uid} "
|
|
731
|
+
f"not found, dependency skipped"
|
|
732
|
+
)
|
|
733
|
+
continue
|
|
734
|
+
dep_objects.append(
|
|
735
|
+
Dependency(
|
|
736
|
+
predecessor_id=predecessor_pk,
|
|
737
|
+
successor_id=successor_pk,
|
|
738
|
+
dep_type=pl.dep_type,
|
|
739
|
+
lag=pl.lag_days,
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
if dep_objects:
|
|
744
|
+
# `dependencies` is its own delta collection filtered on the row's own
|
|
745
|
+
# cursor, and bulk_create bypasses save() — so without this the imported
|
|
746
|
+
# graph would sit at sync_seq=0 and never reach an offline client, leaving
|
|
747
|
+
# it a task list with no edges to recompute from (ADR-0686).
|
|
748
|
+
seq = allocate_for_projects([project_id])
|
|
749
|
+
for dep in dep_objects:
|
|
750
|
+
dep.sync_seq = seq
|
|
751
|
+
Dependency.objects.bulk_create(dep_objects, ignore_conflicts=True, batch_size=batch_size)
|
|
752
|
+
summary["dependencies_created"] = len(dep_objects)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _import_assignments(
|
|
756
|
+
project_id: str,
|
|
757
|
+
data: ProjectData,
|
|
758
|
+
task_uid_to_pk: dict[int, str],
|
|
759
|
+
resource_uid_to_pk: dict[int, str],
|
|
760
|
+
summary: dict[str, Any],
|
|
761
|
+
*,
|
|
762
|
+
batch_size: int,
|
|
763
|
+
) -> None:
|
|
764
|
+
"""Create TaskResource assignments, then auto-roster each assigned resource."""
|
|
765
|
+
from trueppm_api.apps.resources.models import TaskResource
|
|
766
|
+
|
|
767
|
+
assignment_objects: list[TaskResource] = []
|
|
768
|
+
for td in data.tasks:
|
|
769
|
+
task_pk = task_uid_to_pk.get(td.uid)
|
|
770
|
+
if not task_pk:
|
|
771
|
+
continue
|
|
772
|
+
for ad in td.resource_assignments:
|
|
773
|
+
resource_pk = resource_uid_to_pk.get(ad.resource_uid)
|
|
774
|
+
if not resource_pk:
|
|
775
|
+
continue
|
|
776
|
+
assignment_objects.append(
|
|
777
|
+
TaskResource(task_id=task_pk, resource_id=resource_pk, units=ad.units)
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if not assignment_objects:
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
TaskResource.objects.bulk_create(
|
|
784
|
+
assignment_objects, ignore_conflicts=True, batch_size=batch_size
|
|
785
|
+
)
|
|
786
|
+
summary["assignments_created"] = len(assignment_objects)
|
|
787
|
+
_roster_assigned_resources(project_id, assignment_objects, batch_size=batch_size)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _roster_assigned_resources(
|
|
791
|
+
project_id: str, assignment_objects: list[Any], *, batch_size: int
|
|
792
|
+
) -> None:
|
|
793
|
+
"""Roster every assigned resource onto the project so they appear in Team →
|
|
794
|
+
Roster / Heatmap / Allocation views after import (#241).
|
|
795
|
+
|
|
796
|
+
Bulk-builds the rows because bulk_create skipped the per-row signal path used
|
|
797
|
+
by the API ViewSet.
|
|
798
|
+
|
|
799
|
+
``server_version=1`` is set explicitly because ``bulk_create`` does not call
|
|
800
|
+
``Model.save()``, and ``ProjectResource`` is a ``VersionedModel`` whose INSERT path
|
|
801
|
+
is the only thing that sets that counter (#3575). A row left at the field default of
|
|
802
|
+
0 publishes an optimistic-lock token no client can have echoed back.
|
|
803
|
+
"""
|
|
804
|
+
from trueppm_api.apps.resources.models import ProjectResource
|
|
805
|
+
|
|
806
|
+
rostered_resource_pks = {a.resource_id for a in assignment_objects}
|
|
807
|
+
existing_pairs = set(
|
|
808
|
+
ProjectResource.objects.filter(
|
|
809
|
+
project_id=project_id,
|
|
810
|
+
resource_id__in=rostered_resource_pks,
|
|
811
|
+
).values_list("resource_id", flat=True)
|
|
812
|
+
)
|
|
813
|
+
new_roster = [
|
|
814
|
+
ProjectResource(project_id=project_id, resource_id=pk, server_version=1)
|
|
815
|
+
for pk in rostered_resource_pks
|
|
816
|
+
if pk not in existing_pairs
|
|
817
|
+
]
|
|
818
|
+
if new_roster:
|
|
819
|
+
ProjectResource.objects.bulk_create(
|
|
820
|
+
new_roster, ignore_conflicts=True, batch_size=batch_size
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _apply_calendar(
|
|
825
|
+
project_id: str,
|
|
826
|
+
data: ProjectData,
|
|
827
|
+
summary: dict[str, Any],
|
|
828
|
+
*,
|
|
829
|
+
overwrite: bool,
|
|
830
|
+
batch_size: int,
|
|
831
|
+
) -> None:
|
|
832
|
+
"""Wire the file's project calendar onto ``Project.calendar`` (#1769).
|
|
833
|
+
|
|
834
|
+
Resolution: the project-level ``<CalendarUID>`` picks the calendar; when the
|
|
835
|
+
header omits it and the file has exactly one base calendar, that calendar is
|
|
836
|
+
unambiguous and used. The row is reused from the shared calendar library
|
|
837
|
+
only on an exact semantic match (see ``_match_or_create_calendar``),
|
|
838
|
+
otherwise created together with its ``CalendarException`` rows.
|
|
839
|
+
|
|
840
|
+
Overwrite policy mirrors ``_apply_header_to_project``: create-from-import
|
|
841
|
+
(``overwrite=True``) treats the file as authoritative; import-into-existing
|
|
842
|
+
keeps a PM-configured project calendar and only warns when the file's
|
|
843
|
+
calendar differs semantically (silently "conflicting" on the ubiquitous
|
|
844
|
+
vanilla Standard calendar would be pure noise).
|
|
845
|
+
|
|
846
|
+
TruePPM has no per-task calendars, so task-level ``CalendarUID`` references
|
|
847
|
+
to any *other* calendar degrade to one aggregated warning per calendar.
|
|
848
|
+
"""
|
|
849
|
+
from trueppm_api.apps.projects.models import Project
|
|
850
|
+
|
|
851
|
+
if not data.calendars:
|
|
852
|
+
return
|
|
853
|
+
|
|
854
|
+
by_uid = {c.uid: c for c in data.calendars}
|
|
855
|
+
cal_data = _resolve_project_calendar_data(data, by_uid, summary)
|
|
856
|
+
|
|
857
|
+
_warn_unhonored_task_calendars(data, cal_data, by_uid, summary)
|
|
858
|
+
|
|
859
|
+
if cal_data is None:
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
project = (
|
|
863
|
+
Project.objects.select_related("calendar")
|
|
864
|
+
.prefetch_related("calendar__exceptions")
|
|
865
|
+
.get(pk=project_id)
|
|
866
|
+
)
|
|
867
|
+
if project.calendar_id is not None and not overwrite:
|
|
868
|
+
if project.calendar is not None and not _calendar_matches(project.calendar, cal_data):
|
|
869
|
+
summary["warnings"].append(
|
|
870
|
+
f"File calendar '{cal_data.name}' differs from the project's "
|
|
871
|
+
f"configured calendar '{project.calendar.name}'; the project "
|
|
872
|
+
f"calendar was kept — imported dates may shift"
|
|
873
|
+
)
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
calendar = _match_or_create_calendar(cal_data, summary, batch_size=batch_size)
|
|
877
|
+
if project.calendar_id != calendar.pk:
|
|
878
|
+
project.calendar = calendar
|
|
879
|
+
# save() rather than .update() so server_version bumps and sync clients
|
|
880
|
+
# observe the calendar change — same pattern as
|
|
881
|
+
# shift_project_start_if_needed on the start-date pull-back.
|
|
882
|
+
project.save(update_fields=["calendar"])
|
|
883
|
+
summary["calendar_applied"] = True
|
|
884
|
+
summary["calendar_name"] = calendar.name
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _resolve_project_calendar_data(
|
|
888
|
+
data: ProjectData, by_uid: dict[int, CalendarData], summary: dict[str, Any]
|
|
889
|
+
) -> CalendarData | None:
|
|
890
|
+
"""Pick the file's project calendar: the explicit ``CalendarUID`` if present,
|
|
891
|
+
else the sole base calendar when unambiguous. Warns and returns None when the
|
|
892
|
+
referenced UID is missing, or when the header omits it and several calendars exist.
|
|
893
|
+
"""
|
|
894
|
+
cal_data = by_uid.get(data.calendar_uid) if data.calendar_uid is not None else None
|
|
895
|
+
if cal_data is not None:
|
|
896
|
+
return cal_data
|
|
897
|
+
if data.calendar_uid is not None:
|
|
898
|
+
summary["warnings"].append(
|
|
899
|
+
f"Project calendar UID {data.calendar_uid} not found among the "
|
|
900
|
+
f"file's base calendars; project calendar left unchanged"
|
|
901
|
+
)
|
|
902
|
+
elif len(data.calendars) == 1:
|
|
903
|
+
cal_data = data.calendars[0]
|
|
904
|
+
else:
|
|
905
|
+
summary["warnings"].append(
|
|
906
|
+
f"File defines {len(data.calendars)} base calendars but no "
|
|
907
|
+
f"project CalendarUID; project calendar left unchanged"
|
|
908
|
+
)
|
|
909
|
+
return cal_data
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _warn_unhonored_task_calendars(
|
|
913
|
+
data: ProjectData,
|
|
914
|
+
cal_data: CalendarData | None,
|
|
915
|
+
by_uid: dict[int, CalendarData],
|
|
916
|
+
summary: dict[str, Any],
|
|
917
|
+
) -> None:
|
|
918
|
+
"""Emit one aggregated warning per distinct task-level calendar TruePPM cannot
|
|
919
|
+
honor (it has no per-task calendars) — never one warning per task, so a
|
|
920
|
+
500-task plan on a shared task calendar produces a single line, not 500.
|
|
921
|
+
"""
|
|
922
|
+
project_cal_uid = cal_data.uid if cal_data is not None else data.calendar_uid
|
|
923
|
+
task_cal_counts: dict[int, int] = {}
|
|
924
|
+
for td in data.tasks:
|
|
925
|
+
if td.calendar_uid is not None and td.calendar_uid != project_cal_uid:
|
|
926
|
+
task_cal_counts[td.calendar_uid] = task_cal_counts.get(td.calendar_uid, 0) + 1
|
|
927
|
+
for uid, count in sorted(task_cal_counts.items()):
|
|
928
|
+
ref = by_uid.get(uid)
|
|
929
|
+
label = f"'{ref.name}' (UID {uid})" if ref is not None else f"UID {uid}"
|
|
930
|
+
summary["warnings"].append(
|
|
931
|
+
f"{count} task(s) reference calendar {label}; TruePPM has no "
|
|
932
|
+
f"per-task calendars — they are scheduled on the project calendar"
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _match_or_create_calendar(
|
|
937
|
+
cal_data: CalendarData, summary: dict[str, Any], *, batch_size: int
|
|
938
|
+
) -> Any:
|
|
939
|
+
"""Return a library ``Calendar`` row for the parsed calendar, reusing on exact match.
|
|
940
|
+
|
|
941
|
+
Reuse requires the same (case-insensitive) name AND identical semantics —
|
|
942
|
+
mask, hours per day, and exception ranges. A name-only match (every MS
|
|
943
|
+
Project file ships a "Standard") could silently attach another team's
|
|
944
|
+
holidays to this project; a semantics-only match under a different name
|
|
945
|
+
would confuse the calendar library UI. Repeated imports of the same file
|
|
946
|
+
therefore converge on one row instead of accreting duplicates.
|
|
947
|
+
"""
|
|
948
|
+
from datetime import date as _date
|
|
949
|
+
|
|
950
|
+
from trueppm_api.apps.projects.models import Calendar, CalendarException
|
|
951
|
+
|
|
952
|
+
candidates = Calendar.objects.filter(
|
|
953
|
+
name__iexact=cal_data.name, is_deleted=False
|
|
954
|
+
).prefetch_related("exceptions")
|
|
955
|
+
for candidate in candidates:
|
|
956
|
+
if _calendar_matches(candidate, cal_data):
|
|
957
|
+
summary["calendar_matched"] = True
|
|
958
|
+
return candidate
|
|
959
|
+
|
|
960
|
+
calendar = Calendar.objects.create(
|
|
961
|
+
name=cal_data.name[:255],
|
|
962
|
+
working_days=cal_data.working_days,
|
|
963
|
+
hours_per_day=cal_data.hours_per_day,
|
|
964
|
+
)
|
|
965
|
+
if cal_data.exceptions:
|
|
966
|
+
CalendarException.objects.bulk_create(
|
|
967
|
+
[
|
|
968
|
+
CalendarException(
|
|
969
|
+
calendar=calendar,
|
|
970
|
+
exc_start=_date.fromisoformat(exc.start),
|
|
971
|
+
exc_end=_date.fromisoformat(exc.end),
|
|
972
|
+
description=exc.name[:255],
|
|
973
|
+
)
|
|
974
|
+
for exc in cal_data.exceptions
|
|
975
|
+
],
|
|
976
|
+
batch_size=batch_size,
|
|
977
|
+
)
|
|
978
|
+
summary["calendar_created"] = True
|
|
979
|
+
return calendar
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _calendar_matches(calendar: Any, cal_data: CalendarData) -> bool:
|
|
983
|
+
"""True when a ``Calendar`` row is semantically identical to parsed data.
|
|
984
|
+
|
|
985
|
+
Compares the CPM inputs only — mask, hours per day, and exception date
|
|
986
|
+
ranges. Exception descriptions and timezone are metadata that cannot shift
|
|
987
|
+
a schedule, so they don't block reuse.
|
|
988
|
+
"""
|
|
989
|
+
if calendar.working_days != cal_data.working_days:
|
|
990
|
+
return False
|
|
991
|
+
if abs(calendar.hours_per_day - cal_data.hours_per_day) > 1e-6:
|
|
992
|
+
return False
|
|
993
|
+
existing = {
|
|
994
|
+
(exc.exc_start.isoformat(), exc.exc_end.isoformat()) for exc in calendar.exceptions.all()
|
|
995
|
+
}
|
|
996
|
+
parsed = {(exc.start, exc.end) for exc in cal_data.exceptions}
|
|
997
|
+
return existing == parsed
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _build_wbs_paths(tasks: list[TaskData]) -> list[str]:
|
|
1001
|
+
"""Compute an ltree ``wbs_path`` per task, preserving the WBS hierarchy.
|
|
1002
|
+
|
|
1003
|
+
MS Project encodes hierarchy two ways: the dotted ``OutlineNumber``
|
|
1004
|
+
("1", "1.1", "1.2") and the integer ``OutlineLevel`` (indent depth).
|
|
1005
|
+
Genuine MS Project exports keep both consistent, but many third-party and
|
|
1006
|
+
generated MSPDI files write a *flat* ``OutlineNumber`` (1, 2, 3, …) and
|
|
1007
|
+
carry the hierarchy only in ``OutlineLevel`` (#794). Trusting the outline
|
|
1008
|
+
number alone flattens those files — every phase imports as a sibling of its
|
|
1009
|
+
own sub-tasks. So:
|
|
1010
|
+
|
|
1011
|
+
- if any task has a dotted ``OutlineNumber`` the file uses the
|
|
1012
|
+
hierarchical form: map each number straight to an ltree path (the
|
|
1013
|
+
original, well-formed-file behavior — unchanged);
|
|
1014
|
+
- otherwise, when ``OutlineLevel`` indicates nesting, reconstruct the
|
|
1015
|
+
hierarchy from the level sequence (tasks arrive in document order).
|
|
1016
|
+
"""
|
|
1017
|
+
has_dotted = any("." in (t.outline_number or "") for t in tasks)
|
|
1018
|
+
if has_dotted:
|
|
1019
|
+
return [_outline_number_to_ltree(t.outline_number) for t in tasks]
|
|
1020
|
+
|
|
1021
|
+
levels = [t.outline_level for t in tasks]
|
|
1022
|
+
if len(set(levels)) <= 1:
|
|
1023
|
+
# Genuinely flat project (or no level info): keep the outline number,
|
|
1024
|
+
# which is itself flat (1, 2, 3, …) for these files.
|
|
1025
|
+
return [_outline_number_to_ltree(t.outline_number) for t in tasks]
|
|
1026
|
+
|
|
1027
|
+
return _wbs_paths_from_levels(levels)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _wbs_paths_from_levels(levels: list[int]) -> list[str]:
|
|
1031
|
+
"""Reconstruct dotted ltree paths from a document-ordered OutlineLevel list.
|
|
1032
|
+
|
|
1033
|
+
Walks tasks in document order maintaining a sibling counter per depth. A
|
|
1034
|
+
deeper level opens a child ("1" -> "1.1"); the same or a shallower level
|
|
1035
|
+
advances the sibling counter at that depth ("1.1" -> "1.2", "1.4" -> "2").
|
|
1036
|
+
Levels are normalized so the shallowest observed level becomes depth 1, so
|
|
1037
|
+
files whose top-level tasks start at OutlineLevel 1 (or any value) behave
|
|
1038
|
+
identically.
|
|
1039
|
+
"""
|
|
1040
|
+
base = min(levels)
|
|
1041
|
+
counters: list[int] = []
|
|
1042
|
+
paths: list[str] = []
|
|
1043
|
+
for level in levels:
|
|
1044
|
+
depth = max(level - base + 1, 1)
|
|
1045
|
+
if depth <= len(counters):
|
|
1046
|
+
# Same or shallower: drop deeper ancestors, bump this depth's sibling.
|
|
1047
|
+
counters = counters[:depth]
|
|
1048
|
+
counters[depth - 1] += 1
|
|
1049
|
+
else:
|
|
1050
|
+
# Deeper: open child levels (pads with 1s if a level is skipped).
|
|
1051
|
+
while len(counters) < depth:
|
|
1052
|
+
counters.append(1)
|
|
1053
|
+
paths.append(".".join(str(c) for c in counters))
|
|
1054
|
+
return paths
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _summary_indices(wbs_paths: list[str]) -> set[int]:
|
|
1058
|
+
"""Return the indices of summary tasks (have at least one descendant).
|
|
1059
|
+
|
|
1060
|
+
A task is a summary when some other task's ``wbs_path`` is strictly
|
|
1061
|
+
descended from this one's (e.g. ``"1"`` is a summary if any task has
|
|
1062
|
+
path ``"1.1"`` or deeper). Implemented in O(N) by collecting all strict
|
|
1063
|
+
ancestor paths in one pass, then marking every task whose own path is in
|
|
1064
|
+
that set. Used by the importer to skip three-point field writes on
|
|
1065
|
+
summary rows (ADR-0093 Q5).
|
|
1066
|
+
"""
|
|
1067
|
+
ancestor_paths: set[str] = set()
|
|
1068
|
+
for path in wbs_paths:
|
|
1069
|
+
if not path:
|
|
1070
|
+
continue
|
|
1071
|
+
parts = path.split(".")
|
|
1072
|
+
# Each strict prefix of a leaf is an ancestor (excluding the path
|
|
1073
|
+
# itself, since a task with no descendants is a leaf).
|
|
1074
|
+
for end in range(1, len(parts)):
|
|
1075
|
+
ancestor_paths.add(".".join(parts[:end]))
|
|
1076
|
+
return {i for i, p in enumerate(wbs_paths) if p and p in ancestor_paths}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _outline_number_to_ltree(outline_number: str) -> str:
|
|
1080
|
+
"""Convert MS Project OutlineNumber (e.g. '1.2.3') to ltree path.
|
|
1081
|
+
|
|
1082
|
+
MS Project OutlineNumber is already dot-separated and hierarchical,
|
|
1083
|
+
which maps directly to the ltree format used by wbs_path.
|
|
1084
|
+
"""
|
|
1085
|
+
if not outline_number:
|
|
1086
|
+
return ""
|
|
1087
|
+
parts = outline_number.split(".")
|
|
1088
|
+
cleaned: list[str] = []
|
|
1089
|
+
for p in parts:
|
|
1090
|
+
p = p.strip()
|
|
1091
|
+
if p.isdigit():
|
|
1092
|
+
cleaned.append(p)
|
|
1093
|
+
else:
|
|
1094
|
+
return ""
|
|
1095
|
+
return ".".join(cleaned)
|