trueppm-api 0.4.0__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 +27 -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 +346 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -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_squashed_0019_release_0_4.py +302 -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 +143 -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/minter_role.py +38 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +3263 -0
- trueppm_api/apps/access/serializers.py +1007 -0
- trueppm_api/apps/access/services.py +878 -0
- trueppm_api/apps/access/signals.py +171 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +96 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1965 -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/0001_squashed_0003_release_0_4.py +344 -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 +109 -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/0001_squashed_0003_release_0_4.py +90 -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 +459 -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 +178 -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 +863 -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 +597 -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_squashed_0014_release_0_4.py +225 -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/0014_tasklink_created_by.py +27 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +635 -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 +528 -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 +734 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1158 -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 +280 -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 +551 -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 +1276 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +439 -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 +462 -0
- trueppm_api/apps/notifications/email_backend.py +350 -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 +299 -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_squashed_0011_release_0_4.py +204 -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 +1148 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +1094 -0
- trueppm_api/apps/notifications/services.py +1489 -0
- trueppm_api/apps/notifications/tasks.py +762 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +919 -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 +566 -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 +993 -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 +537 -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_squashed_0009_release_0_4.py +128 -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 +541 -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 +458 -0
- trueppm_api/apps/projects/backfill.py +459 -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 +212 -0
- trueppm_api/apps/projects/config_notice.py +1330 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +132 -0
- trueppm_api/apps/projects/decisions_views.py +97 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +423 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -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/key_views.py +468 -0
- trueppm_api/apps/projects/keys.py +205 -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 +514 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -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/migration_helpers.py +75 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -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 +59 -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 +75 -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 +76 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -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/0154_object_keys.py +184 -0
- trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
- trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
- trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +9209 -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 +903 -0
- trueppm_api/apps/projects/program_schedule.py +767 -0
- trueppm_api/apps/projects/program_views.py +3445 -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 +492 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
- trueppm_api/apps/projects/seed/exporter.py +1717 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2764 -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 +128 -0
- trueppm_api/apps/projects/seed/replay.py +1695 -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 +1169 -0
- trueppm_api/apps/projects/serializers.py +12904 -0
- trueppm_api/apps/projects/services.py +7525 -0
- trueppm_api/apps/projects/share_serializers.py +142 -0
- trueppm_api/apps/projects/share_services.py +373 -0
- trueppm_api/apps/projects/share_views.py +395 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +910 -0
- trueppm_api/apps/projects/signal_privacy_views.py +485 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +288 -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 +1244 -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 +1979 -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 +261 -0
- trueppm_api/apps/projects/template_views.py +829 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1016 -0
- trueppm_api/apps/projects/utilization.py +909 -0
- trueppm_api/apps/projects/views.py +21225 -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/0014_squashed_0015_release_0_4.py +47 -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 +1752 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +144 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/finish_reading.py +198 -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/mc_admission.py +181 -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/0010_squashed_0016_release_0_4.py +106 -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/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +542 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +334 -0
- trueppm_api/apps/scheduling/serializers.py +723 -0
- trueppm_api/apps/scheduling/services.py +1251 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +2248 -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 +2908 -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 +446 -0
- trueppm_api/apps/sso/services.py +1473 -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 +369 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +548 -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/0003_squashed_0005_release_0_4.py +77 -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/0006_boardevent_per_project_seq.py +77 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +272 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +488 -0
- trueppm_api/apps/sync/serializers.py +688 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +556 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +957 -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 +159 -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/0004_backfill_default_teams_on_upgrade.py +49 -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 +440 -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/0001_squashed_0004_release_0_4.py +167 -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 +211 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +597 -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 +279 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -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 +100 -0
- trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
- trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +437 -0
- trueppm_api/apps/webhooks/serializers.py +326 -0
- trueppm_api/apps/webhooks/tasks.py +680 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +533 -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 +371 -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 +254 -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_squashed_0027_release_0_4.py +199 -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 +967 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +616 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1581 -0
- trueppm_api/asgi.py +49 -0
- trueppm_api/celery.py +40 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +934 -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/demo_read_only.py +314 -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 +1221 -0
- trueppm_api/core/password_reset.py +513 -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 +1034 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +514 -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/core/worker_selfheal.py +205 -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 +2605 -0
- trueppm_api/settings/dev.py +296 -0
- trueppm_api/settings/prod.py +265 -0
- trueppm_api/urls.py +217 -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.0.dist-info/METADATA +273 -0
- trueppm_api-0.4.0.dist-info/RECORD +799 -0
- trueppm_api-0.4.0.dist-info/WHEEL +4 -0
- trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
|
@@ -0,0 +1,2764 @@
|
|
|
1
|
+
"""Import a canonical JSON seed document into the database (ADR-0109, issue #615).
|
|
2
|
+
|
|
3
|
+
``import_seed`` validates a payload (#614) and then materializes one program,
|
|
4
|
+
its projects, tasks, dependencies, sprints, baselines, risks, resources, and
|
|
5
|
+
memberships inside a single transaction. File-local slugs and ltree wbs paths
|
|
6
|
+
are resolved to freshly-minted UUIDs through in-memory symbol tables; nothing in
|
|
7
|
+
the seed file carries a UUID.
|
|
8
|
+
|
|
9
|
+
Re-import is keyed on the program slug (persisted in ``Program.code``). The seed
|
|
10
|
+
format carries no stable entity ids until 0.5 (#1959), so a field-level merge has
|
|
11
|
+
nothing to key on and wipe-then-rebuild remains the right idempotency model —
|
|
12
|
+
but it is destructive, so as of ADR-0726 it requires explicit consent
|
|
13
|
+
(``replace=True``) and is *recoverable*: a real program's subtree is
|
|
14
|
+
**soft**-deleted into Trash with tombstones and per-project delete broadcasts.
|
|
15
|
+
Only the disposable demo path (``is_sample``) still hard-deletes, which is what
|
|
16
|
+
that data is for (ADR-0109).
|
|
17
|
+
|
|
18
|
+
The seeded tasks carry no CPM outputs (those are derived), so a schedule
|
|
19
|
+
recalculation is enqueued per project after commit; a board broadcast is
|
|
20
|
+
likewise deferred to ``transaction.on_commit``.
|
|
21
|
+
|
|
22
|
+
Writes are batched (ADR-0726 §8). The whole run sits inside
|
|
23
|
+
``coalesce_sync_seq()`` so the per-row delta-cursor allocation collapses to one
|
|
24
|
+
draw per project, and the O(n) collections go through ``_bulk_insert``, which
|
|
25
|
+
hand-maintains the three things ``VersionedModel.save()`` would otherwise do for
|
|
26
|
+
free — ``server_version``, ``sync_seq``, and the ``simple_history`` row.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import random
|
|
35
|
+
import uuid
|
|
36
|
+
from collections import defaultdict
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import date, datetime, timedelta
|
|
39
|
+
from datetime import time as dt_time
|
|
40
|
+
from decimal import Decimal
|
|
41
|
+
from functools import partial
|
|
42
|
+
from typing import Any
|
|
43
|
+
from zoneinfo import ZoneInfo
|
|
44
|
+
|
|
45
|
+
from django.contrib.auth import get_user_model
|
|
46
|
+
from django.db import transaction
|
|
47
|
+
from django.db.models import F
|
|
48
|
+
from django.utils import timezone
|
|
49
|
+
from simple_history.utils import bulk_create_with_history
|
|
50
|
+
|
|
51
|
+
from trueppm_api.apps.access.models import ProgramMembership, ProjectMembership, Role
|
|
52
|
+
from trueppm_api.apps.access.services import create_program
|
|
53
|
+
from trueppm_api.apps.projects.models import (
|
|
54
|
+
AcceptanceCriterion,
|
|
55
|
+
BacklogItem,
|
|
56
|
+
Baseline,
|
|
57
|
+
BaselineTask,
|
|
58
|
+
BoardColumnConfig,
|
|
59
|
+
Calendar,
|
|
60
|
+
CalendarException,
|
|
61
|
+
CeremonyTemplate,
|
|
62
|
+
Dependency,
|
|
63
|
+
DorState,
|
|
64
|
+
EstimateStatus,
|
|
65
|
+
Label,
|
|
66
|
+
ObjectKeySource,
|
|
67
|
+
Program,
|
|
68
|
+
Project,
|
|
69
|
+
Risk,
|
|
70
|
+
RiskTask,
|
|
71
|
+
Sprint,
|
|
72
|
+
Task,
|
|
73
|
+
TaskAttachment,
|
|
74
|
+
TaskLabel,
|
|
75
|
+
TaskRecurrenceRule,
|
|
76
|
+
TaskRelation,
|
|
77
|
+
TaskSource,
|
|
78
|
+
TaskStatus,
|
|
79
|
+
bulk_create_tasks,
|
|
80
|
+
)
|
|
81
|
+
from trueppm_api.apps.projects.seed.forecast_backfill import (
|
|
82
|
+
backfill_forecast_history,
|
|
83
|
+
backfill_monte_carlo_run_history,
|
|
84
|
+
)
|
|
85
|
+
from trueppm_api.apps.projects.seed.reldates import (
|
|
86
|
+
WorkingCalendar,
|
|
87
|
+
resolve_anchor,
|
|
88
|
+
resolve_date,
|
|
89
|
+
resolve_timestamp,
|
|
90
|
+
)
|
|
91
|
+
from trueppm_api.apps.projects.seed.replace import resolve_replace_candidates
|
|
92
|
+
from trueppm_api.apps.projects.seed.replay import ReplayContext, replay_timeline
|
|
93
|
+
from trueppm_api.apps.projects.seed.validation import validate_seed
|
|
94
|
+
from trueppm_api.apps.resources.models import (
|
|
95
|
+
Resource,
|
|
96
|
+
ResourceSkill,
|
|
97
|
+
Skill,
|
|
98
|
+
TaskResource,
|
|
99
|
+
TaskSkillRequirement,
|
|
100
|
+
)
|
|
101
|
+
from trueppm_api.apps.resources.services import ensure_project_resource
|
|
102
|
+
from trueppm_api.apps.scheduling.services import enqueue_recalculate
|
|
103
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
104
|
+
from trueppm_api.apps.sync.sequence import allocate_for_projects, coalesce_sync_seq
|
|
105
|
+
from trueppm_api.apps.timetracking.models import TimeEntry, TimesheetSubmission
|
|
106
|
+
|
|
107
|
+
logger = logging.getLogger(__name__)
|
|
108
|
+
|
|
109
|
+
_UTC = ZoneInfo("UTC")
|
|
110
|
+
|
|
111
|
+
#: Most days of synthesized time one task contributes on the sample path (#3490).
|
|
112
|
+
#: The most recent days of a long waterfall task are what a timesheet shows; an
|
|
113
|
+
#: uncapped fill would write a row per person per working day for months.
|
|
114
|
+
_TIME_FILL_MAX_DAYS = 40
|
|
115
|
+
|
|
116
|
+
#: The seed's proficiency names -> ``Proficiency`` integers (#3498).
|
|
117
|
+
_PROFICIENCY_BY_NAME = {"beginner": 1, "intermediate": 2, "expert": 3}
|
|
118
|
+
|
|
119
|
+
#: Weekday names -> the ``TaskRecurrenceRule.weekdays`` bitmask (Mon=1 … Sun=64).
|
|
120
|
+
_WEEKDAY_BIT = {"mon": 1, "tue": 2, "wed": 4, "thu": 8, "fri": 16, "sat": 32, "sun": 64}
|
|
121
|
+
|
|
122
|
+
#: Rows per INSERT statement in the batched passes (ADR-0726 §8).
|
|
123
|
+
#:
|
|
124
|
+
#: Django's PostgreSQL backend does not override ``bulk_batch_size``, so an
|
|
125
|
+
#: unbounded ``bulk_create`` emits *one* statement for the whole project. With
|
|
126
|
+
#: psycopg's default client-side binding that is a single mogrified string
|
|
127
|
+
#: holding every literal — ``Task`` has ~57 columns, and the history table adds
|
|
128
|
+
#: five more — so an adversarially dense document would build a multi-hundred-MB
|
|
129
|
+
#: statement (twice, row + history) before Postgres ever sees it. Worker memory
|
|
130
|
+
#: is the binding resource once the import is async, so the batch is capped.
|
|
131
|
+
_BULK_BATCH_SIZE = 500
|
|
132
|
+
|
|
133
|
+
# Stand-in reason for a ``blocked`` cluster that carries no ``reason`` (#4082). The
|
|
134
|
+
# exporter withholds the ADR-0124 private text from anyone but the assignee or an
|
|
135
|
+
# @-mentioned user, but ``blocked_reason`` is the flag-of-record — emptiness means
|
|
136
|
+
# "not blocked". Importing the bare cluster as-is would silently unblock the task
|
|
137
|
+
# and drop the age/type/link triage signal the export deliberately kept, so a
|
|
138
|
+
# present cluster with no reason imports as blocked with this placeholder.
|
|
139
|
+
_PRIVATE_BLOCKER_REASON = "(private)"
|
|
140
|
+
|
|
141
|
+
# Ceiling for a TaskResource synthesized from a bare ``assignee`` (#2900). The
|
|
142
|
+
# actual units are ``min(this, resource.max_units)`` — a bare assignee means "this
|
|
143
|
+
# person is on this task at their normal availability", so a half-time engineer
|
|
144
|
+
# lands at 0.5 and a 10% advisor at 0.1 rather than all of them at 1.0.
|
|
145
|
+
#
|
|
146
|
+
# That distinction is load-bearing for the demo the packs exist to give: they
|
|
147
|
+
# deliberately staff part-timers and 10% advisors ("not everyone is at 100%", per
|
|
148
|
+
# the sample-projects docs), and billing an advisor a full unit would put her at
|
|
149
|
+
# 1000% on every task she owns — an all-red heatmap misrepresenting the very
|
|
150
|
+
# capacity story the fixture was authored to tell. A seed that wants some other
|
|
151
|
+
# split says so with an explicit ``assignments`` array, which always wins.
|
|
152
|
+
_ASSIGNEE_MAX_UNITS = Decimal("1.0")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SeedReplaceRequired(Exception):
|
|
156
|
+
"""A live program the caller owns holds this seed's slug and ``replace`` was not given.
|
|
157
|
+
|
|
158
|
+
Raised *before* anything is written. Carries the resolved ``Program`` so the
|
|
159
|
+
view can render the ``409`` conflict body without re-running the ownership
|
|
160
|
+
query — and so the only program that can ever be named is one the resolver
|
|
161
|
+
already proved the caller owns (#994).
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, program: Program) -> None:
|
|
165
|
+
self.program = program
|
|
166
|
+
super().__init__(f"A program you own already uses the code {program.code!r}.")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SeedReplaceMismatch(Exception):
|
|
170
|
+
"""``expected_program_id`` did not match the program that would actually be replaced.
|
|
171
|
+
|
|
172
|
+
The compare-and-swap failure. A client that resolved a conflict via the dry
|
|
173
|
+
run and then acted on it is protected from the collision set having changed
|
|
174
|
+
underneath it in the meantime — the alternative, a bare ``replace=True``,
|
|
175
|
+
would silently follow the change and destroy a different program.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class SeedReplaceAmbiguous(Exception):
|
|
180
|
+
"""Several live programs the caller owns hold this slug, and none was named.
|
|
181
|
+
|
|
182
|
+
``Program.code`` carries no uniqueness constraint, so owning two programs
|
|
183
|
+
under one code is legitimate. A confirmation can only describe one of them,
|
|
184
|
+
so replacing the whole set would destroy a program the caller was never
|
|
185
|
+
shown a count for — ``expected_program_id`` is the way out.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
User = get_user_model()
|
|
190
|
+
|
|
191
|
+
# Program membership role names in the seed map to the Role integer ladder.
|
|
192
|
+
_ROLE_BY_NAME = {
|
|
193
|
+
"VIEWER": Role.VIEWER,
|
|
194
|
+
"MEMBER": Role.MEMBER,
|
|
195
|
+
"SCHEDULER": Role.SCHEDULER,
|
|
196
|
+
"ADMIN": Role.ADMIN,
|
|
197
|
+
"OWNER": Role.OWNER,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def import_seed(
|
|
202
|
+
payload: dict[str, Any],
|
|
203
|
+
*,
|
|
204
|
+
owner: Any,
|
|
205
|
+
create_users: bool = False,
|
|
206
|
+
is_sample: bool = False,
|
|
207
|
+
persona_password: str | None = None,
|
|
208
|
+
replace: bool = False,
|
|
209
|
+
expected_program_id: str | None = None,
|
|
210
|
+
target_program: Program | None = None,
|
|
211
|
+
adopt_keys_from: list[str] | None = None,
|
|
212
|
+
) -> Program:
|
|
213
|
+
"""Validate and import a seed document, returning the created ``Program``.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
payload: a parsed seed document (already JSON-decoded).
|
|
217
|
+
owner: the ``User`` who owns the imported program (gets OWNER membership).
|
|
218
|
+
create_users: when ``True``, ``accounts[]`` that name no existing user
|
|
219
|
+
are created (used by ``make seed`` / the management command). When
|
|
220
|
+
``False`` (the REST default), missing accounts resolve to ``None`` —
|
|
221
|
+
importing a seed must not silently mint logins on a live instance.
|
|
222
|
+
is_sample: when ``True`` this is the disposable demo/sample path
|
|
223
|
+
(``load_sample``). It marks every created project ``is_sample`` and
|
|
224
|
+
reuses the shared persona resource catalog. When ``False`` (the
|
|
225
|
+
generic ``import_seed`` path) imported resources are created fresh so
|
|
226
|
+
a seed can never bind a pre-existing global resource — and the real
|
|
227
|
+
user it may carry — into the importer's program (#1004).
|
|
228
|
+
persona_password: when set (and ``is_sample``), persona accounts are given
|
|
229
|
+
this as a usable login password so an evaluator can actually sign in as
|
|
230
|
+
each persona (#1760). Applies both to accounts this run creates and to
|
|
231
|
+
pre-existing persona rows that carry **no usable password of their own**
|
|
232
|
+
(#3484 — a reload is the common case, and skipping those made the CLI
|
|
233
|
+
report a login it had not enabled). A real account's password is never
|
|
234
|
+
overwritten, and staff/superuser rows are never touched. ``None`` (the
|
|
235
|
+
default) leaves personas with an unusable password, as before. Only the
|
|
236
|
+
server-curated sample path passes this; the caller is responsible for
|
|
237
|
+
never letting a fixed weak value reach a public instance (see the
|
|
238
|
+
``load_sample_project --with-personas`` DEBUG/env gate, mirroring #1350).
|
|
239
|
+
replace: authorizes tearing down a live program of the caller's that
|
|
240
|
+
already holds this seed's slug (ADR-0726, #2581). ``False`` — the
|
|
241
|
+
default, and the only safe default — raises
|
|
242
|
+
:class:`SeedReplaceRequired` instead, so a re-import can never
|
|
243
|
+
destroy real work that the caller did not name.
|
|
244
|
+
expected_program_id: optional compare-and-swap token. When given it must
|
|
245
|
+
equal the id of the program that would actually be replaced, or
|
|
246
|
+
:class:`SeedReplaceMismatch` is raised. Protects a client acting on a
|
|
247
|
+
dry run from a collision set that moved in between.
|
|
248
|
+
target_program: adopt this pre-created ``Program`` shell instead of
|
|
249
|
+
minting one (ADR-0726 §4). The async path creates the shell inside
|
|
250
|
+
the request — so the destructive replace happens in the request the
|
|
251
|
+
operator authorized, and the caller has somewhere to land — and hands
|
|
252
|
+
it here for the worker to fill. ``None`` keeps the create-it-myself
|
|
253
|
+
behavior the management command and ``load_sample`` rely on.
|
|
254
|
+
adopt_keys_from: ids of the projects the async request moved to Trash
|
|
255
|
+
when it replaced a program. Each rebuilt project takes the keys of
|
|
256
|
+
the trashed one with the same name, inside this import's
|
|
257
|
+
transaction (ADR-1237 §3). Only meaningful with ``target_program``.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
SeedValidationError: if the payload fails validation; nothing is written.
|
|
261
|
+
SeedReplaceRequired: if a live program collides and ``replace`` is False.
|
|
262
|
+
SeedReplaceMismatch: if ``expected_program_id`` names a different program.
|
|
263
|
+
"""
|
|
264
|
+
# Agent actions and share links are evidence and credentials: only the
|
|
265
|
+
# server-curated sample path (``load_sample``, the one caller that passes
|
|
266
|
+
# is_sample) may carry them, the same trust line ``persona_password`` uses.
|
|
267
|
+
validate_seed(payload, allow_sample_sections=is_sample)
|
|
268
|
+
importer = _SeedImporter(
|
|
269
|
+
payload,
|
|
270
|
+
owner=owner,
|
|
271
|
+
create_users=create_users,
|
|
272
|
+
is_sample=is_sample,
|
|
273
|
+
persona_password=persona_password,
|
|
274
|
+
replace=replace,
|
|
275
|
+
expected_program_id=expected_program_id,
|
|
276
|
+
target_program=target_program,
|
|
277
|
+
adopt_keys_from=adopt_keys_from,
|
|
278
|
+
)
|
|
279
|
+
# coalesce_sync_seq draws one delta cursor per project for the whole import
|
|
280
|
+
# instead of one per row (ADR-0686). Rows written together may share a
|
|
281
|
+
# sync_seq — the delta is `> since` and the checkpoint is the maximum, so a
|
|
282
|
+
# batch lands wholly on one side of any client checkpoint.
|
|
283
|
+
with transaction.atomic(), coalesce_sync_seq():
|
|
284
|
+
program = importer.run()
|
|
285
|
+
return program
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class _TaskFields:
|
|
290
|
+
"""Fields derived from one seed task's document before the ``Task`` is built.
|
|
291
|
+
|
|
292
|
+
Split out of :meth:`_SeedImporter._build_task` so the derivation (pure,
|
|
293
|
+
read-only) and the ``Task(...)`` construction it feeds are separately
|
|
294
|
+
readable; see that method's callers for the business rules behind each
|
|
295
|
+
field.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
sprint: Sprint | None
|
|
299
|
+
assignee: Any
|
|
300
|
+
is_milestone: bool
|
|
301
|
+
estimate_fields: dict[str, Any]
|
|
302
|
+
final_status: str
|
|
303
|
+
planned_start: date | None
|
|
304
|
+
story_points: Any
|
|
305
|
+
base_status: str
|
|
306
|
+
base_percent: float
|
|
307
|
+
base_remaining: Any
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _SeedImporter:
|
|
311
|
+
"""Holds the per-import symbol tables while materializing a seed document."""
|
|
312
|
+
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
payload: dict[str, Any],
|
|
316
|
+
*,
|
|
317
|
+
owner: Any,
|
|
318
|
+
create_users: bool,
|
|
319
|
+
is_sample: bool = False,
|
|
320
|
+
persona_password: str | None = None,
|
|
321
|
+
replace: bool = False,
|
|
322
|
+
expected_program_id: str | None = None,
|
|
323
|
+
target_program: Program | None = None,
|
|
324
|
+
adopt_keys_from: list[str] | None = None,
|
|
325
|
+
provenance_kind: str = TaskSource.SEED_IMPORT,
|
|
326
|
+
provenance_source_id: uuid.UUID | None = None,
|
|
327
|
+
) -> None:
|
|
328
|
+
self.payload = payload
|
|
329
|
+
self.owner = owner
|
|
330
|
+
self.create_users = create_users
|
|
331
|
+
self.is_sample = is_sample
|
|
332
|
+
self.persona_password = persona_password
|
|
333
|
+
self.replace = replace
|
|
334
|
+
self.expected_program_id = expected_program_id
|
|
335
|
+
self.target_program = target_program
|
|
336
|
+
self.adopt_keys_from = list(adopt_keys_from or [])
|
|
337
|
+
#: Seed provenance stamped on every task this run creates (ADR-0786).
|
|
338
|
+
#: Parameterized rather than hard-coded because #2729's template seeding
|
|
339
|
+
#: materializes through this same importer and must record itself as
|
|
340
|
+
#: ``TaskSource.TEMPLATE`` with the template's id, not as a seed import.
|
|
341
|
+
self.provenance_kind = provenance_kind
|
|
342
|
+
self.provenance_source_id = provenance_source_id
|
|
343
|
+
#: Program this run tore down, for the caller's audit record. None when
|
|
344
|
+
#: nothing collided.
|
|
345
|
+
self.replaced_program_id: str | None = None
|
|
346
|
+
#: Key rows released by the program (and, on the sample hard-delete path,
|
|
347
|
+
#: the projects) this run replaced, so the rebuild adopts them instead of
|
|
348
|
+
#: minting suffixed keys (ADR-1237). Project rows are keyed by name.
|
|
349
|
+
self.released_program_keys: list[Any] = []
|
|
350
|
+
self.released_project_keys: dict[str, list[Any]] = {}
|
|
351
|
+
self.users: dict[str, Any] = {}
|
|
352
|
+
self.calendars: dict[str, Calendar] = {}
|
|
353
|
+
self.resources: dict[str, Resource] = {}
|
|
354
|
+
# account slug -> Resource, so a task carrying only ``assignee`` (an
|
|
355
|
+
# ACCOUNT slug) can still be given the TaskResource row that capacity,
|
|
356
|
+
# utilization and the heatmap actually read (#2900).
|
|
357
|
+
self.resources_by_account: dict[str, Resource] = {}
|
|
358
|
+
self.projects: dict[str, Project] = {}
|
|
359
|
+
# global task / sprint indices keyed by (project_slug, local_id)
|
|
360
|
+
self.tasks: dict[tuple[str, str], Task] = {}
|
|
361
|
+
self.sprints: dict[tuple[str, str], Sprint] = {}
|
|
362
|
+
# Label index keyed by (project_slug, label_slug) — ADR-0400 labels folded
|
|
363
|
+
# into the seed (#1958). Populated in Pass A, consumed by the Pass B
|
|
364
|
+
# ``_link_task_labels`` attach step.
|
|
365
|
+
self.labels: dict[tuple[str, str], Label] = {}
|
|
366
|
+
# v2 (ADR-0114): relative dates resolve against an anchor (import day),
|
|
367
|
+
# and an events timeline is replayed with backdated history. v1 docs set
|
|
368
|
+
# the major to "1", so replay is off and dates are plain ISO literals.
|
|
369
|
+
self.replay = str(payload.get("schema_version", "")).split(".")[0] == "2"
|
|
370
|
+
self.anchor: date = resolve_anchor(payload, date.today())
|
|
371
|
+
# (project_slug, wbs_path) -> earliest authored timeline beat naming that
|
|
372
|
+
# task, so a synthesized creation-history date can never land after a
|
|
373
|
+
# comment/status/estimate/etc. the seed itself dated against the task
|
|
374
|
+
# (#3487). Indexed once up front — Pass A builds tasks project by
|
|
375
|
+
# project, but the events timeline is not project-scoped.
|
|
376
|
+
self.earliest_task_event: dict[tuple[str, str], date] = self._index_earliest_task_events()
|
|
377
|
+
# (project_slug, wbs_path) of every task the timeline dates a status move
|
|
378
|
+
# against, so ``_build_task`` can tell a task that is *moved* into the
|
|
379
|
+
# backlog from one that simply lives there (#3488). Indexed here for the
|
|
380
|
+
# same reason as the map above: Pass A is project-scoped, the timeline is
|
|
381
|
+
# not.
|
|
382
|
+
self.authored_status_targets: set[tuple[str, str]] = self._index_authored_status_targets()
|
|
383
|
+
self.working_calendars: dict[str, WorkingCalendar] = {}
|
|
384
|
+
self.risks_by_slug: dict[str, Risk] = {}
|
|
385
|
+
# Desired END states for replay — tasks/sprints are created at a base
|
|
386
|
+
# state and walked forward to these by the timeline + synthesizer.
|
|
387
|
+
self.final_status: dict[tuple[str, str], str] = {}
|
|
388
|
+
self.final_sprint: dict[tuple[str, str], dict[str, Any]] = {}
|
|
389
|
+
# Authored progress for tasks that end IN_PROGRESS or REVIEW, restored
|
|
390
|
+
# by replay's ``_finalize_tasks`` (#3486, widened #3518). Holds only
|
|
391
|
+
# the keys the document wrote.
|
|
392
|
+
self.final_progress: dict[tuple[str, str], dict[str, Any]] = {}
|
|
393
|
+
# (project_slug, wbs_path) -> the task's acceptance criteria in position
|
|
394
|
+
# order (#3492); built in Pass B, handed to replay for task.ac_met ticks.
|
|
395
|
+
self.criteria: dict[tuple[str, str], list[AcceptanceCriterion]] = {}
|
|
396
|
+
|
|
397
|
+
def run(self) -> Program:
|
|
398
|
+
# Adopting a pre-created shell means the caller already resolved and
|
|
399
|
+
# performed the replacement inside the request it was authorized in
|
|
400
|
+
# (ADR-0726 §4). Running the teardown again here would be actively
|
|
401
|
+
# wrong, not merely redundant: the shell itself now carries this seed's
|
|
402
|
+
# slug and the caller owns it, so it would match its own candidate query
|
|
403
|
+
# and soft-delete the program it was handed to fill.
|
|
404
|
+
if self.target_program is None:
|
|
405
|
+
self._replace_existing()
|
|
406
|
+
else:
|
|
407
|
+
self._release_trashed_project_keys()
|
|
408
|
+
self._resolve_accounts()
|
|
409
|
+
self._resolve_calendars()
|
|
410
|
+
self._resolve_resources()
|
|
411
|
+
program = self._create_program()
|
|
412
|
+
|
|
413
|
+
# Pass A: create each project's structure (sprints, tasks). This fills
|
|
414
|
+
# the global task/sprint indices so Pass B can resolve cross-project
|
|
415
|
+
# references and links that may point anywhere in the program.
|
|
416
|
+
for project_data in self.payload["projects"]:
|
|
417
|
+
self._create_project_structure(program, project_data)
|
|
418
|
+
|
|
419
|
+
# Pass B: wire links that may reference tasks created in any project.
|
|
420
|
+
for project_data in self.payload["projects"]:
|
|
421
|
+
project = self.projects[project_data["slug"]]
|
|
422
|
+
self._link_dependencies(project, project_data)
|
|
423
|
+
self._link_parent_epics(project_data)
|
|
424
|
+
self._link_sprint_milestones(project_data)
|
|
425
|
+
self._link_task_labels(project_data)
|
|
426
|
+
self._link_task_relations(project_data["slug"], project_data)
|
|
427
|
+
self._resolve_blockers(project_data)
|
|
428
|
+
self._create_attachments(project_data)
|
|
429
|
+
self._create_acceptance_criteria(project_data)
|
|
430
|
+
self._create_recurrence_rules(project_data)
|
|
431
|
+
self._create_skill_requirements(project_data)
|
|
432
|
+
self._assign_resources(project, project_data)
|
|
433
|
+
self._capture_baselines(project, project_data)
|
|
434
|
+
self._create_risks(project, project_data.get("risks", []), project_data["slug"])
|
|
435
|
+
|
|
436
|
+
self._create_program_risks(program)
|
|
437
|
+
# v2.1 program sections (#3603). After Pass B so a pulled backlog item can
|
|
438
|
+
# link a task in any project.
|
|
439
|
+
self._create_backlog_items(program)
|
|
440
|
+
self._create_ceremonies(program)
|
|
441
|
+
|
|
442
|
+
# Pass C (v2 only): replay the events timeline + synthesized fill so the
|
|
443
|
+
# demo reads as a program that has run for months — backdated history,
|
|
444
|
+
# real burndown/velocity, scope-injection audit. Runs inside this same
|
|
445
|
+
# transaction under the seed_replay flag (side effects suppressed).
|
|
446
|
+
if self.replay:
|
|
447
|
+
replay_timeline(self.payload, self._replay_context())
|
|
448
|
+
|
|
449
|
+
# Sample-only v2.1 passes (#3603), after replay: time fills the *actual*
|
|
450
|
+
# windows replay stamped, submissions read the time just written, and the
|
|
451
|
+
# agent trail and share links reference the finished program. Each method
|
|
452
|
+
# returns immediately off the sample path.
|
|
453
|
+
self._synthesize_time()
|
|
454
|
+
self._synthesize_timesheet_submissions()
|
|
455
|
+
self._create_share_links()
|
|
456
|
+
|
|
457
|
+
# Pass D (#376): backfill ProjectForecastSnapshot history for any project
|
|
458
|
+
# that authored a forecast_history block. Runs after replay so task_count /
|
|
459
|
+
# completed_task_count reflect the final replayed state, and still inside the
|
|
460
|
+
# import transaction so a failure rolls the whole import back (ADR-0211).
|
|
461
|
+
self._backfill_forecast_history()
|
|
462
|
+
|
|
463
|
+
# Last write of the import, deliberately. record_agent_action takes the
|
|
464
|
+
# instance-wide chain-head row lock and holds it to commit, so every live MCP
|
|
465
|
+
# read that must record an audit row waits behind it. Anything written after
|
|
466
|
+
# it would widen that window for no reason.
|
|
467
|
+
self._record_sample_agent_actions()
|
|
468
|
+
|
|
469
|
+
project_ids = [str(p.pk) for p in self.projects.values()]
|
|
470
|
+
# Seeded tasks have no CPM dates; recompute so the schedule renders.
|
|
471
|
+
# Both effects are deferred to post-commit so they never fire on a
|
|
472
|
+
# rolled-back import.
|
|
473
|
+
for pid in project_ids:
|
|
474
|
+
transaction.on_commit(partial(enqueue_recalculate, project_id=pid))
|
|
475
|
+
|
|
476
|
+
# A direct call rather than partial(broadcast_board_event, ...): the WS
|
|
477
|
+
# freeze guard AST-scans for the event-type literal in the argument slot
|
|
478
|
+
# of a ``broadcast_board_event`` call, and under partial the callee name
|
|
479
|
+
# is ``partial``, which makes the literal invisible to it. The default
|
|
480
|
+
# argument pins the loop variable.
|
|
481
|
+
def _emit_created(project_id: str = pid) -> None:
|
|
482
|
+
broadcast_board_event(project_id, "project_created", {"id": project_id})
|
|
483
|
+
|
|
484
|
+
transaction.on_commit(_emit_created)
|
|
485
|
+
return program
|
|
486
|
+
|
|
487
|
+
# --- v2 date resolution + replay --------------------------------------
|
|
488
|
+
|
|
489
|
+
def _build_working_calendar(self, cal: Calendar | None) -> WorkingCalendar:
|
|
490
|
+
"""Calendar facts for weekend-snapping: bitmask + materialized exceptions."""
|
|
491
|
+
if cal is None:
|
|
492
|
+
return WorkingCalendar()
|
|
493
|
+
exc: set[date] = set()
|
|
494
|
+
for e in cal.exceptions.all()[:500]:
|
|
495
|
+
d = e.exc_start
|
|
496
|
+
while d <= e.exc_end and len(exc) < 5000:
|
|
497
|
+
exc.add(d)
|
|
498
|
+
d += timedelta(days=1)
|
|
499
|
+
try:
|
|
500
|
+
tz = ZoneInfo(cal.timezone) if cal.timezone else ZoneInfo("UTC")
|
|
501
|
+
except Exception:
|
|
502
|
+
tz = ZoneInfo("UTC")
|
|
503
|
+
return WorkingCalendar(working_days=cal.working_days, exception_dates=frozenset(exc), tz=tz)
|
|
504
|
+
|
|
505
|
+
def _wc(self, project_slug: str | None) -> WorkingCalendar:
|
|
506
|
+
if project_slug is None:
|
|
507
|
+
return WorkingCalendar()
|
|
508
|
+
return self.working_calendars.get(project_slug) or WorkingCalendar()
|
|
509
|
+
|
|
510
|
+
def _date(self, value: str, project_slug: str | None, *, snap: bool = True) -> date:
|
|
511
|
+
"""Resolve a required seed date. v1 ISO literals pass straight through.
|
|
512
|
+
|
|
513
|
+
``snap`` forward to the next working day is right for *plan* dates, which
|
|
514
|
+
cannot legitimately fall on a non-working day. Pass ``snap=False`` for a
|
|
515
|
+
date recording something that already happened: history is not confined
|
|
516
|
+
to working days, and snapping it forward misstates when it occurred.
|
|
517
|
+
"""
|
|
518
|
+
return resolve_date(value, anchor=self.anchor, calendar=self._wc(project_slug), snap=snap)
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def _blocker_reason(blocked: dict[str, Any] | None) -> str:
|
|
522
|
+
"""Reason text for a task's ``blocked`` cluster; ``""`` when not blocked."""
|
|
523
|
+
if not blocked:
|
|
524
|
+
return ""
|
|
525
|
+
return str(blocked.get("reason") or "").strip() or _PRIVATE_BLOCKER_REASON
|
|
526
|
+
|
|
527
|
+
def _date_opt(
|
|
528
|
+
self, value: str | None, project_slug: str | None, *, snap: bool = True
|
|
529
|
+
) -> date | None:
|
|
530
|
+
return self._date(value, project_slug, snap=snap) if value else None
|
|
531
|
+
|
|
532
|
+
def _creation_dt(self, when: date) -> datetime:
|
|
533
|
+
"""A backdated creation timestamp (UTC 09:00) for replay history rows."""
|
|
534
|
+
return datetime(when.year, when.month, when.day, 9, 0, tzinfo=ZoneInfo("UTC"))
|
|
535
|
+
|
|
536
|
+
def _index_earliest_task_events(self) -> dict[tuple[str, str], date]:
|
|
537
|
+
"""Earliest ``task:<project>:<wbs>`` beat in the authored events timeline.
|
|
538
|
+
|
|
539
|
+
Only the target parsing matters here (mirrors ``replay._resolve_task``);
|
|
540
|
+
the action doesn't — a comment, an assignment, a status move, all of them
|
|
541
|
+
are things that happened *to* the task, so all of them bound how early
|
|
542
|
+
its own creation row is allowed to be. A seed that references a task
|
|
543
|
+
before it declares a plan for it (comment predates any baseline) still
|
|
544
|
+
gets a consistent, in-order history: the creation row simply follows the
|
|
545
|
+
earliest thing anyone said about it.
|
|
546
|
+
"""
|
|
547
|
+
earliest: dict[tuple[str, str], date] = {}
|
|
548
|
+
for event in self.payload.get("events", []):
|
|
549
|
+
target = event.get("target", "")
|
|
550
|
+
if not target.startswith("task:"):
|
|
551
|
+
continue
|
|
552
|
+
_, _, ref = target.partition(":")
|
|
553
|
+
project_slug, _, wbs = ref.partition(":")
|
|
554
|
+
when = resolve_timestamp(event["at"], anchor=self.anchor).date()
|
|
555
|
+
key = (project_slug, wbs)
|
|
556
|
+
if key not in earliest or when < earliest[key]:
|
|
557
|
+
earliest[key] = when
|
|
558
|
+
return earliest
|
|
559
|
+
|
|
560
|
+
def _index_authored_status_targets(self) -> set[tuple[str, str]]:
|
|
561
|
+
"""Tasks the authored events timeline moves with a ``task.status`` beat.
|
|
562
|
+
|
|
563
|
+
Unlike :meth:`_index_earliest_task_events`, the action *is* the point
|
|
564
|
+
here: only a dated column move tells ``_build_task`` that a task's final
|
|
565
|
+
status is somewhere the timeline walks it to, rather than where it was
|
|
566
|
+
born. The literal action key is repeated rather than imported from
|
|
567
|
+
``replay._TASK_STATUS_ACTION`` — it is private to that module, and the
|
|
568
|
+
codebase already restates it per-module (see ``validation``'s
|
|
569
|
+
``_EVENT_TARGET_KIND``).
|
|
570
|
+
"""
|
|
571
|
+
targets: set[tuple[str, str]] = set()
|
|
572
|
+
for event in self.payload.get("events", []):
|
|
573
|
+
if event.get("action") != "task.status":
|
|
574
|
+
continue
|
|
575
|
+
target = event.get("target", "")
|
|
576
|
+
if not target.startswith("task:"):
|
|
577
|
+
continue
|
|
578
|
+
_, _, ref = target.partition(":")
|
|
579
|
+
project_slug, _, wbs = ref.partition(":")
|
|
580
|
+
targets.add((project_slug, wbs))
|
|
581
|
+
return targets
|
|
582
|
+
|
|
583
|
+
# --- batched inserts (ADR-0726 §8) -------------------------------------
|
|
584
|
+
|
|
585
|
+
@staticmethod
|
|
586
|
+
def _short_id_block(project_id: Any, count: int) -> list[str]:
|
|
587
|
+
"""Reserve ``count`` consecutive ``short_id`` values for one project.
|
|
588
|
+
|
|
589
|
+
``Task.save()`` / ``Sprint.save()`` allocate one at a time from
|
|
590
|
+
``Project.object_sequence`` (an ``UPDATE … + 1`` plus a read-back, so two
|
|
591
|
+
round-trips *per row*). Batched inserts bypass ``save()`` entirely, and a
|
|
592
|
+
row that skipped the allocation would carry ``short_id = ""`` — which the
|
|
593
|
+
``unique_(task|sprint)_short_id_per_project`` constraints turn into an
|
|
594
|
+
IntegrityError on the second row.
|
|
595
|
+
|
|
596
|
+
Advancing the counter by ``count`` in one statement reserves the whole
|
|
597
|
+
block atomically: the read-back is the last value, so the block is
|
|
598
|
+
``[new - count + 1, new]``. Two round-trips per batch instead of two per
|
|
599
|
+
row, and the values stay in the same hex format and the same shared
|
|
600
|
+
Task/Sprint/Risk sequence as the per-row path.
|
|
601
|
+
"""
|
|
602
|
+
if count <= 0:
|
|
603
|
+
return []
|
|
604
|
+
Project.objects.filter(pk=project_id).update(object_sequence=F("object_sequence") + count)
|
|
605
|
+
last: int = Project.objects.values_list("object_sequence", flat=True).get(pk=project_id)
|
|
606
|
+
return [f"{seq:08X}" for seq in range(last - count + 1, last + 1)]
|
|
607
|
+
|
|
608
|
+
def _bulk_insert(
|
|
609
|
+
self,
|
|
610
|
+
model: type[Any],
|
|
611
|
+
rows: list[Any],
|
|
612
|
+
*,
|
|
613
|
+
project_ids: list[Any] | None = None,
|
|
614
|
+
history_dates: list[date] | None = None,
|
|
615
|
+
) -> None:
|
|
616
|
+
"""Insert ``rows`` in one round-trip while preserving what ``save()`` does.
|
|
617
|
+
|
|
618
|
+
``bulk_create`` skips three things a ``VersionedModel.save()`` performs,
|
|
619
|
+
and every one of them fails *silently* rather than loudly:
|
|
620
|
+
|
|
621
|
+
1. ``server_version = 1`` on the INSERT branch. ``sync/conflict.py``
|
|
622
|
+
slices ``current - base_version`` history rows for the ADR-0217
|
|
623
|
+
field-level merge, so a row at version 0 is arithmetically broken.
|
|
624
|
+
2. The ``sync_seq`` delta cursor (ADR-0686). A row that never allocates
|
|
625
|
+
stays at 0, and ``sync_seq__gt=since`` never returns it — *not even on
|
|
626
|
+
a cold start with ``since=0``*. The rows would be permanently
|
|
627
|
+
invisible to every offline client. One value is drawn per project and
|
|
628
|
+
shared across the batch, which ``coalesce_sync_seq``'s contract
|
|
629
|
+
explicitly permits.
|
|
630
|
+
3. The ``simple_history`` row, written here by
|
|
631
|
+
``bulk_create_with_history`` for models that record history.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
model: the concrete model being inserted.
|
|
635
|
+
rows: instances to insert. Empty is a no-op.
|
|
636
|
+
project_ids: owning projects for the cursor draw. ``None`` means the
|
|
637
|
+
model is outside the sync union (no cursor, no history) and a
|
|
638
|
+
plain ``bulk_create`` is correct.
|
|
639
|
+
history_dates: parallel to ``rows``; the backdated ``history_date``
|
|
640
|
+
for each. Used only under v2 replay, where a task's creation row
|
|
641
|
+
is dated to when the entity came into being rather than to import
|
|
642
|
+
time. Rows are grouped by date so the number of INSERT batches is
|
|
643
|
+
bounded by the distinct dates (the sprint count), not the rows.
|
|
644
|
+
"""
|
|
645
|
+
if not rows:
|
|
646
|
+
return
|
|
647
|
+
if project_ids is None:
|
|
648
|
+
model.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
seq = allocate_for_projects(list(project_ids))
|
|
652
|
+
for row in rows:
|
|
653
|
+
row.server_version = 1
|
|
654
|
+
row.sync_seq = seq
|
|
655
|
+
|
|
656
|
+
# Keyed on the row's own pk, not on ``id(row)``: every model here has a UUID
|
|
657
|
+
# primary key assigned at construction, so this survives a caller that hands
|
|
658
|
+
# the insert a re-materialized instance rather than the identical object.
|
|
659
|
+
date_by_row: dict[Any, date] = (
|
|
660
|
+
{row.pk: when for row, when in zip(rows, history_dates, strict=True)}
|
|
661
|
+
if history_dates is not None
|
|
662
|
+
else {}
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def _insert(batch: Any) -> None:
|
|
666
|
+
"""The INSERT itself, over whatever slice of ``rows`` it is handed.
|
|
667
|
+
|
|
668
|
+
Taken as a closure so the Task path can wrap it (see below) without the
|
|
669
|
+
declaration pass having to know which of the three insert shapes applies.
|
|
670
|
+
"""
|
|
671
|
+
batch = list(batch)
|
|
672
|
+
if not hasattr(model, "history"):
|
|
673
|
+
model.objects.bulk_create(batch, batch_size=_BULK_BATCH_SIZE)
|
|
674
|
+
return
|
|
675
|
+
if not date_by_row:
|
|
676
|
+
bulk_create_with_history(
|
|
677
|
+
batch, model, batch_size=_BULK_BATCH_SIZE, default_user=self.owner
|
|
678
|
+
)
|
|
679
|
+
return
|
|
680
|
+
by_date: dict[date, list[Any]] = defaultdict(list)
|
|
681
|
+
for row in batch:
|
|
682
|
+
by_date[date_by_row[row.pk]].append(row)
|
|
683
|
+
for when, group in by_date.items():
|
|
684
|
+
bulk_create_with_history(
|
|
685
|
+
group,
|
|
686
|
+
model,
|
|
687
|
+
batch_size=_BULK_BATCH_SIZE,
|
|
688
|
+
default_user=self.owner,
|
|
689
|
+
default_date=self._creation_dt(when),
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
if model is Task:
|
|
693
|
+
# A seed pack has phases, and `bulk_create` reaches none of the
|
|
694
|
+
# declaration that `Task.save()` paths run — so without this a seeded
|
|
695
|
+
# phase lands `structure_role='work'` with children and the Board
|
|
696
|
+
# renders it as a card (#3030). Routed through the same funnel as the
|
|
697
|
+
# importers rather than re-derived here.
|
|
698
|
+
bulk_create_tasks(rows, insert=_insert)
|
|
699
|
+
return
|
|
700
|
+
_insert(rows)
|
|
701
|
+
|
|
702
|
+
def _save_new(self, instance: Any, created_on: date) -> Any:
|
|
703
|
+
"""Insert ``instance``, backdating its creation history row under replay.
|
|
704
|
+
|
|
705
|
+
Under v2 replay the creation row is dated to when the entity came into
|
|
706
|
+
being (its window/sprint start), not import time, so the History tab and
|
|
707
|
+
activity timeline read chronologically. v1 import saves normally.
|
|
708
|
+
"""
|
|
709
|
+
if self.replay:
|
|
710
|
+
instance._history_date = self._creation_dt(created_on)
|
|
711
|
+
instance._history_user = self.owner
|
|
712
|
+
instance.save()
|
|
713
|
+
return instance
|
|
714
|
+
|
|
715
|
+
def _replay_context(self) -> ReplayContext:
|
|
716
|
+
lead_project = next(iter(self.projects.values()), None)
|
|
717
|
+
try:
|
|
718
|
+
tz = ZoneInfo(getattr(lead_project, "timezone", "") or "UTC")
|
|
719
|
+
except Exception:
|
|
720
|
+
tz = ZoneInfo("UTC")
|
|
721
|
+
return ReplayContext(
|
|
722
|
+
anchor=self.anchor,
|
|
723
|
+
program_code=self.payload["program"]["slug"],
|
|
724
|
+
default_actor=self.owner,
|
|
725
|
+
users=self.users,
|
|
726
|
+
tasks=self.tasks,
|
|
727
|
+
sprints=self.sprints,
|
|
728
|
+
projects=self.projects,
|
|
729
|
+
project_calendars=self.working_calendars,
|
|
730
|
+
risks=self.risks_by_slug,
|
|
731
|
+
final_status=self.final_status,
|
|
732
|
+
final_sprint=self.final_sprint,
|
|
733
|
+
final_progress=self.final_progress,
|
|
734
|
+
tz=tz,
|
|
735
|
+
criteria=self.criteria,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
# --- idempotency -------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
def _replace_existing(self) -> None:
|
|
741
|
+
"""Tear down a prior import the caller owns that holds this seed's slug.
|
|
742
|
+
|
|
743
|
+
Re-import rebuilds the *caller's own* program with this slug (keyed on
|
|
744
|
+
``Program.code``, which carries it). Three guards, in order of how badly
|
|
745
|
+
each one's absence would hurt:
|
|
746
|
+
|
|
747
|
+
**Ownership (#994).** Candidates come from
|
|
748
|
+
:func:`~trueppm_api.apps.projects.seed.replace.resolve_replace_candidates`,
|
|
749
|
+
which restricts them to programs the importing ``owner`` holds a live
|
|
750
|
+
OWNER ``ProgramMembership`` on. ``Program.code`` is user-assigned and
|
|
751
|
+
non-unique, so collisions between strangers are realistic and enumerable;
|
|
752
|
+
without this scope any authenticated user could destroy a victim's
|
|
753
|
+
program and every child project/task/sprint/risk/baseline by crafting a
|
|
754
|
+
seed whose ``slug`` matches the victim's code. ``select_for_update``
|
|
755
|
+
locks each candidate so a concurrent member-add or project-assign cannot
|
|
756
|
+
resurrect a ``PROTECT``-ed reference mid-teardown.
|
|
757
|
+
|
|
758
|
+
**Consent (#2581, ADR-0726).** Even against the caller's own data the
|
|
759
|
+
teardown is refused unless ``replace`` was explicitly given, and
|
|
760
|
+
``expected_program_id`` — when supplied — must name the program that
|
|
761
|
+
would actually go. Both refusals raise *before* the first write.
|
|
762
|
+
|
|
763
|
+
**Sample scoping (#2476).** On the demo path (``is_sample``) a program
|
|
764
|
+
containing any real (non-sample) project is never replaced, so a sample
|
|
765
|
+
reload cannot purge real work even within the caller's own programs.
|
|
766
|
+
|
|
767
|
+
Non-sample programs are **soft**-deleted: the subtree lands in Trash,
|
|
768
|
+
offline clients receive tombstones, and each removed project emits a
|
|
769
|
+
``project_deleted`` broadcast. Sample programs keep the hard delete —
|
|
770
|
+
disposable demo data is what it is for — but route through
|
|
771
|
+
``hard_delete_program``, which resolves the ``PROTECT``-ing set from
|
|
772
|
+
``_meta`` rather than the hand-written list that rotted in #2364.
|
|
773
|
+
"""
|
|
774
|
+
from trueppm_api.apps.access.services import hard_delete_program
|
|
775
|
+
from trueppm_api.apps.projects.services import (
|
|
776
|
+
release_keys_for_replace,
|
|
777
|
+
soft_delete_program_subtree,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
slug = self.payload["program"]["slug"]
|
|
781
|
+
candidates = resolve_replace_candidates(self.owner, slug, lock=True)
|
|
782
|
+
if self.is_sample:
|
|
783
|
+
# A mixed program (real projects under a sample slug) is never torn
|
|
784
|
+
# down. Filtering here rather than inside the loop keeps the consent
|
|
785
|
+
# check below from refusing on a candidate we would have skipped.
|
|
786
|
+
candidates = [
|
|
787
|
+
prog
|
|
788
|
+
for prog in candidates
|
|
789
|
+
if not Project.objects.filter(
|
|
790
|
+
program=prog, is_sample=False, is_deleted=False
|
|
791
|
+
).exists()
|
|
792
|
+
]
|
|
793
|
+
if not candidates:
|
|
794
|
+
return
|
|
795
|
+
|
|
796
|
+
# Narrow to the named program before consenting, so the set the caller
|
|
797
|
+
# authorized and the set actually torn down are the same one.
|
|
798
|
+
# ``Program.code`` is non-unique by design, so a caller can legitimately
|
|
799
|
+
# own two programs under this slug; ``SeedReplaceRequired`` can only
|
|
800
|
+
# carry one, and destroying the rest would be exactly the unconsented
|
|
801
|
+
# deletion this whole gate exists to stop.
|
|
802
|
+
if self.expected_program_id is not None:
|
|
803
|
+
named = [p for p in candidates if str(p.pk) == str(self.expected_program_id)]
|
|
804
|
+
if not named:
|
|
805
|
+
raise SeedReplaceMismatch(
|
|
806
|
+
"expected_program_id does not name the program that would be replaced."
|
|
807
|
+
)
|
|
808
|
+
candidates = named
|
|
809
|
+
|
|
810
|
+
if not self.replace:
|
|
811
|
+
raise SeedReplaceRequired(candidates[0])
|
|
812
|
+
if len(candidates) > 1:
|
|
813
|
+
raise SeedReplaceAmbiguous(
|
|
814
|
+
f"You own {len(candidates)} live programs using the code "
|
|
815
|
+
f"{slug!r}. Pass expected_program_id to name the one to replace."
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
target = candidates[0]
|
|
819
|
+
self.replaced_program_id = str(target.pk)
|
|
820
|
+
# The program shell is unrecoverable on both paths, so its keys move to the
|
|
821
|
+
# rebuild — the same program to its owner, with its slug and old links
|
|
822
|
+
# intact (ADR-1237). Released before the teardown locks and saves the row.
|
|
823
|
+
self.released_program_keys = release_keys_for_replace(target)
|
|
824
|
+
# Each replaced project's keys move to the rebuilt project of the same
|
|
825
|
+
# name, so a re-import reproduces the keys (and the #616 round trip stays
|
|
826
|
+
# byte-identical) instead of minting PLAT2. On the soft path the trashed
|
|
827
|
+
# original is left without a key; restoring it derives a fresh one
|
|
828
|
+
# (``ProjectViewSet.restore``), so a restore still never collides.
|
|
829
|
+
for doomed_project in Project.objects.filter(program=target, is_deleted=False):
|
|
830
|
+
self.released_project_keys.setdefault(doomed_project.name, []).extend(
|
|
831
|
+
release_keys_for_replace(doomed_project)
|
|
832
|
+
)
|
|
833
|
+
if self.is_sample:
|
|
834
|
+
# A hard delete leaves no tombstone, so the project_hard_deleted
|
|
835
|
+
# broadcast is the only signal a client holding these rows will ever
|
|
836
|
+
# get (the #2581 defect, on the branch ADR-0726 deliberately left
|
|
837
|
+
# hard). broadcast=True makes hard_delete_program schedule one per
|
|
838
|
+
# deleted project (#4374) — mirrors ``remove_sample``, which routes
|
|
839
|
+
# through the same helper.
|
|
840
|
+
program_id = str(target.pk)
|
|
841
|
+
hard_delete_program(target.pk, broadcast=True)
|
|
842
|
+
transaction.on_commit(
|
|
843
|
+
lambda: broadcast_board_event(program_id, "program_deleted", {"id": program_id})
|
|
844
|
+
)
|
|
845
|
+
else:
|
|
846
|
+
soft_delete_program_subtree(target, actor=self.owner, reason="seed_replace")
|
|
847
|
+
|
|
848
|
+
def _release_trashed_project_keys(self) -> None:
|
|
849
|
+
"""Take back the keys of the projects the async request trashed (#4150).
|
|
850
|
+
|
|
851
|
+
The async replace runs in the request, but the rebuild runs here, so the
|
|
852
|
+
request leaves the trashed originals holding their keys and hands over
|
|
853
|
+
only their ids. Releasing them now, inside the build's transaction, is
|
|
854
|
+
what makes the hand-over safe: a failed build rolls the release back and
|
|
855
|
+
every original keeps its key, and a duplicate delivery never gets this
|
|
856
|
+
far (the job claim). Each released set is keyed by name, like the sync
|
|
857
|
+
path, and is adopted by the rebuilt project of the same name.
|
|
858
|
+
|
|
859
|
+
Only projects still in Trash and still detached qualify — one the owner
|
|
860
|
+
restored or re-parented in the meantime is live again and keeps its key.
|
|
861
|
+
A trashed project whose name the document no longer carries keeps its
|
|
862
|
+
key too, since nothing would adopt it.
|
|
863
|
+
"""
|
|
864
|
+
from trueppm_api.apps.projects.services import release_keys_for_replace
|
|
865
|
+
|
|
866
|
+
if not self.adopt_keys_from:
|
|
867
|
+
return
|
|
868
|
+
names = {p["name"] for p in self.payload["projects"]}
|
|
869
|
+
for original in (
|
|
870
|
+
Project.objects.select_for_update()
|
|
871
|
+
.filter(
|
|
872
|
+
pk__in=self.adopt_keys_from,
|
|
873
|
+
is_deleted=True,
|
|
874
|
+
program__isnull=True,
|
|
875
|
+
name__in=names,
|
|
876
|
+
)
|
|
877
|
+
.order_by("pk")
|
|
878
|
+
):
|
|
879
|
+
# Two trashed projects sharing a name would hand one rebuild two
|
|
880
|
+
# current keys; the first keeps the hand-over, the other its key.
|
|
881
|
+
if original.name in self.released_project_keys:
|
|
882
|
+
continue
|
|
883
|
+
self.released_project_keys[original.name] = release_keys_for_replace(original)
|
|
884
|
+
|
|
885
|
+
# --- top-level entities ------------------------------------------------
|
|
886
|
+
|
|
887
|
+
def _resolve_accounts(self) -> None:
|
|
888
|
+
for account in self.payload.get("accounts", []):
|
|
889
|
+
username = account["username"]
|
|
890
|
+
user = User.objects.filter(username=username).first()
|
|
891
|
+
if user is None and self.create_users:
|
|
892
|
+
# ``password`` is only non-None on the server-curated sample path
|
|
893
|
+
# when the operator opted into loginable personas via
|
|
894
|
+
# ``--with-personas`` (#1760); ``None`` yields an unusable password
|
|
895
|
+
# (create_user's default), so no dormant weak login is ever minted
|
|
896
|
+
# implicitly.
|
|
897
|
+
user = User.objects.create_user(
|
|
898
|
+
username=username,
|
|
899
|
+
email=account.get("email", ""),
|
|
900
|
+
first_name=account.get("display_name", "").split(" ")[0],
|
|
901
|
+
password=self.persona_password if self.is_sample else None,
|
|
902
|
+
)
|
|
903
|
+
elif user is not None:
|
|
904
|
+
self._enable_existing_persona_login(user)
|
|
905
|
+
# #1057: on the generic import path (create_users=False, the REST
|
|
906
|
+
# default) a seed's accounts[].username is attacker-controlled and may
|
|
907
|
+
# collide with a *pre-existing* real user. Binding that user here would
|
|
908
|
+
# let a crafted seed pull a known victim into the importer's program —
|
|
909
|
+
# as a ProgramMembership (_grant_program_memberships), the program lead,
|
|
910
|
+
# a task assignee, or a resource's user FK. Resolve such accounts to
|
|
911
|
+
# None so the generic path never associates a real account it did not
|
|
912
|
+
# create. The owner is exempt (their own account), and the server-
|
|
913
|
+
# curated sample/demo path (is_sample) binds personas freely.
|
|
914
|
+
if (
|
|
915
|
+
user is not None
|
|
916
|
+
and not self.create_users
|
|
917
|
+
and not self.is_sample
|
|
918
|
+
and user != self.owner
|
|
919
|
+
):
|
|
920
|
+
user = None
|
|
921
|
+
self.users[account["slug"]] = user
|
|
922
|
+
|
|
923
|
+
def _enable_existing_persona_login(self, user: Any) -> None:
|
|
924
|
+
"""Give a *pre-existing* persona account the requested demo password (#3484).
|
|
925
|
+
|
|
926
|
+
Loading a sample is idempotent, so the second run is the common one: the
|
|
927
|
+
persona rows already exist from an earlier plain ``load_sample_project``
|
|
928
|
+
(or from the demo compose stack), and only the *creation* branch above ever
|
|
929
|
+
applied ``persona_password``. ``--with-personas`` therefore printed
|
|
930
|
+
"Persona logins enabled" over fifteen accounts whose ``has_usable_password()``
|
|
931
|
+
was still False and whose ``POST /auth/token/`` still 401'd — the command
|
|
932
|
+
asserted a state it had not produced.
|
|
933
|
+
|
|
934
|
+
Two conditions bound this, and they are what keep it out of the #1057
|
|
935
|
+
account-hijack case:
|
|
936
|
+
|
|
937
|
+
* ``is_sample`` — the usernames come from a **server-curated** bundled
|
|
938
|
+
fixture, not from a caller-supplied seed. ``persona_password`` is only
|
|
939
|
+
ever non-None on that path (the REST ``load-sample`` action never passes
|
|
940
|
+
it), so no caller can steer which username gets a password.
|
|
941
|
+
* the account must have **no usable password of its own**. That is the
|
|
942
|
+
discriminator for "the importer minted this row", since an interactively
|
|
943
|
+
registered account always carries one. Nothing a real user set is ever
|
|
944
|
+
overwritten, which is the invariant
|
|
945
|
+
``test_persona_password_never_repasswords_existing_user`` pins.
|
|
946
|
+
|
|
947
|
+
Privileged accounts are refused outright even when unusable: handing a
|
|
948
|
+
known password to a staff/superuser row would be a full takeover, and the
|
|
949
|
+
sample's OWNER persona can legitimately be the program owner on a stack
|
|
950
|
+
with no superuser (``_resolve_owner``'s third fallback).
|
|
951
|
+
|
|
952
|
+
The residual the two conditions do not cover: an **SSO-provisioned** account
|
|
953
|
+
also carries an unusable password (``sso.services`` calls
|
|
954
|
+
``set_unusable_password`` on JIT signup), so one holding a username that
|
|
955
|
+
collides with a sample persona slug would be given a local password here.
|
|
956
|
+
It is accepted rather than guarded, because the preconditions are narrow —
|
|
957
|
+
an operator explicitly running ``--with-personas``, on an SSO instance,
|
|
958
|
+
where a federated user holds a ``<sample-key>-<name>`` username — and
|
|
959
|
+
because the command now names every account it enabled, so the operator
|
|
960
|
+
sees it rather than being told fifteen logins work.
|
|
961
|
+
"""
|
|
962
|
+
if not (self.is_sample and self.create_users and self.persona_password):
|
|
963
|
+
return
|
|
964
|
+
if user.is_staff or user.is_superuser or user.has_usable_password():
|
|
965
|
+
return
|
|
966
|
+
# Seeded demo persona, not an interactive signup, so password validators
|
|
967
|
+
# do not apply — the value is already gated behind DEBUG/env by the caller.
|
|
968
|
+
# nosemgrep: unvalidated-password
|
|
969
|
+
user.set_password(self.persona_password)
|
|
970
|
+
user.save(update_fields=["password"])
|
|
971
|
+
|
|
972
|
+
def _resolve_calendars(self) -> None:
|
|
973
|
+
for cal in self.payload.get("calendars", []):
|
|
974
|
+
defaults = {
|
|
975
|
+
"working_days": cal.get("working_days", 31),
|
|
976
|
+
"hours_per_day": cal.get("hours_per_day", 8.0),
|
|
977
|
+
"timezone": cal.get("timezone", "UTC"),
|
|
978
|
+
}
|
|
979
|
+
if self.is_sample:
|
|
980
|
+
# Calendar.name carries no uniqueness constraint, so a plain
|
|
981
|
+
# get_or_create raises MultipleObjectsReturned (unhandled 500) when
|
|
982
|
+
# the shared catalog already holds two calendars of this name — the
|
|
983
|
+
# #2267 bug class. Reuse the first match and only create when none
|
|
984
|
+
# exists. Server-curated bundled samples are the one caller allowed
|
|
985
|
+
# to bind the shared catalog by name (see #4369 below).
|
|
986
|
+
obj = Calendar.objects.filter(
|
|
987
|
+
name=cal["name"], is_deleted=False
|
|
988
|
+
).first() or Calendar.objects.create(name=cal["name"], **defaults)
|
|
989
|
+
else:
|
|
990
|
+
# Generic import (#4369): never resolve a calendar against the
|
|
991
|
+
# shared/global catalog by name. Calendar.name has no uniqueness
|
|
992
|
+
# constraint and a Calendar is reachable by any authenticated
|
|
993
|
+
# caller's project (CalendarViewSet's queryset is unscoped) — so
|
|
994
|
+
# matching by name would let an attacker-crafted seed bind a real
|
|
995
|
+
# calendar (e.g. the workspace default) and have
|
|
996
|
+
# ``_resolve_calendar_exceptions`` inject non-working exceptions
|
|
997
|
+
# into it, corrupting every project's CPM dates on its next
|
|
998
|
+
# recalc. Mirrors the #1004 fix already shipped for resources:
|
|
999
|
+
# always create a fresh row on the generic path.
|
|
1000
|
+
obj = Calendar.objects.create(name=cal["name"], **defaults)
|
|
1001
|
+
self.calendars[cal["slug"]] = obj
|
|
1002
|
+
self._resolve_calendar_exceptions(obj, cal.get("exceptions", []))
|
|
1003
|
+
|
|
1004
|
+
def _resolve_calendar_exceptions(
|
|
1005
|
+
self, calendar: Calendar, exceptions: list[dict[str, Any]]
|
|
1006
|
+
) -> None:
|
|
1007
|
+
"""Materialize a calendar's non-working ranges (holidays, PTO) — #376.
|
|
1008
|
+
|
|
1009
|
+
Exceptions resolve against the import anchor (relative dates land exactly —
|
|
1010
|
+
a holiday is a fact, never weekend-snapped) and are created idempotently on
|
|
1011
|
+
``(calendar, exc_start, exc_end)``. Calendars are a shared global catalog
|
|
1012
|
+
that ``_replace_existing`` does not wipe on a sample reload, so get_or_create
|
|
1013
|
+
keeps a same-day reload from accumulating duplicate PTO rows (a reload on a
|
|
1014
|
+
*different* day is the stale-anchor case below). Materializing them here
|
|
1015
|
+
(before the per-project structure pass) means ``_build_working_calendar``
|
|
1016
|
+
picks them up, so a PTO range also removes those days from date snapping —
|
|
1017
|
+
one source of non-working truth (ADR-0211).
|
|
1018
|
+
|
|
1019
|
+
**Stale anchors are reconciled away (#4339).** The idempotency key is the
|
|
1020
|
+
*resolved* range, and a relative range resolves differently on every import
|
|
1021
|
+
day, so a reload on a later day used to add a second "Clara — PTO" row
|
|
1022
|
+
beside the first instead of matching it. Because the calendar outlives
|
|
1023
|
+
the program teardown, a daily ``demo-reset`` grew each authored range into
|
|
1024
|
+
a wall of stale non-working days — dozens of rows a few days apart — and
|
|
1025
|
+
``snap_forward`` pushed a sprint's start *and* finish across that wall onto
|
|
1026
|
+
the same working day, tripping ``sprint_finish_after_start``. The CPM run
|
|
1027
|
+
that follows reads the same rows, so every task date drifted too. A row on
|
|
1028
|
+
this calendar carrying a description this payload authors is therefore the
|
|
1029
|
+
same authored exception at an older anchor, and is replaced rather than
|
|
1030
|
+
kept. Only a row matching an authored exception on description *and* span
|
|
1031
|
+
length (an anchor shift moves a range, it never resizes it) counts as
|
|
1032
|
+
stale. Rows the payload does not author (or that a later payload dropped)
|
|
1033
|
+
are left alone.
|
|
1034
|
+
|
|
1035
|
+
The cleanup deletes rows, so it is fenced twice, because the catalog is
|
|
1036
|
+
matched by calendar *name* and a seed can therefore land on a real
|
|
1037
|
+
calendar that merely shares one:
|
|
1038
|
+
|
|
1039
|
+
- **Bundled samples only** (``is_sample``). A sample's calendar names and
|
|
1040
|
+
descriptions are server-curated; a generic ``import_seed`` payload is
|
|
1041
|
+
caller-authored. Before #4339 a seed could only ever *add* rows to a
|
|
1042
|
+
shared calendar; since #4369, ``_resolve_calendars`` never resolves a
|
|
1043
|
+
calendar by name on the generic path at all — the ``calendar`` this
|
|
1044
|
+
method receives there is always one this import itself just created, so
|
|
1045
|
+
a caller-authored payload can no longer add rows to a shared calendar
|
|
1046
|
+
either. Letting any authenticated caller delete rows by naming a real
|
|
1047
|
+
calendar would widen what the looser import gate can destroy, which is
|
|
1048
|
+
why this cleanup stays fenced to ``is_sample`` even though the sibling
|
|
1049
|
+
risk it once guarded against on the generic path is now closed upstream.
|
|
1050
|
+
- **Never a calendar in real use** (:meth:`_calendar_in_real_use`): the
|
|
1051
|
+
workspace default, any program's, any non-sample project's primary
|
|
1052
|
+
calendar or calendar layer, or one a resource with real assignments
|
|
1053
|
+
works on. Those exceptions are someone's actual holidays — the same
|
|
1054
|
+
rule ``reanchor`` follows for not shifting a shared calendar's
|
|
1055
|
+
exceptions. A sample landing there only adds rows, as it always did.
|
|
1056
|
+
|
|
1057
|
+
What remains unprotected is a same-named calendar nothing real uses (see
|
|
1058
|
+
:meth:`_calendar_in_real_use` for the one gap in "uses"). There the guard
|
|
1059
|
+
is the row match itself — same description *and* same span as an
|
|
1060
|
+
exception this pack authors — not the calendar's name; a pack-specific
|
|
1061
|
+
calendar name only makes the collision less likely.
|
|
1062
|
+
"""
|
|
1063
|
+
authored = [
|
|
1064
|
+
(
|
|
1065
|
+
resolve_date(exc["exc_start"], anchor=self.anchor, snap=False),
|
|
1066
|
+
resolve_date(exc["exc_end"], anchor=self.anchor, snap=False),
|
|
1067
|
+
exc.get("description", ""),
|
|
1068
|
+
)
|
|
1069
|
+
for exc in exceptions
|
|
1070
|
+
]
|
|
1071
|
+
if self.is_sample:
|
|
1072
|
+
# Lock the calendar row before deciding. Any concurrent write that points
|
|
1073
|
+
# a project, layer, program, resource or the workspace at this calendar
|
|
1074
|
+
# takes a FOR KEY SHARE lock on it, which conflicts with FOR UPDATE — so
|
|
1075
|
+
# nothing can start using the calendar between the check and the delete.
|
|
1076
|
+
Calendar.objects.select_for_update().filter(pk=calendar.pk).first()
|
|
1077
|
+
if not self._calendar_in_real_use(calendar):
|
|
1078
|
+
self._delete_stale_exceptions(calendar, authored)
|
|
1079
|
+
for start, end, description in authored:
|
|
1080
|
+
# (calendar, exc_start, exc_end) is the intended idempotency key but has
|
|
1081
|
+
# no DB uniqueness constraint, so a plain get_or_create raises
|
|
1082
|
+
# MultipleObjectsReturned once two identical ranges exist (#2267 class).
|
|
1083
|
+
# Reuse the first match; only create when the range is absent.
|
|
1084
|
+
exists = CalendarException.objects.filter(
|
|
1085
|
+
calendar=calendar, exc_start=start, exc_end=end
|
|
1086
|
+
).exists()
|
|
1087
|
+
if not exists:
|
|
1088
|
+
CalendarException.objects.create(
|
|
1089
|
+
calendar=calendar,
|
|
1090
|
+
exc_start=start,
|
|
1091
|
+
exc_end=end,
|
|
1092
|
+
description=description,
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
@staticmethod
|
|
1096
|
+
def _calendar_in_real_use(calendar: Calendar) -> bool:
|
|
1097
|
+
"""Whether anything other than sample data schedules against ``calendar``.
|
|
1098
|
+
|
|
1099
|
+
Runs after ``_replace_existing``, so the sample being reloaded has already
|
|
1100
|
+
been torn down; another user's live *sample* program is not real use (it
|
|
1101
|
+
is the same disposable data, and benefits from the cleanup too). A
|
|
1102
|
+
soft-deleted real project still counts — it can be restored.
|
|
1103
|
+
|
|
1104
|
+
Every reverse relation to ``Calendar`` is consulted except one, on
|
|
1105
|
+
purpose: a ``Resource`` on this calendar with no assignment to real work
|
|
1106
|
+
does not count, so a real person whose calendar shares a sample
|
|
1107
|
+
calendar's name, and who has not been assigned anything yet, is not
|
|
1108
|
+
protected. Counting every resource would disable the cleanup outright —
|
|
1109
|
+
the sample's own persona resources outlive the teardown on these same
|
|
1110
|
+
calendars. ``test_calendar_reverse_relations_are_all_considered`` pins
|
|
1111
|
+
the relation set so a new FK to ``Calendar`` has to be decided here.
|
|
1112
|
+
"""
|
|
1113
|
+
from trueppm_api.apps.workspace.models import Workspace
|
|
1114
|
+
|
|
1115
|
+
return (
|
|
1116
|
+
Workspace.objects.filter(calendar=calendar).exists()
|
|
1117
|
+
or calendar.programs.exists()
|
|
1118
|
+
or calendar.projects.filter(is_sample=False).exists()
|
|
1119
|
+
or calendar.applied_to_layers.filter(project__is_sample=False).exists()
|
|
1120
|
+
or TaskResource.objects.filter(
|
|
1121
|
+
resource__calendar=calendar, task__project__is_sample=False
|
|
1122
|
+
).exists()
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
@staticmethod
|
|
1126
|
+
def _delete_stale_exceptions(
|
|
1127
|
+
calendar: Calendar, authored: list[tuple[date, date, str]]
|
|
1128
|
+
) -> None:
|
|
1129
|
+
"""Delete this seed's own exceptions left behind by an older anchor."""
|
|
1130
|
+
current = set(authored)
|
|
1131
|
+
authored_shapes = {(desc, end - start) for start, end, desc in authored if desc}
|
|
1132
|
+
stale_ids = [
|
|
1133
|
+
row.pk
|
|
1134
|
+
for row in CalendarException.objects.filter(
|
|
1135
|
+
calendar=calendar, description__in={desc for desc, _ in authored_shapes}
|
|
1136
|
+
)
|
|
1137
|
+
if (row.description, row.exc_end - row.exc_start) in authored_shapes
|
|
1138
|
+
and (row.exc_start, row.exc_end, row.description) not in current
|
|
1139
|
+
]
|
|
1140
|
+
if stale_ids:
|
|
1141
|
+
CalendarException.objects.filter(pk__in=stale_ids).delete()
|
|
1142
|
+
|
|
1143
|
+
def _resolve_resources(self) -> None:
|
|
1144
|
+
for res in self.payload.get("resources", []):
|
|
1145
|
+
calendar = self.calendars.get(res["calendar"]) if res.get("calendar") else None
|
|
1146
|
+
# account_user is already None on the generic path for any account that
|
|
1147
|
+
# resolved to a pre-existing real user — _resolve_accounts drops those
|
|
1148
|
+
# (#1057), so the resource's user FK cannot bind a victim here.
|
|
1149
|
+
account_user = self.users.get(res["account"]) if res.get("account") else None
|
|
1150
|
+
defaults = {
|
|
1151
|
+
"name": res["name"],
|
|
1152
|
+
"email": res.get("email", ""),
|
|
1153
|
+
"job_role": res.get("job_role", ""),
|
|
1154
|
+
"max_units": res.get("max_units", 1.0),
|
|
1155
|
+
"calendar": calendar,
|
|
1156
|
+
"user": account_user,
|
|
1157
|
+
}
|
|
1158
|
+
if self.is_sample:
|
|
1159
|
+
# Demo path: resources are a global catalog and have no slug
|
|
1160
|
+
# column; reuse the shared persona rows by email (else name) so a
|
|
1161
|
+
# sample reload does not accumulate duplicate demo people.
|
|
1162
|
+
#
|
|
1163
|
+
# Neither ``email`` nor ``name`` is unique, and the generic branch
|
|
1164
|
+
# below creates fresh rows unconditionally — so the catalog can
|
|
1165
|
+
# legitimately already hold two rows matching this lookup. A plain
|
|
1166
|
+
# ``get_or_create`` would then raise ``MultipleObjectsReturned`` (an
|
|
1167
|
+
# unhandled 500 the nightly fuzzer hit on a generic-import-then-
|
|
1168
|
+
# sample-load sequence, #2267). Reuse the first existing match and
|
|
1169
|
+
# only create when none exists, which keeps the dedup intent while
|
|
1170
|
+
# tolerating a pre-existing duplicate.
|
|
1171
|
+
lookup = {"email": res["email"]} if res.get("email") else {"name": res["name"]}
|
|
1172
|
+
obj = Resource.objects.filter(
|
|
1173
|
+
is_deleted=False, **lookup
|
|
1174
|
+
).first() or Resource.objects.create(**defaults)
|
|
1175
|
+
else:
|
|
1176
|
+
# Generic import (#1004): never match a live global resource by
|
|
1177
|
+
# email. Doing so would bind a pre-existing resource — and the
|
|
1178
|
+
# real ``user`` FK it may carry — into the importer's project via
|
|
1179
|
+
# ``_assign_resources``. Create a fresh row so an attacker-crafted
|
|
1180
|
+
# seed cannot pull a real user's resource into their program.
|
|
1181
|
+
obj = Resource.objects.create(**defaults)
|
|
1182
|
+
self.resources[res["slug"]] = obj
|
|
1183
|
+
account = res.get("account")
|
|
1184
|
+
if account:
|
|
1185
|
+
self.resources_by_account[account] = obj
|
|
1186
|
+
self._assign_resource_skills(obj, res.get("skills", []))
|
|
1187
|
+
|
|
1188
|
+
def _assign_resource_skills(self, resource: Resource, skills: list[dict[str, Any]]) -> None:
|
|
1189
|
+
"""Attach a resource's skills from the workspace catalog (#3498).
|
|
1190
|
+
|
|
1191
|
+
``Skill`` is de-duplicated on the case-folded name — the normalization
|
|
1192
|
+
``SkillSerializer.create`` applies — so "Python" authored by two samples lands
|
|
1193
|
+
on one catalog row. On the sample path a resource is reused across reloads,
|
|
1194
|
+
so an existing ``ResourceSkill`` is updated in place rather than duplicated.
|
|
1195
|
+
"""
|
|
1196
|
+
for entry in skills:
|
|
1197
|
+
name = entry["name"].strip()
|
|
1198
|
+
skill, _ = Skill.objects.get_or_create(
|
|
1199
|
+
normalized_name=name.casefold(),
|
|
1200
|
+
defaults={"name": name, "category": entry.get("category", "")},
|
|
1201
|
+
)
|
|
1202
|
+
proficiency = _PROFICIENCY_BY_NAME[entry.get("proficiency", "intermediate")]
|
|
1203
|
+
row = ResourceSkill.objects.filter(
|
|
1204
|
+
resource=resource, skill=skill, is_deleted=False
|
|
1205
|
+
).first()
|
|
1206
|
+
if row is None:
|
|
1207
|
+
ResourceSkill.objects.create(
|
|
1208
|
+
resource=resource, skill=skill, proficiency=proficiency
|
|
1209
|
+
)
|
|
1210
|
+
elif row.proficiency != proficiency:
|
|
1211
|
+
row.proficiency = proficiency
|
|
1212
|
+
row.save(update_fields=["proficiency"])
|
|
1213
|
+
|
|
1214
|
+
def _create_program(self) -> Program:
|
|
1215
|
+
"""Mint the program, or adopt the shell the caller pre-created (ADR-0726 §4).
|
|
1216
|
+
|
|
1217
|
+
The async path creates the shell inside the request — so the replace it
|
|
1218
|
+
may have performed stays in the request the operator authorized, and the
|
|
1219
|
+
202 can name a program to land on — then hands it here for the worker to
|
|
1220
|
+
fill. Either way the seed's display fields and memberships are applied
|
|
1221
|
+
identically, so the two paths cannot produce different programs.
|
|
1222
|
+
"""
|
|
1223
|
+
data = self.payload["program"]
|
|
1224
|
+
if self.target_program is not None:
|
|
1225
|
+
program = self.target_program
|
|
1226
|
+
# Re-apply the document's own fields over the shell. The view builds
|
|
1227
|
+
# the shell from this same payload, so this is normally a no-op — but
|
|
1228
|
+
# asserting it here means the adopted program is defined by the seed,
|
|
1229
|
+
# not by whatever the view happened to pass.
|
|
1230
|
+
program.name = data["name"]
|
|
1231
|
+
program.description = data.get("description", "")
|
|
1232
|
+
program.methodology = data["methodology"]
|
|
1233
|
+
else:
|
|
1234
|
+
program = create_program(
|
|
1235
|
+
name=data["name"],
|
|
1236
|
+
description=data.get("description", ""),
|
|
1237
|
+
methodology=data["methodology"],
|
|
1238
|
+
created_by=self.owner,
|
|
1239
|
+
# The importer assigns the key itself, below.
|
|
1240
|
+
code=None,
|
|
1241
|
+
)
|
|
1242
|
+
# Persist the slug as the natural key + carry display fields.
|
|
1243
|
+
self._assign_program_key(program, data["slug"])
|
|
1244
|
+
if data.get("color"):
|
|
1245
|
+
program.color = data["color"]
|
|
1246
|
+
lead = self.users.get(data["lead"]) if data.get("lead") else None
|
|
1247
|
+
if lead is not None:
|
|
1248
|
+
program.lead = lead
|
|
1249
|
+
# Per-scope Monte Carlo forecast-history overrides (#3494, ADR-0144).
|
|
1250
|
+
# Both are NULL-means-inherit on the model, so only set them when the
|
|
1251
|
+
# seed authors them explicitly — an absent key must not clobber the
|
|
1252
|
+
# workspace default with a false "explicitly disabled".
|
|
1253
|
+
if "mc_history_enabled" in data:
|
|
1254
|
+
program.mc_history_enabled = data["mc_history_enabled"]
|
|
1255
|
+
if "mc_history_attribution_audience" in data:
|
|
1256
|
+
program.mc_history_attribution_audience = data["mc_history_attribution_audience"]
|
|
1257
|
+
# Record the anchor every relative date in this document was resolved
|
|
1258
|
+
# against, so the drift is computable later (ADR-1175, #3481).
|
|
1259
|
+
#
|
|
1260
|
+
# Sample path only. A caller-authored import is somebody's real program;
|
|
1261
|
+
# stamping it would advertise a "Shift dates to today" action that would
|
|
1262
|
+
# bulk-rewrite real work, and `shift_sample_dates` refuses on `is_sample`
|
|
1263
|
+
# anyway — so a non-NULL anchor there would be a field promising something
|
|
1264
|
+
# the server will not do.
|
|
1265
|
+
if self.is_sample:
|
|
1266
|
+
program.sample_anchor_date = self.anchor
|
|
1267
|
+
program.save(
|
|
1268
|
+
update_fields=[
|
|
1269
|
+
"code",
|
|
1270
|
+
"color",
|
|
1271
|
+
"lead",
|
|
1272
|
+
"name",
|
|
1273
|
+
"description",
|
|
1274
|
+
"methodology",
|
|
1275
|
+
"mc_history_enabled",
|
|
1276
|
+
"mc_history_attribution_audience",
|
|
1277
|
+
"sample_anchor_date",
|
|
1278
|
+
]
|
|
1279
|
+
)
|
|
1280
|
+
self._grant_program_memberships(program)
|
|
1281
|
+
return program
|
|
1282
|
+
|
|
1283
|
+
def _assign_program_key(self, program: Program, slug: str) -> None:
|
|
1284
|
+
"""Give the program its key: adopted, the seed's slug, or a suffixed slug (ADR-1237).
|
|
1285
|
+
|
|
1286
|
+
In order: an adopted shell that already carries a key (the async path's
|
|
1287
|
+
view assigned it) keeps it; a replacement adopts the replaced program's
|
|
1288
|
+
keys; otherwise the slug is used when free. When the slug is held by a
|
|
1289
|
+
program this run does not replace — another user's copy of the same
|
|
1290
|
+
sample, or a trashed program — the key is suffixed
|
|
1291
|
+
(``atlas-platform-launch-2``) rather than failing: an import is not an
|
|
1292
|
+
interactive form, and the interactive ``POST /programs/import/`` refuses
|
|
1293
|
+
that case with a ``409`` before it ever reaches here.
|
|
1294
|
+
|
|
1295
|
+
``save=False`` throughout: :meth:`_create_program` saves ``code`` with the
|
|
1296
|
+
other display fields in one write.
|
|
1297
|
+
"""
|
|
1298
|
+
from trueppm_api.apps.projects.services import (
|
|
1299
|
+
adopt_released_keys,
|
|
1300
|
+
assign_key,
|
|
1301
|
+
is_key_taken,
|
|
1302
|
+
suggest_free_key,
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
if program.code:
|
|
1306
|
+
return
|
|
1307
|
+
if self.released_program_keys and adopt_released_keys(
|
|
1308
|
+
program, self.released_program_keys, save=False
|
|
1309
|
+
):
|
|
1310
|
+
return
|
|
1311
|
+
if is_key_taken("program", slug, exclude=program):
|
|
1312
|
+
assign_key(
|
|
1313
|
+
program,
|
|
1314
|
+
suggest_free_key(slug, "program"),
|
|
1315
|
+
source=ObjectKeySource.DERIVED,
|
|
1316
|
+
actor=self.owner,
|
|
1317
|
+
save=False,
|
|
1318
|
+
)
|
|
1319
|
+
else:
|
|
1320
|
+
assign_key(program, slug, source=ObjectKeySource.USER, actor=self.owner, save=False)
|
|
1321
|
+
|
|
1322
|
+
def _assign_project_key(self, project: Project, data: dict[str, Any]) -> None:
|
|
1323
|
+
"""Give an imported project its key (ADR-1237), never failing the import.
|
|
1324
|
+
|
|
1325
|
+
A sample reload adopts the key of the hard-deleted project of the same
|
|
1326
|
+
name. Otherwise the seed's own ``code`` is used when it is free and a
|
|
1327
|
+
valid new key; a taken one is suffixed (``PLAT`` → ``PLAT2``,
|
|
1328
|
+
``source=derived``) — loading the same sample twice must not collide —
|
|
1329
|
+
and a document without one derives from the project name.
|
|
1330
|
+
"""
|
|
1331
|
+
from trueppm_api.apps.projects.keys import key_format_error, normalize_key
|
|
1332
|
+
from trueppm_api.apps.projects.services import (
|
|
1333
|
+
adopt_released_keys,
|
|
1334
|
+
assign_key,
|
|
1335
|
+
derive_key,
|
|
1336
|
+
is_key_taken,
|
|
1337
|
+
suggest_free_key,
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
released = self.released_project_keys.pop(project.name, None)
|
|
1341
|
+
if released and adopt_released_keys(project, released):
|
|
1342
|
+
return
|
|
1343
|
+
code = normalize_key(data.get("code") or "", "project")
|
|
1344
|
+
if code and key_format_error(code, "project") is None:
|
|
1345
|
+
if not is_key_taken("project", code, exclude=project):
|
|
1346
|
+
assign_key(project, code, source=ObjectKeySource.USER, actor=self.owner)
|
|
1347
|
+
return
|
|
1348
|
+
key = suggest_free_key(code, "project")
|
|
1349
|
+
else:
|
|
1350
|
+
key = derive_key(project.name, "project", exclude=project)
|
|
1351
|
+
assign_key(project, key, source=ObjectKeySource.DERIVED, actor=self.owner)
|
|
1352
|
+
|
|
1353
|
+
def _grant_program_memberships(self, program: Program) -> None:
|
|
1354
|
+
for account in self.payload.get("accounts", []):
|
|
1355
|
+
user = self.users.get(account["slug"])
|
|
1356
|
+
role = _ROLE_BY_NAME.get(account.get("role", ""))
|
|
1357
|
+
if user is None or role is None or user == self.owner:
|
|
1358
|
+
continue # owner already has OWNER from create_program
|
|
1359
|
+
# is_deleted/deleted_version are in the defaults, not just role: on a
|
|
1360
|
+
# re-import over a program where this account was previously removed,
|
|
1361
|
+
# the tombstoned row still owns the (program, user) slot, so an update
|
|
1362
|
+
# that touched only `role` would report success and confer nothing
|
|
1363
|
+
# (#3410). A seed grant must always leave the member with access.
|
|
1364
|
+
ProgramMembership.objects.update_or_create(
|
|
1365
|
+
program=program,
|
|
1366
|
+
user=user,
|
|
1367
|
+
defaults={"role": role, "is_deleted": False, "deleted_version": None},
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
def _grant_project_memberships(self, project: Project, data: dict[str, Any]) -> None:
|
|
1371
|
+
"""Grant the seed's accounts a ``ProjectMembership`` on this project (#3092).
|
|
1372
|
+
|
|
1373
|
+
Project access is scoped by ``ProjectMembership`` — ``ProgramMembership``
|
|
1374
|
+
alone reaches the program rail and *no* project. Before this the importer
|
|
1375
|
+
granted only the owner, so every seeded persona signed in to an empty
|
|
1376
|
+
project list and the documented evaluation walkthrough dead-ended on its
|
|
1377
|
+
"sign in as atlas-alex" step.
|
|
1378
|
+
|
|
1379
|
+
Two modes, and the fallback is what keeps existing packs working:
|
|
1380
|
+
|
|
1381
|
+
* ``members`` **declared** — use exactly those pairs. This is the only way
|
|
1382
|
+
to express one person holding different roles on different projects,
|
|
1383
|
+
which is what makes project-scoped RBAC demonstrable at all.
|
|
1384
|
+
* ``members`` **omitted** — grant every account its program-level role.
|
|
1385
|
+
Equivalent to the flat roster a v2 seed already describes, so a fixture
|
|
1386
|
+
written before this key existed gains working access without an edit.
|
|
1387
|
+
|
|
1388
|
+
The owner is skipped: they were granted OWNER unconditionally by the
|
|
1389
|
+
caller, and a seed must never be able to *demote* the importing user on
|
|
1390
|
+
their own project. Accounts that resolved to ``None`` — a pre-existing
|
|
1391
|
+
real user on an untrusted import (see ``_resolve_accounts``) — are skipped
|
|
1392
|
+
by the same guard that protects every other account reference, so a
|
|
1393
|
+
crafted seed cannot pull a stranger into a project it does not own.
|
|
1394
|
+
"""
|
|
1395
|
+
declared = data.get("members")
|
|
1396
|
+
if declared is None:
|
|
1397
|
+
pairs = [
|
|
1398
|
+
(account["slug"], account.get("role", ""))
|
|
1399
|
+
for account in self.payload.get("accounts", [])
|
|
1400
|
+
]
|
|
1401
|
+
else:
|
|
1402
|
+
pairs = [(entry["account"], entry["role"]) for entry in declared]
|
|
1403
|
+
|
|
1404
|
+
for account_slug, role_name in pairs:
|
|
1405
|
+
user = self.users.get(account_slug)
|
|
1406
|
+
role = _ROLE_BY_NAME.get(role_name)
|
|
1407
|
+
if user is None or role is None or user == self.owner:
|
|
1408
|
+
continue
|
|
1409
|
+
# Un-tombstone alongside the role — see _grant_program_memberships
|
|
1410
|
+
# (#3410). Defensive here rather than load-bearing: the project row
|
|
1411
|
+
# this runs against was created moments ago in
|
|
1412
|
+
# _create_project_structure, so no tombstone can exist yet. It is
|
|
1413
|
+
# stated anyway so the invariant survives the day a project is reused,
|
|
1414
|
+
# which is exactly how the program-side path acquired the defect.
|
|
1415
|
+
# source_group is cleared for the same reason the members endpoint
|
|
1416
|
+
# clears it: a seed grant is a direct grant, and leaving a revoked
|
|
1417
|
+
# row's group provenance set would let a later workspace-group
|
|
1418
|
+
# reconcile revoke access the seed just conferred.
|
|
1419
|
+
ProjectMembership.objects.update_or_create(
|
|
1420
|
+
project=project,
|
|
1421
|
+
user=user,
|
|
1422
|
+
defaults={
|
|
1423
|
+
"role": role,
|
|
1424
|
+
"is_deleted": False,
|
|
1425
|
+
"deleted_version": None,
|
|
1426
|
+
"source_group": None,
|
|
1427
|
+
},
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
# --- per-project structure (Pass A) ------------------------------------
|
|
1431
|
+
|
|
1432
|
+
def _create_project_structure(self, program: Program, data: dict[str, Any]) -> None:
|
|
1433
|
+
slug = data["slug"]
|
|
1434
|
+
calendar = self.calendars.get(data["calendar"]) if data.get("calendar") else None
|
|
1435
|
+
# Build the working-calendar facts first so relative dates snap against
|
|
1436
|
+
# this project's calendar (weekends + exceptions).
|
|
1437
|
+
self.working_calendars[slug] = self._build_working_calendar(calendar)
|
|
1438
|
+
project = Project.objects.create(
|
|
1439
|
+
program=program,
|
|
1440
|
+
name=data["name"],
|
|
1441
|
+
description=data.get("description", ""),
|
|
1442
|
+
start_date=self._date(data["start_date"], slug),
|
|
1443
|
+
calendar=calendar,
|
|
1444
|
+
methodology=data["methodology"],
|
|
1445
|
+
# Assigned by ``_assign_project_key`` right after the INSERT, which
|
|
1446
|
+
# suffixes a taken seed code instead of colliding (ADR-1237).
|
|
1447
|
+
code="",
|
|
1448
|
+
default_view=data.get("default_view", "SCHEDULE"),
|
|
1449
|
+
estimation_mode=data.get("estimation_mode", "open"),
|
|
1450
|
+
# PM health override (#520). Seeds may pin a project off AUTO so the
|
|
1451
|
+
# program overview shows a mix of computed and hand-set chips.
|
|
1452
|
+
health=data.get("health", "AUTO"),
|
|
1453
|
+
# is_sample is owned by the importer so the idempotency guard in
|
|
1454
|
+
# _replace_existing can distinguish disposable demo data from real
|
|
1455
|
+
# work on a later reload (#994).
|
|
1456
|
+
is_sample=self.is_sample,
|
|
1457
|
+
)
|
|
1458
|
+
self._assign_project_key(project, data)
|
|
1459
|
+
# Workstream lead (#3098). Distinct from both the program lead and the
|
|
1460
|
+
# OWNER membership: on a program of parallel workstreams the lead is who
|
|
1461
|
+
# the program manager asks about this project, and it is what the project
|
|
1462
|
+
# header and the program roll-up display. Resolved after create() because
|
|
1463
|
+
# the account may be declared later in the same document.
|
|
1464
|
+
lead_slug = data.get("lead")
|
|
1465
|
+
if lead_slug:
|
|
1466
|
+
lead_user = self.users.get(lead_slug)
|
|
1467
|
+
if lead_user is not None:
|
|
1468
|
+
project.lead = lead_user
|
|
1469
|
+
project.save(update_fields=["lead"])
|
|
1470
|
+
self.projects[slug] = project
|
|
1471
|
+
ProjectMembership.objects.update_or_create(
|
|
1472
|
+
project=project,
|
|
1473
|
+
user=self.owner,
|
|
1474
|
+
defaults={
|
|
1475
|
+
"role": Role.OWNER,
|
|
1476
|
+
"is_deleted": False,
|
|
1477
|
+
"deleted_version": None,
|
|
1478
|
+
"source_group": None,
|
|
1479
|
+
},
|
|
1480
|
+
)
|
|
1481
|
+
self._grant_project_memberships(project, data)
|
|
1482
|
+
# After the grants: the default team's membership rows are mirrored from them.
|
|
1483
|
+
self._apply_team(project, data)
|
|
1484
|
+
self._create_board_config(project, data)
|
|
1485
|
+
|
|
1486
|
+
self._create_project_labels(project, slug, data)
|
|
1487
|
+
sprint_rows = self._create_project_sprints(project, slug, data)
|
|
1488
|
+
self._create_project_tasks(project, slug, data)
|
|
1489
|
+
self._refresh_active_sprint_burndown(sprint_rows)
|
|
1490
|
+
|
|
1491
|
+
def _create_project_labels(self, project: Project, slug: str, data: dict[str, Any]) -> None:
|
|
1492
|
+
"""Create the project's label catalog (Pass A).
|
|
1493
|
+
|
|
1494
|
+
Order within Pass A is load-bearing: labels, then sprints, then tasks.
|
|
1495
|
+
``_build_task`` reads ``self.sprints`` for a task's sprint assignment,
|
|
1496
|
+
and Pass B's ``_link_task_labels`` reads ``self.labels`` — so both
|
|
1497
|
+
registries must be populated before the task loop runs.
|
|
1498
|
+
"""
|
|
1499
|
+
# Labels (ADR-0400, #1958): the project's curated label catalog. Created
|
|
1500
|
+
# here in Pass A so the Pass B ``_link_task_labels`` step can attach each
|
|
1501
|
+
# task's slugged labels once all tasks exist. Slugs are file-local and
|
|
1502
|
+
# project-scoped; the seed carries no label UUID (0.5 adds that, #1959).
|
|
1503
|
+
label_rows = []
|
|
1504
|
+
for label_data in data.get("labels", []):
|
|
1505
|
+
label = Label(
|
|
1506
|
+
project=project,
|
|
1507
|
+
name=label_data["name"],
|
|
1508
|
+
color=label_data.get("color", "slate"),
|
|
1509
|
+
position=label_data.get("position", 0),
|
|
1510
|
+
created_by=self.owner,
|
|
1511
|
+
)
|
|
1512
|
+
label_rows.append(label)
|
|
1513
|
+
self.labels[(slug, label_data["slug"])] = label
|
|
1514
|
+
self._bulk_insert(Label, label_rows, project_ids=[project.pk])
|
|
1515
|
+
|
|
1516
|
+
def _create_project_sprints(
|
|
1517
|
+
self, project: Project, slug: str, data: dict[str, Any]
|
|
1518
|
+
) -> list[Sprint]:
|
|
1519
|
+
"""Create the project's sprints and return the rows.
|
|
1520
|
+
|
|
1521
|
+
The rows are returned rather than re-queried because
|
|
1522
|
+
``_refresh_active_sprint_burndown`` needs the in-memory instances: they
|
|
1523
|
+
came from ``bulk_create`` and the caller has not re-read them.
|
|
1524
|
+
"""
|
|
1525
|
+
sprint_rows: list[Sprint] = []
|
|
1526
|
+
sprint_dates: list[date] = []
|
|
1527
|
+
for sprint_data in data.get("sprints", []):
|
|
1528
|
+
start = self._date(sprint_data["start_date"], slug)
|
|
1529
|
+
finish = self._date(sprint_data["finish_date"], slug)
|
|
1530
|
+
# Snapping is monotone, so it can never invert an authored start < finish,
|
|
1531
|
+
# but it can collapse one: when every day from start through finish is
|
|
1532
|
+
# non-working (a short sprint over a weekend, or a long PTO/holiday run)
|
|
1533
|
+
# both ends land on the same next working day, and the
|
|
1534
|
+
# ``sprint_finish_after_start`` CHECK aborts the whole import (#4339).
|
|
1535
|
+
# Move the finish to the first working day after the snapped start (the
|
|
1536
|
+
# shortest window the CHECK admits: two working days inclusive) rather
|
|
1537
|
+
# than fail the whole import — a demo install, or any user's seed —
|
|
1538
|
+
# over an authored window the calendar happened to swallow.
|
|
1539
|
+
# Only a collapse the snap *caused* is repaired: a window authored
|
|
1540
|
+
# inverted or zero-length is an authoring error, and still fails loudly.
|
|
1541
|
+
if finish <= start and self._date(
|
|
1542
|
+
sprint_data["finish_date"], slug, snap=False
|
|
1543
|
+
) > self._date(sprint_data["start_date"], slug, snap=False):
|
|
1544
|
+
finish = self._wc(slug).snap_forward(start + timedelta(days=1))
|
|
1545
|
+
# Under replay the sprint is born PLANNED and walked to its end state
|
|
1546
|
+
# by activate/close beats (authored or synthesized); points are
|
|
1547
|
+
# snapshotted at those beats. v1 import sets the end state directly.
|
|
1548
|
+
sprint = Sprint(
|
|
1549
|
+
project=project,
|
|
1550
|
+
name=sprint_data["name"],
|
|
1551
|
+
goal=sprint_data.get("goal", ""),
|
|
1552
|
+
notes=sprint_data.get("notes", ""),
|
|
1553
|
+
start_date=start,
|
|
1554
|
+
finish_date=finish,
|
|
1555
|
+
state="PLANNED" if self.replay else sprint_data["state"],
|
|
1556
|
+
committed_points=None if self.replay else sprint_data.get("committed_points"),
|
|
1557
|
+
completed_points=None if self.replay else sprint_data.get("completed_points"),
|
|
1558
|
+
capacity_points=sprint_data.get("capacity_points"),
|
|
1559
|
+
)
|
|
1560
|
+
sprint_rows.append(sprint)
|
|
1561
|
+
sprint_dates.append(start)
|
|
1562
|
+
self.sprints[(slug, sprint_data["slug"])] = sprint
|
|
1563
|
+
if self.replay:
|
|
1564
|
+
self.final_sprint[(slug, sprint_data["slug"])] = {
|
|
1565
|
+
"state": sprint_data["state"],
|
|
1566
|
+
"committed_points": sprint_data.get("committed_points"),
|
|
1567
|
+
"completed_points": sprint_data.get("completed_points"),
|
|
1568
|
+
}
|
|
1569
|
+
for sprint, short_id in zip(
|
|
1570
|
+
sprint_rows, self._short_id_block(project.pk, len(sprint_rows)), strict=True
|
|
1571
|
+
):
|
|
1572
|
+
sprint.short_id = short_id
|
|
1573
|
+
self._bulk_insert(
|
|
1574
|
+
Sprint,
|
|
1575
|
+
sprint_rows,
|
|
1576
|
+
project_ids=[project.pk],
|
|
1577
|
+
history_dates=sprint_dates if self.replay else None,
|
|
1578
|
+
)
|
|
1579
|
+
return sprint_rows
|
|
1580
|
+
|
|
1581
|
+
def _create_project_tasks(self, project: Project, slug: str, data: dict[str, Any]) -> None:
|
|
1582
|
+
"""Create the project's tasks (Pass A). Runs after sprints — see above."""
|
|
1583
|
+
task_rows: list[Task] = []
|
|
1584
|
+
task_dates: list[date] = []
|
|
1585
|
+
planning_beat = self._project_planning_beat(project, slug, data)
|
|
1586
|
+
for task_data in data.get("tasks", []):
|
|
1587
|
+
task, created_on = self._build_task(project, slug, task_data, planning_beat)
|
|
1588
|
+
task_rows.append(task)
|
|
1589
|
+
task_dates.append(created_on)
|
|
1590
|
+
self._stamp_new_tasks(task_rows, project)
|
|
1591
|
+
# One batch per project. The three Task post_save receivers are all
|
|
1592
|
+
# no-ops on INSERT — two early-return on ``created``, and the milestone
|
|
1593
|
+
# rollup short-circuits because ``Sprint.target_milestone`` is not linked
|
|
1594
|
+
# until Pass B — so nothing is lost by skipping the signal.
|
|
1595
|
+
self._bulk_insert(
|
|
1596
|
+
Task,
|
|
1597
|
+
task_rows,
|
|
1598
|
+
project_ids=[project.pk],
|
|
1599
|
+
history_dates=task_dates if self.replay else None,
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
def _stamp_new_tasks(self, rows: list[Task], project: Project) -> None:
|
|
1603
|
+
"""Apply everything ``Task.save()`` does on INSERT that ``bulk_create`` skips.
|
|
1604
|
+
|
|
1605
|
+
Three stamps, each of which would otherwise be a silent data defect:
|
|
1606
|
+
|
|
1607
|
+
* ``short_id`` — reserved as one block (see :meth:`_short_id_block`);
|
|
1608
|
+
without it every row carries ``""`` and the per-project unique
|
|
1609
|
+
constraint rejects the second one.
|
|
1610
|
+
* ``status_changed_at`` — ``_stamp_status_change`` treats an INSERT as a
|
|
1611
|
+
transition from ``None``, so every created task is stamped. Column
|
|
1612
|
+
charts and the stale-task threshold read it.
|
|
1613
|
+
* the sign-off percent coercion — REVIEW and COMPLETE both imply 100%
|
|
1614
|
+
delivered work, and a v1 seed authors those states directly. Without
|
|
1615
|
+
it a seeded COMPLETE task reports its authored ``percent_complete``
|
|
1616
|
+
and the ring, strip, and SPI math disagree with the column the card
|
|
1617
|
+
sits in.
|
|
1618
|
+
|
|
1619
|
+
``_stamp_blocker_transition`` is deliberately not replicated: on INSERT it
|
|
1620
|
+
only acts when ``blocked_reason`` is non-empty, and the seed schema has no
|
|
1621
|
+
blocker fields, so it is a guaranteed no-op here.
|
|
1622
|
+
|
|
1623
|
+
Seed provenance (ADR-0786, #2730) is stamped here too, and ``edited_at`` is
|
|
1624
|
+
deliberately left null: these rows were written by a machine and nobody has
|
|
1625
|
+
touched them yet, which is exactly what makes them eligible for the
|
|
1626
|
+
"Delete untouched rows (N)" sweep for the next seven days.
|
|
1627
|
+
"""
|
|
1628
|
+
if not rows:
|
|
1629
|
+
return
|
|
1630
|
+
now = timezone.now()
|
|
1631
|
+
for task, short_id in zip(rows, self._short_id_block(project.pk, len(rows)), strict=True):
|
|
1632
|
+
task.short_id = short_id
|
|
1633
|
+
task.status_changed_at = now
|
|
1634
|
+
task.source_kind = self.provenance_kind
|
|
1635
|
+
task.source_id = self.provenance_source_id
|
|
1636
|
+
task.seeded_at = now
|
|
1637
|
+
if task.status in (TaskStatus.REVIEW, TaskStatus.COMPLETE):
|
|
1638
|
+
task.percent_complete = 100.0
|
|
1639
|
+
|
|
1640
|
+
def _refresh_active_sprint_burndown(self, sprints: list[Sprint]) -> None:
|
|
1641
|
+
"""Upsert today's burn row once per ACTIVE sprint, not once per task.
|
|
1642
|
+
|
|
1643
|
+
``Task.save()`` emits ``task_status_changed`` on INSERT, whose receiver
|
|
1644
|
+
upserts the sprint's burndown row. Batching skips the signal, so the
|
|
1645
|
+
effect is reproduced here — but per *sprint* rather than per task, which
|
|
1646
|
+
is what the receiver's own idempotent upsert was collapsing to anyway.
|
|
1647
|
+
|
|
1648
|
+
Skipped under seed replay for the same reason the receiver skips it: the
|
|
1649
|
+
timeline drives backdated burndown per simulated day, and stamping
|
|
1650
|
+
today's row would collapse the curve to a single point (ADR-0114).
|
|
1651
|
+
"""
|
|
1652
|
+
from trueppm_api.apps.projects.seed.replay_ctx import is_seed_replay_active
|
|
1653
|
+
|
|
1654
|
+
if is_seed_replay_active():
|
|
1655
|
+
return
|
|
1656
|
+
from trueppm_api.apps.projects.models import SprintState
|
|
1657
|
+
from trueppm_api.apps.projects.services import upsert_burndown_for_sprint
|
|
1658
|
+
|
|
1659
|
+
for sprint in sprints:
|
|
1660
|
+
if sprint.state != SprintState.ACTIVE:
|
|
1661
|
+
continue
|
|
1662
|
+
try:
|
|
1663
|
+
upsert_burndown_for_sprint(sprint)
|
|
1664
|
+
# TODO(#4421): swallows SoftTimeLimitExceeded; the drain-loop guard
|
|
1665
|
+
# is ineffective until this is fixed
|
|
1666
|
+
except Exception:
|
|
1667
|
+
logger.exception("seed import: burndown upsert failed for sprint=%s", sprint.pk)
|
|
1668
|
+
|
|
1669
|
+
def _project_planning_beat(self, project: Project, slug: str, data: dict[str, Any]) -> date:
|
|
1670
|
+
"""The creation-history anchor for the project's non-sprint tasks (#3487).
|
|
1671
|
+
|
|
1672
|
+
Prefers the project's earliest *declared* baseline capture — the moment
|
|
1673
|
+
the plan was first committed to paper, which in every bundled sample
|
|
1674
|
+
predates any comment or status beat authored against a task in it —
|
|
1675
|
+
falling back to the project's own start date when the seed declares no
|
|
1676
|
+
baseline. Reads the raw seed dict rather than ``Baseline`` rows: Pass B
|
|
1677
|
+
(``_capture_baselines``) hasn't run yet when Pass A builds tasks.
|
|
1678
|
+
"""
|
|
1679
|
+
captured = [
|
|
1680
|
+
self._date_opt(bl.get("captured_at"), slug, snap=False)
|
|
1681
|
+
for bl in data.get("baselines", [])
|
|
1682
|
+
]
|
|
1683
|
+
earliest = min((c for c in captured if c is not None), default=None)
|
|
1684
|
+
return earliest if earliest is not None else project.start_date
|
|
1685
|
+
|
|
1686
|
+
def _sprint_creation_beat(self, project_slug: str, wbs_path: str, sprint: Sprint) -> date:
|
|
1687
|
+
"""A sprint-bound task's creation beat: a few days before the sprint starts.
|
|
1688
|
+
|
|
1689
|
+
Never the sprint's own ``start_date`` (#3487) — a story exists before
|
|
1690
|
+
sprint planning seats it in a sprint, so backdating creation to exactly
|
|
1691
|
+
kickoff still reads as "born the moment planning began". The lead time is
|
|
1692
|
+
derived from a stable hash of the task's own key, never wall-clock time
|
|
1693
|
+
or unseeded randomness, so a reload reproduces the same history
|
|
1694
|
+
(ADR-0114).
|
|
1695
|
+
"""
|
|
1696
|
+
digest = hashlib.sha256(f"{project_slug}:{wbs_path}".encode()).digest()
|
|
1697
|
+
lead_days = 2 + (digest[0] % 4) # 2-5 days before kickoff
|
|
1698
|
+
return sprint.start_date - timedelta(days=lead_days)
|
|
1699
|
+
|
|
1700
|
+
def _derive_task_fields(self, project_slug: str, data: dict[str, Any]) -> _TaskFields:
|
|
1701
|
+
sprint = self.sprints.get((project_slug, data["sprint"])) if data.get("sprint") else None
|
|
1702
|
+
assignee = self.users.get(data["assignee"]) if data.get("assignee") else None
|
|
1703
|
+
is_milestone = data.get("is_milestone", False)
|
|
1704
|
+
|
|
1705
|
+
# Three-point estimate is all-or-none and never set on milestones
|
|
1706
|
+
# (ADR-0093). Seeded estimates are PM-authored, so they import ACCEPTED.
|
|
1707
|
+
# Folded into the create() so an estimated task is one INSERT, not two.
|
|
1708
|
+
estimate = data.get("estimate") if not is_milestone else None
|
|
1709
|
+
estimate_fields = (
|
|
1710
|
+
{
|
|
1711
|
+
"optimistic_duration": estimate["optimistic"],
|
|
1712
|
+
"most_likely_duration": estimate["most_likely"],
|
|
1713
|
+
"pessimistic_duration": estimate["pessimistic"],
|
|
1714
|
+
"estimate_status": EstimateStatus.ACCEPTED,
|
|
1715
|
+
}
|
|
1716
|
+
if estimate
|
|
1717
|
+
else {}
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
final_status = data.get("status", "NOT_STARTED")
|
|
1721
|
+
planned_start = self._date_opt(data.get("planned_start"), project_slug)
|
|
1722
|
+
story_points = data.get("story_points")
|
|
1723
|
+
|
|
1724
|
+
# Under replay a task that ends in-flight/done is born NOT_STARTED and
|
|
1725
|
+
# walked forward by the timeline + synthesizer; its creation row is
|
|
1726
|
+
# backdated to when work could have begun (sprint/planned/project start).
|
|
1727
|
+
progresses = self.replay and final_status in ("IN_PROGRESS", "REVIEW", "COMPLETE")
|
|
1728
|
+
|
|
1729
|
+
# BACKLOG is off the progression spine — an ordinary backlog item is born
|
|
1730
|
+
# there and the synthesizer deliberately refuses to walk anything to it
|
|
1731
|
+
# (``replay._PROGRESSION_INDEX`` has no entry), so it must NOT be folded
|
|
1732
|
+
# into ``progresses``: born NOT_STARTED with nothing to move it, it would
|
|
1733
|
+
# strand at NOT_STARTED forever. But a sprint close that carries an
|
|
1734
|
+
# unfinished task back to the backlog (#3488) *is* an event-driven move
|
|
1735
|
+
# into BACKLOG, and the exporter reconstructs it as a dated ``task.status``
|
|
1736
|
+
# beat. Born already at BACKLOG that beat is a no-op (``_apply_task_status``
|
|
1737
|
+
# returns when the column already matches), so no history row exists and
|
|
1738
|
+
# the re-export drops the event — breaking the #616 byte-identical round
|
|
1739
|
+
# trip. The document itself distinguishes the two cases: only the carried
|
|
1740
|
+
# task has a ``task.status`` beat dated against it.
|
|
1741
|
+
decommits = (
|
|
1742
|
+
self.replay
|
|
1743
|
+
and final_status == "BACKLOG"
|
|
1744
|
+
and (project_slug, data["wbs_path"]) in self.authored_status_targets
|
|
1745
|
+
)
|
|
1746
|
+
base_status = "NOT_STARTED" if (progresses or decommits) else final_status
|
|
1747
|
+
# Progress fields stay keyed on ``progresses`` alone: a carried task walks
|
|
1748
|
+
# only its column, so re-basing its percent/remaining would invent numbers
|
|
1749
|
+
# nothing later restores (``_finalize_tasks`` only rescues IN_PROGRESS and
|
|
1750
|
+
# REVIEW), and the re-export would then disagree about those fields.
|
|
1751
|
+
base_percent = 0.0 if progresses else data.get("percent_complete", 0.0)
|
|
1752
|
+
base_remaining = story_points if progresses else data.get("remaining_points")
|
|
1753
|
+
|
|
1754
|
+
return _TaskFields(
|
|
1755
|
+
sprint=sprint,
|
|
1756
|
+
assignee=assignee,
|
|
1757
|
+
is_milestone=is_milestone,
|
|
1758
|
+
estimate_fields=estimate_fields,
|
|
1759
|
+
final_status=final_status,
|
|
1760
|
+
planned_start=planned_start,
|
|
1761
|
+
story_points=story_points,
|
|
1762
|
+
base_status=base_status,
|
|
1763
|
+
base_percent=base_percent,
|
|
1764
|
+
base_remaining=base_remaining,
|
|
1765
|
+
)
|
|
1766
|
+
|
|
1767
|
+
def _task_creation_beat(
|
|
1768
|
+
self, project_slug: str, data: dict[str, Any], planning_beat: date, sprint: Sprint | None
|
|
1769
|
+
) -> date:
|
|
1770
|
+
"""The date this task's creation-history row backdates to (#3487).
|
|
1771
|
+
|
|
1772
|
+
A sprint-bound task backdates to shortly before its sprint kicks off;
|
|
1773
|
+
everything else backdates to the project's own planning beat (earliest
|
|
1774
|
+
baseline capture, else project start) — never to ``planned_start``, which
|
|
1775
|
+
is a future schedule position, not a record of when the task came into
|
|
1776
|
+
being, and is exactly what put creation rows months into the future
|
|
1777
|
+
before this fix. Two further clamps, both required: an authored event
|
|
1778
|
+
against this task (comment, status move, …) is honored as an even
|
|
1779
|
+
earlier floor when the seed talks about the task before its plan
|
|
1780
|
+
existed, and the anchor is a hard ceiling so a reload never manufactures
|
|
1781
|
+
a future-dated creation row regardless of how far out the task or
|
|
1782
|
+
sprint is planned.
|
|
1783
|
+
"""
|
|
1784
|
+
beat = (
|
|
1785
|
+
self._sprint_creation_beat(project_slug, data["wbs_path"], sprint)
|
|
1786
|
+
if sprint
|
|
1787
|
+
else planning_beat
|
|
1788
|
+
)
|
|
1789
|
+
earliest_event = self.earliest_task_event.get((project_slug, data["wbs_path"]))
|
|
1790
|
+
if earliest_event is not None and earliest_event < beat:
|
|
1791
|
+
beat = earliest_event
|
|
1792
|
+
return min(beat, self.anchor)
|
|
1793
|
+
|
|
1794
|
+
def _capture_replay_progress(
|
|
1795
|
+
self, project_slug: str, data: dict[str, Any], final_status: str
|
|
1796
|
+
) -> None:
|
|
1797
|
+
"""Record the authored progress fields a replayed task should be restored to.
|
|
1798
|
+
|
|
1799
|
+
A task born at 0%/full-points to give the timeline room to walk it
|
|
1800
|
+
forward never gets those numbers back on its own — COMPLETE is rescued
|
|
1801
|
+
by ``Task._coerce_signoff_percent``, IN_PROGRESS is not (#3486). REVIEW
|
|
1802
|
+
is a mixed case (#3518): the same coercion fixes ``percent_complete`` to
|
|
1803
|
+
100 by design ("work done, awaiting sign-off"), but ``remaining_points``
|
|
1804
|
+
has no such contract — a story in review with points still open is a
|
|
1805
|
+
real, authorable state — so REVIEW only hands back ``remaining_points``,
|
|
1806
|
+
never ``percent_complete``, to avoid fighting the coercion. Hand replay
|
|
1807
|
+
the authored values to restore, keyed per field so a dated
|
|
1808
|
+
``task.points`` beat still wins where the document declared nothing.
|
|
1809
|
+
"""
|
|
1810
|
+
self.final_status[(project_slug, data["wbs_path"])] = final_status
|
|
1811
|
+
progress_fields: tuple[str, ...]
|
|
1812
|
+
if final_status == "IN_PROGRESS":
|
|
1813
|
+
progress_fields = ("percent_complete", "remaining_points")
|
|
1814
|
+
elif final_status == "REVIEW":
|
|
1815
|
+
progress_fields = ("remaining_points",)
|
|
1816
|
+
else:
|
|
1817
|
+
progress_fields = ()
|
|
1818
|
+
if not progress_fields:
|
|
1819
|
+
return
|
|
1820
|
+
authored = {key: data[key] for key in progress_fields if data.get(key) is not None}
|
|
1821
|
+
if authored:
|
|
1822
|
+
self.final_progress[(project_slug, data["wbs_path"])] = authored
|
|
1823
|
+
|
|
1824
|
+
def _build_task(
|
|
1825
|
+
self, project: Project, project_slug: str, data: dict[str, Any], planning_beat: date
|
|
1826
|
+
) -> tuple[Task, date]:
|
|
1827
|
+
"""Construct one unsaved ``Task`` plus the date its creation row backdates to.
|
|
1828
|
+
|
|
1829
|
+
Returns rather than saves so the caller can batch a whole project's tasks
|
|
1830
|
+
into one INSERT (ADR-0726 §8). The returned date is only consulted under
|
|
1831
|
+
v2 replay.
|
|
1832
|
+
|
|
1833
|
+
Args:
|
|
1834
|
+
planning_beat: the project's default creation-history anchor for a
|
|
1835
|
+
non-sprint task (see :meth:`_project_planning_beat`) — computed
|
|
1836
|
+
once per project rather than per task since it doesn't vary.
|
|
1837
|
+
"""
|
|
1838
|
+
f = self._derive_task_fields(project_slug, data)
|
|
1839
|
+
|
|
1840
|
+
task = Task(
|
|
1841
|
+
project=project,
|
|
1842
|
+
name=data["name"],
|
|
1843
|
+
wbs_path=data["wbs_path"],
|
|
1844
|
+
type=data.get("type", "task"),
|
|
1845
|
+
status=f.base_status,
|
|
1846
|
+
is_milestone=f.is_milestone,
|
|
1847
|
+
duration=0 if f.is_milestone else data.get("duration", 1),
|
|
1848
|
+
planned_start=f.planned_start,
|
|
1849
|
+
percent_complete=f.base_percent,
|
|
1850
|
+
notes=data.get("notes", ""),
|
|
1851
|
+
story_points=f.story_points,
|
|
1852
|
+
remaining_points=f.base_remaining,
|
|
1853
|
+
assignee=f.assignee,
|
|
1854
|
+
sprint=f.sprint,
|
|
1855
|
+
sprint_rank=data.get("sprint_rank"),
|
|
1856
|
+
# Declared in the schema with an enum since v1 and read by nothing
|
|
1857
|
+
# (#3093): aurora authored 23 `ready` + 2 `refine` and every one of
|
|
1858
|
+
# them landed on the model default. `task.ac_met` was the only writer,
|
|
1859
|
+
# so the pure-scrum pack's Definition of Ready — the whole point of its
|
|
1860
|
+
# sprint-picker story — was uniformly `idea` on screen.
|
|
1861
|
+
dor=data.get("dor", DorState.IDEA),
|
|
1862
|
+
board_lane=data.get("board_lane", ""),
|
|
1863
|
+
# ``blocked_reason`` is the flag-of-record: non-empty means blocked.
|
|
1864
|
+
# ``blocked_since`` is stamped by ``Task.save()`` with ``timezone.now()``,
|
|
1865
|
+
# which would read "blocked 0 days" on a backdated seed, so it is
|
|
1866
|
+
# corrected to the project's own timeline in ``_backdate_blockers``.
|
|
1867
|
+
blocked_reason=self._blocker_reason(data.get("blocked")),
|
|
1868
|
+
blocker_type=(data.get("blocked") or {}).get("type", ""),
|
|
1869
|
+
governance_class=data.get("governance_class", "flow"),
|
|
1870
|
+
# Couple delivery_mode to the milestone flag (#1773) so seeded
|
|
1871
|
+
# milestones satisfy the canonical invariant like every other path.
|
|
1872
|
+
delivery_mode="milestone" if f.is_milestone else data.get("delivery_mode", "waterfall"),
|
|
1873
|
+
color=data.get("color"),
|
|
1874
|
+
# Drawer subtask (ADR-0060, #3498). Validation already held it to a
|
|
1875
|
+
# depth-1 leaf under a decomposable parent, as the create view does.
|
|
1876
|
+
is_subtask=bool(data.get("is_subtask", False)),
|
|
1877
|
+
**f.estimate_fields,
|
|
1878
|
+
)
|
|
1879
|
+
created_on = self._task_creation_beat(project_slug, data, planning_beat, f.sprint)
|
|
1880
|
+
self.tasks[(project_slug, data["wbs_path"])] = task
|
|
1881
|
+
if self.replay:
|
|
1882
|
+
self._capture_replay_progress(project_slug, data, f.final_status)
|
|
1883
|
+
return task, created_on
|
|
1884
|
+
|
|
1885
|
+
# --- cross-cutting links (Pass B) --------------------------------------
|
|
1886
|
+
|
|
1887
|
+
def _resolve_task_ref(self, ref: str, enclosing_project: str) -> Task:
|
|
1888
|
+
if ":" in ref:
|
|
1889
|
+
project_slug, _, wbs = ref.partition(":")
|
|
1890
|
+
else:
|
|
1891
|
+
project_slug, wbs = enclosing_project, ref
|
|
1892
|
+
return self.tasks[(project_slug, wbs)]
|
|
1893
|
+
|
|
1894
|
+
def _link_dependencies(self, project: Project, data: dict[str, Any]) -> None:
|
|
1895
|
+
"""Materialize the project's CPM edges in one batch per owning project.
|
|
1896
|
+
|
|
1897
|
+
A dependency carries no project FK, so its sync cursor is drawn through
|
|
1898
|
+
its ``predecessor`` (the endpoint the delta scopes and orders by). Edges
|
|
1899
|
+
are therefore grouped by the *predecessor's* project rather than by the
|
|
1900
|
+
enclosing block — a seed may reference a task in any project of the
|
|
1901
|
+
program (the ADR-0120 D1 envelope), so the two are not the same set.
|
|
1902
|
+
"""
|
|
1903
|
+
slug = data["slug"]
|
|
1904
|
+
by_owner: dict[Any, list[Dependency]] = defaultdict(list)
|
|
1905
|
+
for dep in data.get("dependencies", []):
|
|
1906
|
+
predecessor = self._resolve_task_ref(dep["predecessor"], slug)
|
|
1907
|
+
by_owner[predecessor.project_id].append(
|
|
1908
|
+
Dependency(
|
|
1909
|
+
predecessor=predecessor,
|
|
1910
|
+
successor=self._resolve_task_ref(dep["successor"], slug),
|
|
1911
|
+
dep_type=dep["dep_type"],
|
|
1912
|
+
lag=dep.get("lag", 0),
|
|
1913
|
+
)
|
|
1914
|
+
)
|
|
1915
|
+
for project_id, rows in by_owner.items():
|
|
1916
|
+
self._bulk_insert(Dependency, rows, project_ids=[project_id])
|
|
1917
|
+
|
|
1918
|
+
def _resolve_blockers(self, data: dict[str, Any]) -> None:
|
|
1919
|
+
"""Fill in the blocker halves that need every task and account to exist (#3094).
|
|
1920
|
+
|
|
1921
|
+
``blocked_reason`` / ``blocker_type`` are set at construction; the soft
|
|
1922
|
+
``blocking_task`` link and the ``blocked_by`` actor are resolved here in
|
|
1923
|
+
Pass B, once the whole project's tasks are inserted and a forward
|
|
1924
|
+
reference can land.
|
|
1925
|
+
|
|
1926
|
+
``blocked_since`` is set explicitly rather than left to the model.
|
|
1927
|
+
``Task.save()`` stamps it with ``timezone.now()`` on the block transition,
|
|
1928
|
+
but the importer inserts through ``bulk_create_tasks`` — no ``save()``, so
|
|
1929
|
+
nothing stamps it at all, and an unstamped blocker renders no age. Age is
|
|
1930
|
+
the whole triage signal ("3 tasks blocked more than a week"), so a seeded
|
|
1931
|
+
blocker that reads "0d" is worse than none. Falls back to the task's own
|
|
1932
|
+
planned start, then the project's, so the value is never null while the
|
|
1933
|
+
flag is raised.
|
|
1934
|
+
"""
|
|
1935
|
+
slug = data["slug"]
|
|
1936
|
+
project = self.projects[slug]
|
|
1937
|
+
touched = []
|
|
1938
|
+
for task_data in data.get("tasks", []):
|
|
1939
|
+
blocked = task_data.get("blocked")
|
|
1940
|
+
if not blocked:
|
|
1941
|
+
continue
|
|
1942
|
+
task = self.tasks[(slug, task_data["wbs_path"])]
|
|
1943
|
+
# A blocker started when it started — a Saturday stays a Saturday.
|
|
1944
|
+
# Snapping this forward would report the block as newer than it is.
|
|
1945
|
+
since = self._date_opt(blocked.get("since"), slug, snap=False)
|
|
1946
|
+
task.blocked_since = self._creation_dt(
|
|
1947
|
+
since or task.planned_start or project.start_date
|
|
1948
|
+
)
|
|
1949
|
+
if blocked.get("blocking_task"):
|
|
1950
|
+
task.blocking_task = self._resolve_task_ref(blocked["blocking_task"], slug)
|
|
1951
|
+
if blocked.get("by"):
|
|
1952
|
+
task.blocked_by = self.users.get(blocked["by"])
|
|
1953
|
+
touched.append(task)
|
|
1954
|
+
if touched:
|
|
1955
|
+
Task.objects.bulk_update(
|
|
1956
|
+
touched,
|
|
1957
|
+
["blocked_since", "blocking_task", "blocked_by"],
|
|
1958
|
+
batch_size=_BULK_BATCH_SIZE,
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
def _create_attachments(self, data: dict[str, Any]) -> None:
|
|
1962
|
+
"""Create each task's external-link attachments (#3094).
|
|
1963
|
+
|
|
1964
|
+
URL-only by design: ``TaskAttachment`` enforces file XOR ``external_url``
|
|
1965
|
+
at the DB level, and a seed is a text document with no bytes to carry, so
|
|
1966
|
+
the file half is deliberately inexpressible rather than half-supported.
|
|
1967
|
+
"""
|
|
1968
|
+
slug = data["slug"]
|
|
1969
|
+
rows = []
|
|
1970
|
+
for task_data in data.get("tasks", []):
|
|
1971
|
+
attachments = task_data.get("attachments")
|
|
1972
|
+
if not attachments:
|
|
1973
|
+
continue
|
|
1974
|
+
task = self.tasks[(slug, task_data["wbs_path"])]
|
|
1975
|
+
for entry in attachments:
|
|
1976
|
+
rows.append(
|
|
1977
|
+
TaskAttachment(
|
|
1978
|
+
task=task,
|
|
1979
|
+
external_url=entry["external_url"],
|
|
1980
|
+
external_title=entry.get("external_title", ""),
|
|
1981
|
+
is_pinned=entry.get("is_pinned", False),
|
|
1982
|
+
uploaded_by=(
|
|
1983
|
+
self.users.get(entry["uploaded_by"])
|
|
1984
|
+
if entry.get("uploaded_by")
|
|
1985
|
+
else None
|
|
1986
|
+
),
|
|
1987
|
+
)
|
|
1988
|
+
)
|
|
1989
|
+
if rows:
|
|
1990
|
+
TaskAttachment.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
|
|
1991
|
+
|
|
1992
|
+
def _link_parent_epics(self, data: dict[str, Any]) -> None:
|
|
1993
|
+
slug = data["slug"]
|
|
1994
|
+
touched = []
|
|
1995
|
+
for task_data in data.get("tasks", []):
|
|
1996
|
+
parent = task_data.get("parent_epic")
|
|
1997
|
+
if not parent:
|
|
1998
|
+
continue
|
|
1999
|
+
task = self.tasks[(slug, task_data["wbs_path"])]
|
|
2000
|
+
task.parent_epic = self.tasks[(slug, parent)]
|
|
2001
|
+
touched.append(task)
|
|
2002
|
+
# bulk_update: a back-link is a structural fixup of a row this import just
|
|
2003
|
+
# created, not an independent edit, so it neither needs its own history
|
|
2004
|
+
# row nor a second cursor draw (the same reasoning ADR-0091 applies to CPM
|
|
2005
|
+
# output writes).
|
|
2006
|
+
if touched:
|
|
2007
|
+
Task.objects.bulk_update(touched, ["parent_epic"])
|
|
2008
|
+
|
|
2009
|
+
def _link_sprint_milestones(self, data: dict[str, Any]) -> None:
|
|
2010
|
+
slug = data["slug"]
|
|
2011
|
+
touched = []
|
|
2012
|
+
for sprint_data in data.get("sprints", []):
|
|
2013
|
+
target = sprint_data.get("target_milestone")
|
|
2014
|
+
if not target:
|
|
2015
|
+
continue
|
|
2016
|
+
sprint = self.sprints[(slug, sprint_data["slug"])]
|
|
2017
|
+
sprint.target_milestone = self.tasks[(slug, target)]
|
|
2018
|
+
touched.append(sprint)
|
|
2019
|
+
if touched:
|
|
2020
|
+
Sprint.objects.bulk_update(touched, ["target_milestone"])
|
|
2021
|
+
|
|
2022
|
+
def _link_task_labels(self, data: dict[str, Any]) -> None:
|
|
2023
|
+
"""Attach each task's slugged labels via the ``TaskLabel`` through table (#1958).
|
|
2024
|
+
|
|
2025
|
+
Runs in Pass B (after all tasks exist). A task's ``labels`` is a list of
|
|
2026
|
+
project-local label slugs resolved against the ``self.labels`` index built
|
|
2027
|
+
in Pass A. Unknown slugs are skipped defensively — validation (#614) rejects
|
|
2028
|
+
a dangling label ref before we reach here, so this is belt-and-braces.
|
|
2029
|
+
"""
|
|
2030
|
+
slug = data["slug"]
|
|
2031
|
+
rows = []
|
|
2032
|
+
seen: set[tuple[Any, Any]] = set()
|
|
2033
|
+
for task_data in data.get("tasks", []):
|
|
2034
|
+
label_slugs = task_data.get("labels", [])
|
|
2035
|
+
if not label_slugs:
|
|
2036
|
+
continue
|
|
2037
|
+
task = self.tasks[(slug, task_data["wbs_path"])]
|
|
2038
|
+
for label_slug in label_slugs:
|
|
2039
|
+
label = self.labels.get((slug, label_slug))
|
|
2040
|
+
# The de-dup that ``get_or_create`` used to provide. Validation
|
|
2041
|
+
# (#614) already rejects a task listing a label twice, so this
|
|
2042
|
+
# only guards a payload that slipped past it — but the batch has
|
|
2043
|
+
# no per-row conflict handling, so it has to guard it here.
|
|
2044
|
+
if label is not None and (task.pk, label.pk) not in seen:
|
|
2045
|
+
seen.add((task.pk, label.pk))
|
|
2046
|
+
rows.append(TaskLabel(task=task, label=label))
|
|
2047
|
+
# TaskLabel is outside the sync union (it has no cursor of its own —
|
|
2048
|
+
# attach/detach saves the Task, which allocates there instead) and
|
|
2049
|
+
# records no history, so a plain bulk_create is the whole story.
|
|
2050
|
+
self._bulk_insert(TaskLabel, rows)
|
|
2051
|
+
|
|
2052
|
+
def _link_task_relations(self, project_slug: str, data: dict[str, Any]) -> None:
|
|
2053
|
+
"""Materialize each task's informational ``links`` as ``TaskRelation`` rows (ADR-0455).
|
|
2054
|
+
|
|
2055
|
+
Runs in Pass B (after all tasks exist) so a relation's ``target`` may resolve
|
|
2056
|
+
to a task in any project of the program — the ADR-0120 D1 envelope. A relation
|
|
2057
|
+
is inert: unlike :meth:`_link_dependencies` it drives no CPM edge, no consent
|
|
2058
|
+
gate, and no schedule recompute, so it is a plain ``create`` (the enclosing
|
|
2059
|
+
program subtree is wiped on re-import, so there is nothing to upsert against).
|
|
2060
|
+
Self-links are skipped defensively — validation (#614) rejects them first and
|
|
2061
|
+
the model's ``task_relation_no_self`` CheckConstraint is the DB backstop.
|
|
2062
|
+
"""
|
|
2063
|
+
by_owner: dict[Any, list[TaskRelation]] = defaultdict(list)
|
|
2064
|
+
for task_data in data.get("tasks", []):
|
|
2065
|
+
links = task_data.get("links", [])
|
|
2066
|
+
if not links:
|
|
2067
|
+
continue
|
|
2068
|
+
source = self.tasks[(project_slug, task_data["wbs_path"])]
|
|
2069
|
+
for link in links:
|
|
2070
|
+
target = self._resolve_task_ref(link["target"], project_slug)
|
|
2071
|
+
if target.pk == source.pk:
|
|
2072
|
+
continue # inert self-link; rejected by the DB constraint anyway
|
|
2073
|
+
# Grouped by the source task's project — a relation's cursor is
|
|
2074
|
+
# drawn through ``source``, and a link may cross projects.
|
|
2075
|
+
by_owner[source.project_id].append(
|
|
2076
|
+
TaskRelation(
|
|
2077
|
+
source=source,
|
|
2078
|
+
target=target,
|
|
2079
|
+
relation_type=link["link_type"],
|
|
2080
|
+
note=link.get("note", ""),
|
|
2081
|
+
created_by=self.owner,
|
|
2082
|
+
)
|
|
2083
|
+
)
|
|
2084
|
+
for project_id, rows in by_owner.items():
|
|
2085
|
+
self._bulk_insert(TaskRelation, rows, project_ids=[project_id])
|
|
2086
|
+
|
|
2087
|
+
def _assign_resources(self, project: Project, data: dict[str, Any]) -> None:
|
|
2088
|
+
"""Create the ``TaskResource`` rows, from ``assignments`` or from ``assignee``.
|
|
2089
|
+
|
|
2090
|
+
The fallback is the load-bearing half (#2900). Capacity, utilization and
|
|
2091
|
+
the heatmap sum ``TaskResource.units`` and never read ``Task.assignee``, so
|
|
2092
|
+
a task carrying only an assignee contributes **zero** load. Every bundled
|
|
2093
|
+
seed pack authors assignees and no ``assignments`` array at all, which made
|
|
2094
|
+
this whole method dead code for every sample that ships — an evaluator
|
|
2095
|
+
clicking "Load demo data" saw an owner on every board card and a completely
|
|
2096
|
+
empty heatmap, and concluded the capacity features do not work.
|
|
2097
|
+
|
|
2098
|
+
Synthesizing the row here rather than adding ``assignments`` to the four
|
|
2099
|
+
fixtures is deliberate: it also covers any future fixture that authors an
|
|
2100
|
+
assignee and forgets the allocation, which is the mistake all four made.
|
|
2101
|
+
|
|
2102
|
+
An explicit ``assignments`` array always wins — it can express partial
|
|
2103
|
+
units and several people on one task, which a bare assignee cannot. The
|
|
2104
|
+
fallback fires only when a task has no assignments at all, and resolves
|
|
2105
|
+
the assignee (an **account** slug) through the resource that declares that
|
|
2106
|
+
account. A task whose assignee has no corresponding resource is skipped:
|
|
2107
|
+
there is no defensible units figure for a person the seed never staffed.
|
|
2108
|
+
|
|
2109
|
+
Units are the resource's own availability, capped at one full unit — see
|
|
2110
|
+
:data:`_ASSIGNEE_MAX_UNITS` for why a flat 1.0 would misrepresent the
|
|
2111
|
+
part-time staffing the packs deliberately author.
|
|
2112
|
+
"""
|
|
2113
|
+
slug = data["slug"]
|
|
2114
|
+
rows = []
|
|
2115
|
+
# ``ensure_project_resource`` is idempotent but costs a round-trip, so it
|
|
2116
|
+
# is hoisted out of the per-assignment loop and run once per distinct
|
|
2117
|
+
# resource rather than once per assignment.
|
|
2118
|
+
used: dict[Any, Any] = {}
|
|
2119
|
+
for task_data in data.get("tasks", []):
|
|
2120
|
+
task_key = (slug, task_data["wbs_path"])
|
|
2121
|
+
assignments = task_data.get("assignments", [])
|
|
2122
|
+
if assignments:
|
|
2123
|
+
task = self.tasks[task_key]
|
|
2124
|
+
for assignment in assignments:
|
|
2125
|
+
resource = self.resources[assignment["resource"]]
|
|
2126
|
+
used[resource.pk] = resource
|
|
2127
|
+
rows.append(
|
|
2128
|
+
TaskResource(
|
|
2129
|
+
task=task, resource=resource, units=assignment.get("units", 1.0)
|
|
2130
|
+
)
|
|
2131
|
+
)
|
|
2132
|
+
continue
|
|
2133
|
+
|
|
2134
|
+
assignee = task_data.get("assignee")
|
|
2135
|
+
if not assignee:
|
|
2136
|
+
continue
|
|
2137
|
+
owner = self.resources_by_account.get(assignee)
|
|
2138
|
+
if owner is None:
|
|
2139
|
+
continue
|
|
2140
|
+
# A milestone is a gate, not work somebody performs, so it carries no
|
|
2141
|
+
# load even when the seed names an owner on it. Giving one units would
|
|
2142
|
+
# inflate every heatmap cell it lands in with effort nobody spends.
|
|
2143
|
+
task = self.tasks[task_key]
|
|
2144
|
+
if task.is_milestone:
|
|
2145
|
+
continue
|
|
2146
|
+
used[owner.pk] = owner
|
|
2147
|
+
units = min(_ASSIGNEE_MAX_UNITS, owner.max_units)
|
|
2148
|
+
rows.append(TaskResource(task=task, resource=owner, units=units))
|
|
2149
|
+
self._bulk_insert(TaskResource, rows)
|
|
2150
|
+
for resource in used.values():
|
|
2151
|
+
ensure_project_resource(project, resource)
|
|
2152
|
+
|
|
2153
|
+
def _apply_team(self, project: Project, data: dict[str, Any]) -> None:
|
|
2154
|
+
"""Name the project's default team and set its SM/PO facets (ADR-0078, #3498).
|
|
2155
|
+
|
|
2156
|
+
Membership itself comes from the ``ProjectMembership`` mirror
|
|
2157
|
+
(``teams.signals``), which the grants just before this call triggered. This
|
|
2158
|
+
only names the team and sets the facets, which the mirror never infers. A
|
|
2159
|
+
member whose account resolved to nobody is skipped, like every other account
|
|
2160
|
+
reference on the generic path (#1057).
|
|
2161
|
+
"""
|
|
2162
|
+
spec = data.get("team")
|
|
2163
|
+
if not spec:
|
|
2164
|
+
return
|
|
2165
|
+
from trueppm_api.apps.teams.models import TeamMembership, TeamRole
|
|
2166
|
+
from trueppm_api.apps.teams.services import ensure_default_team
|
|
2167
|
+
|
|
2168
|
+
team = ensure_default_team(project, created_by=self.owner)
|
|
2169
|
+
if spec.get("name") and team.name != spec["name"]:
|
|
2170
|
+
team.name = spec["name"]
|
|
2171
|
+
team.save(update_fields=["name"])
|
|
2172
|
+
for member in spec.get("members", []):
|
|
2173
|
+
user = self.users.get(member["account"])
|
|
2174
|
+
if user is None:
|
|
2175
|
+
continue
|
|
2176
|
+
membership, _ = TeamMembership.objects.get_or_create(
|
|
2177
|
+
team=team, user=user, is_deleted=False, defaults={"role": TeamRole.MEMBER}
|
|
2178
|
+
)
|
|
2179
|
+
membership.is_scrum_master = bool(member.get("scrum_master", False))
|
|
2180
|
+
membership.is_product_owner = bool(member.get("product_owner", False))
|
|
2181
|
+
membership.save(update_fields=["is_scrum_master", "is_product_owner"])
|
|
2182
|
+
|
|
2183
|
+
def _create_recurrence_rules(self, data: dict[str, Any]) -> None:
|
|
2184
|
+
"""Turn each template task's ``recurrence`` into a rule (ADR-0090, #3498).
|
|
2185
|
+
|
|
2186
|
+
Only the rule is written; occurrences spawn on the generator's rolling horizon,
|
|
2187
|
+
exactly as after the API creates one. ``is_recurring`` is set on the template
|
|
2188
|
+
the way ``TaskRecurrenceRuleViewSet.perform_create`` sets it, which takes the
|
|
2189
|
+
template out of CPM and every committed-delivery aggregate.
|
|
2190
|
+
"""
|
|
2191
|
+
slug = data["slug"]
|
|
2192
|
+
project = self.projects[slug]
|
|
2193
|
+
tz = project.calendar.timezone if project.calendar is not None else "UTC"
|
|
2194
|
+
for task_data in data.get("tasks", []):
|
|
2195
|
+
spec = task_data.get("recurrence")
|
|
2196
|
+
if not spec:
|
|
2197
|
+
continue
|
|
2198
|
+
task = self.tasks[(slug, task_data["wbs_path"])]
|
|
2199
|
+
end_date = self._date_opt(spec.get("end_date"), slug, snap=False)
|
|
2200
|
+
end_count = spec.get("end_count")
|
|
2201
|
+
if end_date is not None:
|
|
2202
|
+
end_type = "ON_DATE"
|
|
2203
|
+
elif end_count:
|
|
2204
|
+
end_type = "AFTER_N"
|
|
2205
|
+
else:
|
|
2206
|
+
end_type = "NEVER"
|
|
2207
|
+
rule = TaskRecurrenceRule(
|
|
2208
|
+
task=task,
|
|
2209
|
+
frequency=spec["frequency"],
|
|
2210
|
+
interval=spec.get("interval", 1),
|
|
2211
|
+
weekdays=sum(_WEEKDAY_BIT[day] for day in spec.get("weekdays", [])),
|
|
2212
|
+
day_of_month=spec.get("day_of_month"),
|
|
2213
|
+
time_of_day=dt_time.fromisoformat(spec.get("time_of_day", "09:00")),
|
|
2214
|
+
timezone=tz or "UTC",
|
|
2215
|
+
end_type=end_type,
|
|
2216
|
+
end_date=end_date,
|
|
2217
|
+
end_count=end_count,
|
|
2218
|
+
)
|
|
2219
|
+
# The same invariants the serializer enforces; validation already
|
|
2220
|
+
# checked them, so a failure here means the two drifted.
|
|
2221
|
+
rule.clean()
|
|
2222
|
+
rule.save()
|
|
2223
|
+
task.is_recurring = True
|
|
2224
|
+
Task.objects.filter(pk=task.pk).update(is_recurring=True)
|
|
2225
|
+
|
|
2226
|
+
def _create_skill_requirements(self, data: dict[str, Any]) -> None:
|
|
2227
|
+
"""Attach each task's skill requirements from the workspace catalog (#3498).
|
|
2228
|
+
|
|
2229
|
+
Matched onto ``Skill`` by case-folded name, so a requirement naming a skill a
|
|
2230
|
+
resource already declared lands on that same catalog row and the assignment
|
|
2231
|
+
fit can compare them.
|
|
2232
|
+
"""
|
|
2233
|
+
slug = data["slug"]
|
|
2234
|
+
for task_data in data.get("tasks", []):
|
|
2235
|
+
for entry in task_data.get("skill_requirements", []):
|
|
2236
|
+
name = entry["skill"].strip()
|
|
2237
|
+
skill, _ = Skill.objects.get_or_create(
|
|
2238
|
+
normalized_name=name.casefold(), defaults={"name": name}
|
|
2239
|
+
)
|
|
2240
|
+
TaskSkillRequirement.objects.get_or_create(
|
|
2241
|
+
task=self.tasks[(slug, task_data["wbs_path"])],
|
|
2242
|
+
skill=skill,
|
|
2243
|
+
is_deleted=False,
|
|
2244
|
+
defaults={
|
|
2245
|
+
"min_proficiency": _PROFICIENCY_BY_NAME[
|
|
2246
|
+
entry.get("min_proficiency", "beginner")
|
|
2247
|
+
]
|
|
2248
|
+
},
|
|
2249
|
+
)
|
|
2250
|
+
|
|
2251
|
+
def _create_board_config(self, project: Project, data: dict[str, Any]) -> None:
|
|
2252
|
+
"""Materialize the project's Kanban board configuration (#3093).
|
|
2253
|
+
|
|
2254
|
+
``board_columns`` was declared in the schema as a bare list of label
|
|
2255
|
+
strings and read by nothing, so a pack could author "Backlog / To Do /
|
|
2256
|
+
In Progress / In Review / Done" and get the API's hardcoded defaults —
|
|
2257
|
+
no renamed column, no WIP limit, no lane. The old shape could not have
|
|
2258
|
+
worked even if it had been read: ``BoardColumnConfig.columns`` is keyed
|
|
2259
|
+
on the canonical ``status``, and a label list carries none.
|
|
2260
|
+
|
|
2261
|
+
Entries are stored as authored. The board serializer requires all five
|
|
2262
|
+
canonical statuses exactly once and the JSON Schema enforces the same
|
|
2263
|
+
five-value enum, so a seed cannot express a config the board API would
|
|
2264
|
+
reject — but the serializer is the authority at read time, not this.
|
|
2265
|
+
"""
|
|
2266
|
+
columns = data.get("board_columns")
|
|
2267
|
+
if not columns:
|
|
2268
|
+
return
|
|
2269
|
+
BoardColumnConfig.objects.update_or_create(
|
|
2270
|
+
project=project,
|
|
2271
|
+
defaults={
|
|
2272
|
+
"columns": [
|
|
2273
|
+
{
|
|
2274
|
+
"status": column["status"],
|
|
2275
|
+
"label": column["label"],
|
|
2276
|
+
"visible": column.get("visible", True),
|
|
2277
|
+
"color": column.get("color"),
|
|
2278
|
+
"wip_limit": column.get("wip_limit"),
|
|
2279
|
+
"age_threshold_days": column.get("age_threshold_days"),
|
|
2280
|
+
"lanes": [
|
|
2281
|
+
{
|
|
2282
|
+
"key": lane["key"],
|
|
2283
|
+
"label": lane["label"],
|
|
2284
|
+
"wip_limit": lane.get("wip_limit"),
|
|
2285
|
+
}
|
|
2286
|
+
for lane in column.get("lanes", [])
|
|
2287
|
+
],
|
|
2288
|
+
}
|
|
2289
|
+
for column in columns
|
|
2290
|
+
]
|
|
2291
|
+
},
|
|
2292
|
+
)
|
|
2293
|
+
|
|
2294
|
+
def _capture_baselines(self, project: Project, data: dict[str, Any]) -> None:
|
|
2295
|
+
slug = data["slug"]
|
|
2296
|
+
for bl_data in data.get("baselines", []):
|
|
2297
|
+
baseline = Baseline.objects.create(
|
|
2298
|
+
project=project,
|
|
2299
|
+
name=bl_data["name"],
|
|
2300
|
+
is_active=bl_data.get("is_active", False),
|
|
2301
|
+
)
|
|
2302
|
+
# ``captured_at`` has been in the schema and authored by every bundled
|
|
2303
|
+
# pack since v1, and nothing read it (#3093). ``Baseline.created_at`` is
|
|
2304
|
+
# auto_now_add, so each declared baseline recorded the moment somebody
|
|
2305
|
+
# clicked "Load demo data" — collapsing Bayside's 75-day gap between its
|
|
2306
|
+
# contract baseline and its change-order rebaseline onto one afternoon,
|
|
2307
|
+
# which is exactly the interval every planned-vs-actual view reads.
|
|
2308
|
+
# The replay path (``_apply_baseline_capture``) already backdates this
|
|
2309
|
+
# way; the declarative path simply never did.
|
|
2310
|
+
# A capture happened when it happened, so this must not weekend-snap:
|
|
2311
|
+
# two baselines whose offsets snap differently render an interval that
|
|
2312
|
+
# is not the one the pack authored. The replay path backdates from a
|
|
2313
|
+
# `resolve_timestamp`, which never snaps -- this keeps the two agreeing.
|
|
2314
|
+
captured = self._date_opt(bl_data.get("captured_at"), slug, snap=False)
|
|
2315
|
+
if captured is not None:
|
|
2316
|
+
Baseline.objects.filter(pk=baseline.pk).update(
|
|
2317
|
+
created_at=self._creation_dt(captured)
|
|
2318
|
+
)
|
|
2319
|
+
rows = []
|
|
2320
|
+
has_dates = True
|
|
2321
|
+
for bt in bl_data.get("tasks", []):
|
|
2322
|
+
task = self.tasks[(slug, bt["task"])]
|
|
2323
|
+
start = self._date_opt(bt.get("start"), slug)
|
|
2324
|
+
finish = self._date_opt(bt.get("finish"), slug)
|
|
2325
|
+
has_dates = has_dates and start is not None
|
|
2326
|
+
rows.append(
|
|
2327
|
+
BaselineTask(
|
|
2328
|
+
baseline=baseline,
|
|
2329
|
+
task_id=task.pk,
|
|
2330
|
+
task_name=task.name,
|
|
2331
|
+
start=start,
|
|
2332
|
+
finish=finish,
|
|
2333
|
+
duration=bt.get("duration", task.duration),
|
|
2334
|
+
story_points=bt.get("story_points", task.story_points),
|
|
2335
|
+
)
|
|
2336
|
+
)
|
|
2337
|
+
BaselineTask.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
|
|
2338
|
+
if rows and has_dates:
|
|
2339
|
+
Baseline.objects.filter(pk=baseline.pk).update(has_cpm_dates=True)
|
|
2340
|
+
|
|
2341
|
+
def _backfill_forecast_history(self) -> None:
|
|
2342
|
+
"""Synthesize ProjectForecastSnapshot + MonteCarloRun history for projects
|
|
2343
|
+
that author a ``forecast_history`` block.
|
|
2344
|
+
|
|
2345
|
+
Delegates the drift math to ``forecast_backfill`` (ADR-0211). Keyed off the
|
|
2346
|
+
seed's ``forecast_history`` block so it is opt-in and generic — only the
|
|
2347
|
+
history-aware samples carry it, but any v2 seed may. The MonteCarloRun
|
|
2348
|
+
history (#3494) is derived from the same snapshot rows, not recomputed, so
|
|
2349
|
+
the MC history panel and the forecast-trend chart can never disagree.
|
|
2350
|
+
"""
|
|
2351
|
+
for project_data in self.payload["projects"]:
|
|
2352
|
+
spec = project_data.get("forecast_history")
|
|
2353
|
+
if not spec:
|
|
2354
|
+
continue
|
|
2355
|
+
project = self.projects[project_data["slug"]]
|
|
2356
|
+
# Must match the population capture_forecast_snapshot aggregates over
|
|
2357
|
+
# (Task.committed, #3539), not every non-deleted task: the synthesized
|
|
2358
|
+
# history and the sample's first *real* capture sit next to each other on
|
|
2359
|
+
# the forecast-trend chart, and a task_count that steps at the join is a
|
|
2360
|
+
# discontinuity in demo data with no cause a viewer can find.
|
|
2361
|
+
task_count = Task.committed.filter(project=project).count()
|
|
2362
|
+
snapshots = backfill_forecast_history(
|
|
2363
|
+
project,
|
|
2364
|
+
spec,
|
|
2365
|
+
anchor=self.anchor,
|
|
2366
|
+
program_code=self.payload["program"]["slug"],
|
|
2367
|
+
project_slug=project_data["slug"],
|
|
2368
|
+
task_count=task_count,
|
|
2369
|
+
)
|
|
2370
|
+
backfill_monte_carlo_run_history(
|
|
2371
|
+
project,
|
|
2372
|
+
snapshots,
|
|
2373
|
+
triggered_by=self._scheduler_persona(project_data),
|
|
2374
|
+
)
|
|
2375
|
+
|
|
2376
|
+
def _scheduler_persona(self, project_data: dict[str, Any]) -> Any:
|
|
2377
|
+
"""Resolve the user MonteCarloRun history (#3494) attributes seeded runs to.
|
|
2378
|
+
|
|
2379
|
+
A run's ``triggered_by`` is display/audit metadata (ADR-0175), never
|
|
2380
|
+
access control, so this need not be exact — but it should read as a real
|
|
2381
|
+
person, not the importing owner, when the seed casts one. Only a
|
|
2382
|
+
Scheduler+ role may actually persist an attributed run (``run_monte_carlo``
|
|
2383
|
+
docstring), so preference goes to whichever of that ladder the project
|
|
2384
|
+
casts, project scope first: the project's own ``members`` override (or
|
|
2385
|
+
the program-roster fallback ``_grant_project_memberships`` would apply)
|
|
2386
|
+
for SCHEDULER, then ADMIN, then OWNER; then the same three at program
|
|
2387
|
+
scope; then the importing owner as a last resort (every program has one).
|
|
2388
|
+
"""
|
|
2389
|
+
declared = project_data.get("members")
|
|
2390
|
+
project_pairs = (
|
|
2391
|
+
[(entry["account"], entry["role"]) for entry in declared]
|
|
2392
|
+
if declared is not None
|
|
2393
|
+
else [
|
|
2394
|
+
(account["slug"], account.get("role", ""))
|
|
2395
|
+
for account in self.payload.get("accounts", [])
|
|
2396
|
+
]
|
|
2397
|
+
)
|
|
2398
|
+
program_pairs = [
|
|
2399
|
+
(account["slug"], account.get("role", ""))
|
|
2400
|
+
for account in self.payload.get("accounts", [])
|
|
2401
|
+
]
|
|
2402
|
+
for role_name in ("SCHEDULER", "ADMIN", "OWNER"):
|
|
2403
|
+
for pairs in (project_pairs, program_pairs):
|
|
2404
|
+
for slug, pair_role in pairs:
|
|
2405
|
+
if pair_role == role_name and self.users.get(slug) is not None:
|
|
2406
|
+
return self.users[slug]
|
|
2407
|
+
return self.owner
|
|
2408
|
+
|
|
2409
|
+
# --- v2.1 collaboration sections (#3603) --------------------------------
|
|
2410
|
+
|
|
2411
|
+
def _ts(self, value: str | None) -> datetime | None:
|
|
2412
|
+
"""Resolve an optional seed timestamp. Timestamps are never weekend-snapped."""
|
|
2413
|
+
return resolve_timestamp(value, anchor=self.anchor) if value else None
|
|
2414
|
+
|
|
2415
|
+
def _create_acceptance_criteria(self, data: dict[str, Any]) -> None:
|
|
2416
|
+
"""Materialize each task's acceptance criteria in authored order (#3492).
|
|
2417
|
+
|
|
2418
|
+
A criterion authored ``met`` carries its own trail. One ticked later, on a
|
|
2419
|
+
dated ``task.ac_met`` beat, is born unmet and replay flips it — so the rows
|
|
2420
|
+
are also indexed per task for the replay context to address by position.
|
|
2421
|
+
"""
|
|
2422
|
+
slug = data["slug"]
|
|
2423
|
+
rows: list[AcceptanceCriterion] = []
|
|
2424
|
+
for task_data in data.get("tasks", []):
|
|
2425
|
+
authored = task_data.get("acceptance_criteria")
|
|
2426
|
+
if not authored:
|
|
2427
|
+
continue
|
|
2428
|
+
key = (slug, task_data["wbs_path"])
|
|
2429
|
+
per_task = self.criteria.setdefault(key, [])
|
|
2430
|
+
for position, entry in enumerate(authored):
|
|
2431
|
+
met = bool(entry.get("met", False))
|
|
2432
|
+
row = AcceptanceCriterion(
|
|
2433
|
+
task=self.tasks[key],
|
|
2434
|
+
text=entry["text"],
|
|
2435
|
+
given=entry.get("given", ""),
|
|
2436
|
+
when=entry.get("when", ""),
|
|
2437
|
+
then=entry.get("then", ""),
|
|
2438
|
+
met=met,
|
|
2439
|
+
position=position,
|
|
2440
|
+
met_by=(
|
|
2441
|
+
self.users.get(entry["met_by"]) if met and entry.get("met_by") else None
|
|
2442
|
+
),
|
|
2443
|
+
met_at=self._ts(entry.get("met_at")) if met else None,
|
|
2444
|
+
)
|
|
2445
|
+
rows.append(row)
|
|
2446
|
+
per_task.append(row)
|
|
2447
|
+
self._bulk_insert(AcceptanceCriterion, rows, project_ids=[self.projects[slug].pk])
|
|
2448
|
+
|
|
2449
|
+
def _create_backlog_items(self, program: Program) -> None:
|
|
2450
|
+
"""Seed the program backlog — the intake pool (ADR-0069, #3491).
|
|
2451
|
+
|
|
2452
|
+
Per-row saves rather than the batched path: a program carries a handful of
|
|
2453
|
+
items, and ``BacklogItem`` is program-scoped, so there is no project cursor
|
|
2454
|
+
to draw. A pulled item links the task it became, which is what the
|
|
2455
|
+
backlog's "pulled into …" chip and the task's source link both read.
|
|
2456
|
+
"""
|
|
2457
|
+
for data in self.payload["program"].get("backlog_items", []):
|
|
2458
|
+
target = data.get("pulled_to")
|
|
2459
|
+
item = BacklogItem(
|
|
2460
|
+
program=program,
|
|
2461
|
+
title=data["title"],
|
|
2462
|
+
description=data.get("description", ""),
|
|
2463
|
+
item_type=data.get("item_type", "task"),
|
|
2464
|
+
status=data.get("status", "proposed"),
|
|
2465
|
+
tags=list(data.get("tags", [])),
|
|
2466
|
+
priority_rank=data.get("priority_rank"),
|
|
2467
|
+
story_points=data.get("story_points"),
|
|
2468
|
+
pulled_task=self._resolve_task_ref(target, "") if target else None,
|
|
2469
|
+
pulled_at=self._ts(data.get("pulled_at")) if target else None,
|
|
2470
|
+
pulled_by=(
|
|
2471
|
+
self.users.get(data["pulled_by"]) if target and data.get("pulled_by") else None
|
|
2472
|
+
),
|
|
2473
|
+
created_by=(
|
|
2474
|
+
self.users.get(data["created_by"]) if data.get("created_by") else self.owner
|
|
2475
|
+
),
|
|
2476
|
+
)
|
|
2477
|
+
item.save()
|
|
2478
|
+
created = self._ts(data.get("created_at"))
|
|
2479
|
+
if created is not None:
|
|
2480
|
+
# auto_now_add stamped import time; an intake item was raised when
|
|
2481
|
+
# the seed says it was.
|
|
2482
|
+
BacklogItem.objects.filter(pk=item.pk).update(created_at=created)
|
|
2483
|
+
|
|
2484
|
+
def _create_ceremonies(self, program: Program) -> None:
|
|
2485
|
+
"""Seed the program's recurring ceremonies (ADR-0079, #3603).
|
|
2486
|
+
|
|
2487
|
+
Validation already refused sprint-event names and a missing clock anchor,
|
|
2488
|
+
mirroring ``CeremonyTemplateSerializer``; an on-milestone ceremony drops
|
|
2489
|
+
any day/time exactly as the serializer strips them.
|
|
2490
|
+
"""
|
|
2491
|
+
for data in self.payload["program"].get("ceremonies", []):
|
|
2492
|
+
on_milestone = data["cadence_type"] == "on_milestone"
|
|
2493
|
+
CeremonyTemplate(
|
|
2494
|
+
program=program,
|
|
2495
|
+
name=data["name"].strip(),
|
|
2496
|
+
cadence_type=data["cadence_type"],
|
|
2497
|
+
cadence_day="" if on_milestone else data.get("cadence_day", ""),
|
|
2498
|
+
cadence_time=None if on_milestone else dt_time.fromisoformat(data["cadence_time"]),
|
|
2499
|
+
duration_minutes=data.get("duration_minutes", 60),
|
|
2500
|
+
owner_role=data.get("owner_role", ""),
|
|
2501
|
+
enabled=data.get("enabled", True),
|
|
2502
|
+
created_by=self.owner,
|
|
2503
|
+
).save()
|
|
2504
|
+
|
|
2505
|
+
def _create_share_links(self) -> None:
|
|
2506
|
+
"""Mint each sample project's share links (ADR-0245, #3603). Sample path only.
|
|
2507
|
+
|
|
2508
|
+
The token comes from ``mint_share_link`` here, at load; nothing in any file
|
|
2509
|
+
can choose it, and the raw value is discarded — only its hash is stored, as
|
|
2510
|
+
for every link. The link is live and revocable from the project's share
|
|
2511
|
+
settings, so the share surface is populated; ``create_demo_share_link`` is
|
|
2512
|
+
still how an operator obtains an openable URL.
|
|
2513
|
+
"""
|
|
2514
|
+
if not self.is_sample:
|
|
2515
|
+
return
|
|
2516
|
+
from trueppm_api.apps.projects.share_services import mint_share_link
|
|
2517
|
+
|
|
2518
|
+
for project_data in self.payload["projects"]:
|
|
2519
|
+
project = self.projects[project_data["slug"]]
|
|
2520
|
+
for data in project_data.get("share_links", []):
|
|
2521
|
+
days = data.get("expires_in_days")
|
|
2522
|
+
creator = self.users.get(data["created_by"]) if data.get("created_by") else None
|
|
2523
|
+
mint_share_link(
|
|
2524
|
+
project,
|
|
2525
|
+
creator or self.owner,
|
|
2526
|
+
label=data.get("label", ""),
|
|
2527
|
+
show_assignees=data.get("show_assignees", False),
|
|
2528
|
+
show_milestone_dates=data.get("show_milestone_dates", True),
|
|
2529
|
+
content_kind=data.get("content_kind", "board"),
|
|
2530
|
+
expires_at=timezone.now() + timedelta(days=days) if days else None,
|
|
2531
|
+
)
|
|
2532
|
+
|
|
2533
|
+
def _agent_action_refs(self, data: dict[str, Any]) -> tuple[Task | None, Project | None]:
|
|
2534
|
+
"""Resolve the task and project an authored agent action points at."""
|
|
2535
|
+
obj = data.get("object")
|
|
2536
|
+
task = self._resolve_task_ref(obj, "") if obj else None
|
|
2537
|
+
project = self.projects.get(data["project"]) if data.get("project") else None
|
|
2538
|
+
if project is None and task is not None:
|
|
2539
|
+
project = task.project
|
|
2540
|
+
return task, project
|
|
2541
|
+
|
|
2542
|
+
def _record_sample_agent_actions(self) -> None:
|
|
2543
|
+
"""Write the sample's agent trail through the real audit chain (#3603). Sample only.
|
|
2544
|
+
|
|
2545
|
+
``record_agent_action(sample=True)`` marks each row in hashed fields, because
|
|
2546
|
+
the row outlives its project: a reload hard-deletes the sample and SET_NULLs
|
|
2547
|
+
``project``, after which only the marker says the row is demo data. Appended
|
|
2548
|
+
in ``at`` order so the chain's sequence and the narrated timeline agree.
|
|
2549
|
+
"""
|
|
2550
|
+
if not self.is_sample:
|
|
2551
|
+
return
|
|
2552
|
+
from trueppm_api.apps.agents.services import record_agent_action
|
|
2553
|
+
|
|
2554
|
+
authored = self.payload["program"].get("agent_actions", [])
|
|
2555
|
+
ordered = sorted(
|
|
2556
|
+
range(len(authored)),
|
|
2557
|
+
key=lambda i: (resolve_timestamp(authored[i]["at"], anchor=self.anchor), i),
|
|
2558
|
+
)
|
|
2559
|
+
for i in ordered:
|
|
2560
|
+
data = authored[i]
|
|
2561
|
+
task, project = self._agent_action_refs(data)
|
|
2562
|
+
record_agent_action(
|
|
2563
|
+
actor_token=None,
|
|
2564
|
+
principal=self.users.get(data["principal"]) if data.get("principal") else None,
|
|
2565
|
+
action=data["action"],
|
|
2566
|
+
method=data.get("method", "GET"),
|
|
2567
|
+
capability_used=data.get("capability", "mcp:read"),
|
|
2568
|
+
verdict=data["verdict"],
|
|
2569
|
+
payload_hash=hashlib.sha256(
|
|
2570
|
+
json.dumps(data, sort_keys=True).encode("utf-8")
|
|
2571
|
+
).hexdigest(),
|
|
2572
|
+
refusal_reason=data.get("refusal_reason", ""),
|
|
2573
|
+
refusal_constraint=data.get("refusal_constraint", ""),
|
|
2574
|
+
projected_impact=data.get("projected_impact"),
|
|
2575
|
+
object_type=data.get("object_type", "task" if task is not None else ""),
|
|
2576
|
+
object_id=str(task.pk) if task is not None else "",
|
|
2577
|
+
project_id=project.pk if project is not None else None,
|
|
2578
|
+
summary=data.get("summary", ""),
|
|
2579
|
+
occurred_at=resolve_timestamp(data["at"], anchor=self.anchor),
|
|
2580
|
+
sample=True,
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
def _synthesize_time(self) -> None:
|
|
2584
|
+
"""Fill logged time on sample work the timeline left unlogged (#3490). Sample only.
|
|
2585
|
+
|
|
2586
|
+
On a real import this would invent actuals in somebody's program, so it is
|
|
2587
|
+
confined to the bundled samples. For each COMPLETE or IN_PROGRESS work task
|
|
2588
|
+
with no authored ``time.log`` beat, every allocated person who has a login
|
|
2589
|
+
gets entries on most working days of the task's *actual* window (the dates
|
|
2590
|
+
replay stamped), sized ``hours_per_day × units`` with jitter — so the
|
|
2591
|
+
Timesheet page, the log-time popover and actual-vs-estimate read as a team
|
|
2592
|
+
that has been working.
|
|
2593
|
+
|
|
2594
|
+
Three bounds keep it honest: no entry on or after the anchor ("today"); a
|
|
2595
|
+
person's synthesized day never exceeds their calendar day, however many
|
|
2596
|
+
tasks overlap; and a task contributes at most ``_TIME_FILL_MAX_DAYS``. The
|
|
2597
|
+
RNG is seeded on program, task and username — never a UUID or the clock —
|
|
2598
|
+
so a reload reproduces the same hours.
|
|
2599
|
+
"""
|
|
2600
|
+
if not (self.is_sample and self.replay):
|
|
2601
|
+
return
|
|
2602
|
+
authored = self._authored_time_log_keys()
|
|
2603
|
+
allocations = self._task_resource_allocations()
|
|
2604
|
+
|
|
2605
|
+
program_code = self.payload["program"]["slug"]
|
|
2606
|
+
last_day = self.anchor - timedelta(days=1)
|
|
2607
|
+
used: dict[tuple[Any, date], int] = defaultdict(int)
|
|
2608
|
+
by_project: dict[Any, list[TimeEntry]] = defaultdict(list)
|
|
2609
|
+
for key, task in self.tasks.items():
|
|
2610
|
+
if key in authored or self.final_status.get(key) not in ("COMPLETE", "IN_PROGRESS"):
|
|
2611
|
+
continue
|
|
2612
|
+
entries = self._synthesize_task_time_entries(
|
|
2613
|
+
key, task, allocations, program_code, last_day, used
|
|
2614
|
+
)
|
|
2615
|
+
if entries:
|
|
2616
|
+
by_project[self.projects[key[0]].pk].extend(entries)
|
|
2617
|
+
for project_id, rows in by_project.items():
|
|
2618
|
+
self._bulk_insert(TimeEntry, rows, project_ids=[project_id])
|
|
2619
|
+
|
|
2620
|
+
def _authored_time_log_keys(self) -> set[tuple[str, str]]:
|
|
2621
|
+
"""(project_slug, wbs) pairs already carrying an authored ``time.log`` beat."""
|
|
2622
|
+
authored: set[tuple[str, str]] = set()
|
|
2623
|
+
for event in self.payload.get("events", []):
|
|
2624
|
+
if event.get("action") == "time.log":
|
|
2625
|
+
_, _, ref = event.get("target", "").partition(":")
|
|
2626
|
+
project_slug, _, wbs = ref.partition(":")
|
|
2627
|
+
authored.add((project_slug, wbs))
|
|
2628
|
+
return authored
|
|
2629
|
+
|
|
2630
|
+
def _task_resource_allocations(self) -> dict[Any, list[TaskResource]]:
|
|
2631
|
+
allocations: dict[Any, list[TaskResource]] = defaultdict(list)
|
|
2632
|
+
for row in TaskResource.objects.filter(
|
|
2633
|
+
task_id__in=[task.pk for task in self.tasks.values()],
|
|
2634
|
+
resource__user__isnull=False,
|
|
2635
|
+
).select_related("resource__user"):
|
|
2636
|
+
allocations[row.task_id].append(row)
|
|
2637
|
+
return allocations
|
|
2638
|
+
|
|
2639
|
+
def _synthesize_task_time_entries(
|
|
2640
|
+
self,
|
|
2641
|
+
key: tuple[str, str],
|
|
2642
|
+
task: Task,
|
|
2643
|
+
allocations: dict[Any, list[TaskResource]],
|
|
2644
|
+
program_code: str,
|
|
2645
|
+
last_day: date,
|
|
2646
|
+
used: dict[tuple[Any, date], int],
|
|
2647
|
+
) -> list[TimeEntry]:
|
|
2648
|
+
"""The ``TimeEntry`` rows synthesized for one task, or ``[]`` if it's out of scope.
|
|
2649
|
+
|
|
2650
|
+
Mutates `used` with every minute it allocates, so later tasks/allocations
|
|
2651
|
+
sharing a (user, day) see the day already partly spent.
|
|
2652
|
+
"""
|
|
2653
|
+
if task.actual_start is None or getattr(task, "structure_role", "work") != "work":
|
|
2654
|
+
return []
|
|
2655
|
+
end = min(task.actual_finish or last_day, last_day)
|
|
2656
|
+
if end < task.actual_start:
|
|
2657
|
+
return []
|
|
2658
|
+
project = self.projects[key[0]]
|
|
2659
|
+
calendar = self._wc(key[0])
|
|
2660
|
+
hours = float(project.calendar.hours_per_day) if project.calendar is not None else 8.0
|
|
2661
|
+
day_cap = int(hours * 60 * 1.1)
|
|
2662
|
+
entries: list[TimeEntry] = []
|
|
2663
|
+
for allocation in allocations.get(task.pk, []):
|
|
2664
|
+
user = allocation.resource.user
|
|
2665
|
+
if user is None: # filtered out by the query; narrows the type
|
|
2666
|
+
continue
|
|
2667
|
+
rng = random.Random(f"{program_code}:{key[0]}:{key[1]}:{user.get_username()}")
|
|
2668
|
+
day = max(task.actual_start, end - timedelta(days=_TIME_FILL_MAX_DAYS))
|
|
2669
|
+
while day <= end:
|
|
2670
|
+
if calendar.is_working_day(day) and rng.random() < 0.85:
|
|
2671
|
+
wanted = hours * 60 * float(allocation.units) * rng.uniform(0.6, 1.1)
|
|
2672
|
+
minutes = min(round(wanted / 15) * 15, day_cap - used[(user.pk, day)])
|
|
2673
|
+
if minutes >= 15:
|
|
2674
|
+
used[(user.pk, day)] += minutes
|
|
2675
|
+
entries.append(
|
|
2676
|
+
TimeEntry(task=task, user=user, minutes=minutes, entry_date=day)
|
|
2677
|
+
)
|
|
2678
|
+
day += timedelta(days=1)
|
|
2679
|
+
return entries
|
|
2680
|
+
|
|
2681
|
+
def _synthesize_timesheet_submissions(self) -> None:
|
|
2682
|
+
"""Submit every fully elapsed week a sample persona logged time in (#3490). Sample only.
|
|
2683
|
+
|
|
2684
|
+
``TimesheetSubmission`` is per user and week, not per program, and personas
|
|
2685
|
+
persist across reloads — so an existing row is kept, never duplicated. The
|
|
2686
|
+
week containing the anchor is never submitted: it has not ended.
|
|
2687
|
+
"""
|
|
2688
|
+
if not self.is_sample:
|
|
2689
|
+
return
|
|
2690
|
+
this_monday = self.anchor - timedelta(days=self.anchor.weekday())
|
|
2691
|
+
logged = TimeEntry.objects.filter(
|
|
2692
|
+
task__project__in=list(self.projects.values()),
|
|
2693
|
+
entry_date__lt=this_monday,
|
|
2694
|
+
is_deleted=False,
|
|
2695
|
+
).values_list("user_id", "entry_date")
|
|
2696
|
+
weeks = {(user_id, day - timedelta(days=day.weekday())) for user_id, day in logged}
|
|
2697
|
+
existing = set(
|
|
2698
|
+
TimesheetSubmission.objects.filter(
|
|
2699
|
+
user_id__in={user_id for user_id, _ in weeks}
|
|
2700
|
+
).values_list("user_id", "week_start")
|
|
2701
|
+
)
|
|
2702
|
+
rows = [
|
|
2703
|
+
TimesheetSubmission(
|
|
2704
|
+
user_id=user_id,
|
|
2705
|
+
week_start=monday,
|
|
2706
|
+
# Friday 17:00 UTC of that week — submitted when the week closed.
|
|
2707
|
+
submitted_at=datetime(monday.year, monday.month, monday.day, 17, 0, tzinfo=_UTC)
|
|
2708
|
+
+ timedelta(days=4),
|
|
2709
|
+
)
|
|
2710
|
+
for user_id, monday in sorted(weeks, key=lambda pair: (str(pair[0]), pair[1]))
|
|
2711
|
+
if (user_id, monday) not in existing
|
|
2712
|
+
]
|
|
2713
|
+
TimesheetSubmission.objects.bulk_create(rows, batch_size=_BULK_BATCH_SIZE)
|
|
2714
|
+
|
|
2715
|
+
def _create_program_risks(self, program: Program) -> None:
|
|
2716
|
+
"""Program-scoped risks attach to the first project (Risk is project-FK).
|
|
2717
|
+
|
|
2718
|
+
TruePPM has no program-level Risk model; a program-scoped seed risk is
|
|
2719
|
+
materialized on the lead project but may link tasks across projects.
|
|
2720
|
+
"""
|
|
2721
|
+
risks = self.payload.get("risks", [])
|
|
2722
|
+
if not risks:
|
|
2723
|
+
return
|
|
2724
|
+
lead_project = next(iter(self.projects.values()))
|
|
2725
|
+
self._create_risks(lead_project, risks, enclosing_project=None)
|
|
2726
|
+
|
|
2727
|
+
def _create_risks(
|
|
2728
|
+
self, project: Project, risks: list[dict[str, Any]], enclosing_project: str | None
|
|
2729
|
+
) -> None:
|
|
2730
|
+
for data in risks:
|
|
2731
|
+
risk = Risk(
|
|
2732
|
+
project=project,
|
|
2733
|
+
title=data["title"],
|
|
2734
|
+
description=data.get("description", ""),
|
|
2735
|
+
status=data["status"],
|
|
2736
|
+
probability=data["probability"],
|
|
2737
|
+
impact=data["impact"],
|
|
2738
|
+
category=data.get("category"),
|
|
2739
|
+
response=data.get("response"),
|
|
2740
|
+
mitigation_due_date=self._date_opt(
|
|
2741
|
+
data.get("mitigation_due_date"), enclosing_project
|
|
2742
|
+
),
|
|
2743
|
+
trigger=data.get("trigger", ""),
|
|
2744
|
+
contingency=data.get("contingency", ""),
|
|
2745
|
+
notes=data.get("notes", ""),
|
|
2746
|
+
owner=self.users.get(data["owner"]) if data.get("owner") else None,
|
|
2747
|
+
)
|
|
2748
|
+
# Backdate the creation history row to the project start under replay
|
|
2749
|
+
# (risks are identified at kickoff), so a risk that the events timeline
|
|
2750
|
+
# walks through a status lifecycle reads chronologically — the "opened"
|
|
2751
|
+
# row precedes its dated transitions rather than landing at import time.
|
|
2752
|
+
self._save_new(risk, project.start_date)
|
|
2753
|
+
# Slug map lets risk.status replay beats resolve their target.
|
|
2754
|
+
if data.get("slug"):
|
|
2755
|
+
self.risks_by_slug[data["slug"]] = risk
|
|
2756
|
+
# Risk itself stays a per-row save so the ``risk_changed`` signal
|
|
2757
|
+
# fires; risk counts are in the tens, so there is nothing to win.
|
|
2758
|
+
risk_task_rows = [
|
|
2759
|
+
RiskTask(risk=risk, task=self._resolve_task_ref(ref, enclosing_project or ""))
|
|
2760
|
+
# Program-scoped risks (enclosing_project is None) always carry
|
|
2761
|
+
# qualified refs, so the enclosing fallback is never consulted.
|
|
2762
|
+
for ref in data.get("tasks", [])
|
|
2763
|
+
]
|
|
2764
|
+
self._bulk_insert(RiskTask, risk_task_rows)
|