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,2908 @@
|
|
|
1
|
+
"""Views for the scheduling app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from datetime import date as _date
|
|
10
|
+
from datetime import datetime as _datetime
|
|
11
|
+
from datetime import timedelta
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from django.conf import settings
|
|
15
|
+
from django.core.cache import cache
|
|
16
|
+
from django.db import models, transaction
|
|
17
|
+
from django.shortcuts import get_object_or_404
|
|
18
|
+
from django.utils import timezone
|
|
19
|
+
from django.utils.dateparse import parse_date, parse_datetime
|
|
20
|
+
from drf_spectacular.types import OpenApiTypes
|
|
21
|
+
from drf_spectacular.utils import (
|
|
22
|
+
OpenApiExample,
|
|
23
|
+
OpenApiParameter,
|
|
24
|
+
OpenApiResponse,
|
|
25
|
+
PolymorphicProxySerializer,
|
|
26
|
+
extend_schema,
|
|
27
|
+
extend_schema_view,
|
|
28
|
+
inline_serializer,
|
|
29
|
+
)
|
|
30
|
+
from rest_framework import serializers, status
|
|
31
|
+
from rest_framework import serializers as drf_serializers
|
|
32
|
+
from rest_framework.decorators import action, api_view, permission_classes, throttle_classes
|
|
33
|
+
from rest_framework.generics import ListAPIView
|
|
34
|
+
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
|
35
|
+
from rest_framework.pagination import PageNumberPagination
|
|
36
|
+
from rest_framework.permissions import IsAuthenticated
|
|
37
|
+
from rest_framework.request import Request
|
|
38
|
+
from rest_framework.response import Response
|
|
39
|
+
from rest_framework.throttling import ScopedRateThrottle, SimpleRateThrottle
|
|
40
|
+
from rest_framework.views import APIView
|
|
41
|
+
from rest_framework.viewsets import GenericViewSet
|
|
42
|
+
|
|
43
|
+
from trueppm_api.apps.access.permissions import (
|
|
44
|
+
IsProjectAdmin,
|
|
45
|
+
IsProjectMember,
|
|
46
|
+
IsProjectNotArchived,
|
|
47
|
+
IsProjectScheduler,
|
|
48
|
+
IsWorkspaceOperator,
|
|
49
|
+
McpReadableViewMixin,
|
|
50
|
+
McpScope,
|
|
51
|
+
assert_project_not_archived,
|
|
52
|
+
declare_scope_in_body,
|
|
53
|
+
)
|
|
54
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
55
|
+
from trueppm_api.apps.projects.models import (
|
|
56
|
+
Dependency,
|
|
57
|
+
EstimationMode,
|
|
58
|
+
Project,
|
|
59
|
+
Task,
|
|
60
|
+
three_point_estimates_ordered,
|
|
61
|
+
)
|
|
62
|
+
from trueppm_api.apps.scheduling.calendars import compose_project_calendar
|
|
63
|
+
from trueppm_api.apps.scheduling.finish_reading import (
|
|
64
|
+
finish_shift_days,
|
|
65
|
+
latest_finish,
|
|
66
|
+
lazy_finish_shift_days,
|
|
67
|
+
schedule_result_finish,
|
|
68
|
+
task_finish_at_day_start,
|
|
69
|
+
)
|
|
70
|
+
from trueppm_api.apps.scheduling.forecast_staleness import (
|
|
71
|
+
forecast_staleness_facts,
|
|
72
|
+
forecast_staleness_for_payload,
|
|
73
|
+
forecast_staleness_from_run,
|
|
74
|
+
)
|
|
75
|
+
from trueppm_api.apps.scheduling.mc_admission import admit_runs, busy_response, simulation_slot
|
|
76
|
+
from trueppm_api.apps.scheduling.models import (
|
|
77
|
+
FailedTask,
|
|
78
|
+
FailedTaskStatus,
|
|
79
|
+
MonteCarloRun,
|
|
80
|
+
ProjectForecastSnapshot,
|
|
81
|
+
ScheduleRequestReason,
|
|
82
|
+
VelocitySuggestion,
|
|
83
|
+
)
|
|
84
|
+
from trueppm_api.apps.scheduling.risk_premium import (
|
|
85
|
+
build_risk_premium,
|
|
86
|
+
delta_vs_cpm_days,
|
|
87
|
+
risk_premium_for_forecast_payload,
|
|
88
|
+
risk_premium_from_values,
|
|
89
|
+
)
|
|
90
|
+
from trueppm_api.apps.scheduling.serializers import (
|
|
91
|
+
FailedTaskDropSerializer,
|
|
92
|
+
FailedTaskRequeueSerializer,
|
|
93
|
+
FailedTaskSerializer,
|
|
94
|
+
MonteCarloForecastSerializer,
|
|
95
|
+
MonteCarloRunSerializer,
|
|
96
|
+
MonteCarloWhatIfRequestSerializer,
|
|
97
|
+
MonteCarloWhatIfResponseSerializer,
|
|
98
|
+
ProjectForecastSnapshotSerializer,
|
|
99
|
+
ScheduleCpmDerivationSerializer,
|
|
100
|
+
ScheduleMonteCarloDerivationSerializer,
|
|
101
|
+
VelocitySuggestionSerializer,
|
|
102
|
+
)
|
|
103
|
+
from trueppm_api.apps.scheduling.services import (
|
|
104
|
+
build_sched_graph,
|
|
105
|
+
build_sched_tasks,
|
|
106
|
+
enqueue_recalculate,
|
|
107
|
+
forecast_diagnostic,
|
|
108
|
+
record_monte_carlo_run,
|
|
109
|
+
resolve_cpm_status_date,
|
|
110
|
+
)
|
|
111
|
+
from trueppm_api.apps.scheduling.telemetry import monte_carlo_span
|
|
112
|
+
from trueppm_api.workflows.consumers.requeue_failed_task import WORKFLOW_NAME as REQUEUE_WORKFLOW
|
|
113
|
+
from trueppm_api.workflows.services import start_workflow
|
|
114
|
+
|
|
115
|
+
logger = logging.getLogger(__name__)
|
|
116
|
+
|
|
117
|
+
# Version token in the Monte Carlo read-cache key. Bump it in the same commit as
|
|
118
|
+
# any engine change that moves the percentiles for an unchanged project.
|
|
119
|
+
#
|
|
120
|
+
# The entry lives for 24 hours (ADR-0144, #1231) and nothing else invalidates it —
|
|
121
|
+
# not a task edit, not a deploy, since the cache declares no KEY_PREFIX/VERSION and
|
|
122
|
+
# the key carried only the project pk. So an engine correctness fix would keep
|
|
123
|
+
# serving its own pre-fix numbers to `GET /monte-carlo/latest/`, to the MCP
|
|
124
|
+
# forecast tool, and to the derivation ("why") endpoint for a full day after
|
|
125
|
+
# rollout, from a payload with no signal that it is stale. Bumping the token
|
|
126
|
+
# retires the old keyspace at deploy instead (#2833).
|
|
127
|
+
#
|
|
128
|
+
# This does NOT reach the persisted `MonteCarloRun` fallback that serves once the
|
|
129
|
+
# TTL expires: those rows carry no engine-version column, so a project whose last
|
|
130
|
+
# run predates a fix and is never re-run keeps reporting the old answer.
|
|
131
|
+
#
|
|
132
|
+
# v3 (#4204): percentiles are ordered by working-time finish and every delta is
|
|
133
|
+
# measured in working time; a v2 entry's `delta_vs_cpm` diffed shown days.
|
|
134
|
+
MC_LATEST_CACHE_VERSION = 3
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def mc_latest_cache_key(pk: object) -> str:
|
|
138
|
+
"""Read-cache key for a project's latest Monte Carlo forecast.
|
|
139
|
+
|
|
140
|
+
One definition for the write, both reads, and the tests. Three duplicated
|
|
141
|
+
f-strings is how a version bump gets applied to two of them.
|
|
142
|
+
"""
|
|
143
|
+
return f"mc_latest:v{MC_LATEST_CACHE_VERSION}:{pk}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _current_plan_state(pk: uuid.UUID | str) -> tuple[int | None, _datetime | None]:
|
|
147
|
+
"""The project's live plan version and last CPM recalc time (#3140).
|
|
148
|
+
|
|
149
|
+
Both terms the staleness discriminant compares a run against, in one narrow
|
|
150
|
+
``values_list`` rather than loading the row: callers that already hold the project
|
|
151
|
+
read the two attributes directly, and this exists for the ones that do not.
|
|
152
|
+
|
|
153
|
+
``(None, None)`` when the row is gone (a concurrent hard delete), which classifies
|
|
154
|
+
the forecast as ``unknown`` rather than inventing a comparison — see
|
|
155
|
+
:func:`~.forecast_staleness.classify_forecast_staleness`.
|
|
156
|
+
"""
|
|
157
|
+
# `is_deleted=False` matches every sibling project fetch in this module. It changes
|
|
158
|
+
# nothing for the current caller, which already resolved the project through a
|
|
159
|
+
# filtered fetch — it stops the helper failing open on soft-delete for the next one.
|
|
160
|
+
row = (
|
|
161
|
+
Project.objects.filter(pk=pk, is_deleted=False)
|
|
162
|
+
.values_list("last_sync_version", "recalculated_at")
|
|
163
|
+
.first()
|
|
164
|
+
)
|
|
165
|
+
return row if row is not None else (None, None)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Upper bound on how many parked tasks a single bulk requeue/drop touches, so a
|
|
169
|
+
# "drop all" / "requeue all" over a large filtered set cannot unbounded-load the
|
|
170
|
+
# DB or storm the broker (ADR-0210 §4). Oldest-first slice, with a ``capped`` flag
|
|
171
|
+
# in the response so the operator knows to repeat.
|
|
172
|
+
#
|
|
173
|
+
# Read straight off settings, not through `getattr(..., 500)`: the fallback made the
|
|
174
|
+
# knob look tunable while no settings module bound it, so the docs' claim that
|
|
175
|
+
# operators could raise it was false and the 500 always won (#2880). The binding now
|
|
176
|
+
# lives in settings/base.py (env: TRUEPPM_FAILED_TASK_BULK_ACTION_MAX) and a missing
|
|
177
|
+
# one is an AttributeError at import — loud, and impossible to ship silently.
|
|
178
|
+
FAILED_TASK_BULK_ACTION_MAX: int = settings.FAILED_TASK_BULK_ACTION_MAX
|
|
179
|
+
|
|
180
|
+
# Statuses an operator can act on: a task that is already dismissed or retried is
|
|
181
|
+
# terminal and is skipped by bulk actions / rejected by single actions.
|
|
182
|
+
_ACTIONABLE_FAILED_STATUSES = (FailedTaskStatus.DEAD, FailedTaskStatus.PENDING_RETRY)
|
|
183
|
+
|
|
184
|
+
# Shared user-facing response details.
|
|
185
|
+
_NOT_FOUND_DETAIL = "Not found."
|
|
186
|
+
_DATE_RANGE_EXCEEDED_DETAIL = "Project schedule exceeds the representable date range."
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@extend_schema(
|
|
190
|
+
summary="Trigger a CPM recalculation for a project",
|
|
191
|
+
request=None,
|
|
192
|
+
responses={
|
|
193
|
+
202: OpenApiResponse(
|
|
194
|
+
response=inline_serializer(
|
|
195
|
+
name="ScheduleTriggerResult",
|
|
196
|
+
fields={"queued": serializers.BooleanField()},
|
|
197
|
+
),
|
|
198
|
+
description='Recalculation queued via the outbox; body is {"queued": true}.',
|
|
199
|
+
),
|
|
200
|
+
403: OpenApiResponse(
|
|
201
|
+
description=(
|
|
202
|
+
"Caller lacks the Resource Manager role on the project, or the project "
|
|
203
|
+
"is archived (archived plans are read-only, and a recalculation rewrites "
|
|
204
|
+
"every task's dates). Both causes answer the same status — branch on "
|
|
205
|
+
"`detail`, not on 403 alone."
|
|
206
|
+
)
|
|
207
|
+
),
|
|
208
|
+
404: OpenApiResponse(description="Project not found."),
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
@declare_scope_in_body(
|
|
212
|
+
"@api_view function: the generated WrappedAPIView carries no project_url_kwarg, so "
|
|
213
|
+
"has_permission cannot scope this POST. The gate is the explicit "
|
|
214
|
+
"IsProjectScheduler().has_object_permission(request, None, project) call in the body."
|
|
215
|
+
)
|
|
216
|
+
@api_view(["POST"])
|
|
217
|
+
@permission_classes([IsAuthenticated, IsProjectScheduler, IsProjectNotArchived])
|
|
218
|
+
def trigger_schedule(request: Request, pk: str) -> Response:
|
|
219
|
+
"""Manually trigger a CPM recalculation for a project.
|
|
220
|
+
|
|
221
|
+
Requires the requesting user to hold at least the Resource Manager role on the
|
|
222
|
+
project. The request is written to the transactional outbox and dispatched
|
|
223
|
+
immediately if the broker is available; otherwise the Beat drain task picks
|
|
224
|
+
it up within 30 seconds.
|
|
225
|
+
|
|
226
|
+
Returns 404 if the project does not exist.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
project = Project.objects.get(pk=pk, is_deleted=False)
|
|
230
|
+
except Project.DoesNotExist:
|
|
231
|
+
return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
|
|
232
|
+
|
|
233
|
+
# Permission check against the project object.
|
|
234
|
+
if not IsProjectScheduler().has_object_permission(request, None, project): # type: ignore[arg-type]
|
|
235
|
+
return Response({"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN)
|
|
236
|
+
|
|
237
|
+
# ...and the archived check, in the body for the same reason the membership check
|
|
238
|
+
# is (#3414). `IsProjectNotArchived` IS declared above, but this is an `@api_view`
|
|
239
|
+
# function: the generated `WrappedAPIView` carries no `project_url_kwarg`, the route
|
|
240
|
+
# spells the project `pk`, so `_project_pk_from_view` returns None and
|
|
241
|
+
# `has_permission` returns True — and DRF never calls `has_object_permission` on a
|
|
242
|
+
# function view. The declaration read as the gate while a manual recalculation ran
|
|
243
|
+
# happily against an archived plan, rewriting every task's dates.
|
|
244
|
+
assert_project_not_archived(project.pk)
|
|
245
|
+
|
|
246
|
+
# Defer until any outer transaction commits so the ScheduleRequest row is
|
|
247
|
+
# never visible to the drain before its parent write lands.
|
|
248
|
+
project_id = str(project.pk)
|
|
249
|
+
transaction.on_commit(
|
|
250
|
+
lambda: enqueue_recalculate(project_id, reason=ScheduleRequestReason.MANUAL)
|
|
251
|
+
)
|
|
252
|
+
return Response({"queued": True}, status=status.HTTP_202_ACCEPTED)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# The premium derivation moved to `risk_premium` when the metric was promoted off
|
|
256
|
+
# this payload onto the project (#2483). Aliased rather than reimplemented so the
|
|
257
|
+
# forecast panel and the Overview card can never disagree about the same gap.
|
|
258
|
+
_delta_vs_cpm_days = delta_vs_cpm_days
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _date_delta_days(
|
|
262
|
+
later: _date | None,
|
|
263
|
+
earlier: _date | None,
|
|
264
|
+
*,
|
|
265
|
+
later_at_day_start: bool | None = None,
|
|
266
|
+
earlier_at_day_start: bool | None = None,
|
|
267
|
+
calendar_for: Callable[[], Any] | None = None,
|
|
268
|
+
) -> int | None:
|
|
269
|
+
"""Signed delta ``later - earlier`` of two shown finishes, in working time.
|
|
270
|
+
|
|
271
|
+
Positive means ``later`` lands after ``earlier`` (the forecast slipped). Shared
|
|
272
|
+
by the what-if and history endpoints so a run-to-run / current-to-whatif delta is
|
|
273
|
+
computed one way. Each date is read with its edge of the day (a Monte Carlo
|
|
274
|
+
percentile's ``*_at_day_start``, #4204), so a percentile whose shown day hops a
|
|
275
|
+
weekend with no working-time move is 0; an unknown reading (a run persisted
|
|
276
|
+
before #4204) follows :func:`.finish_reading.finish_shift_days`. Still reported
|
|
277
|
+
in calendar days. ``None`` if either date is missing.
|
|
278
|
+
"""
|
|
279
|
+
if later is None or earlier is None:
|
|
280
|
+
return None
|
|
281
|
+
return lazy_finish_shift_days(
|
|
282
|
+
(earlier, earlier_at_day_start), (later, later_at_day_start), calendar_for
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _mc_percentile_deltas(
|
|
287
|
+
later: Any, earlier: Any, calendar_for: Callable[[], Any] | None
|
|
288
|
+
) -> dict[str, int | None]:
|
|
289
|
+
"""``{p50, p80, p95}`` working-time deltas between two Monte Carlo results or runs."""
|
|
290
|
+
return {
|
|
291
|
+
q: _date_delta_days(
|
|
292
|
+
getattr(later, q),
|
|
293
|
+
getattr(earlier, q),
|
|
294
|
+
later_at_day_start=getattr(later, f"{q}_at_day_start", None),
|
|
295
|
+
earlier_at_day_start=getattr(earlier, f"{q}_at_day_start", None),
|
|
296
|
+
calendar_for=calendar_for,
|
|
297
|
+
)
|
|
298
|
+
for q in ("p50", "p80", "p95")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _mc_deltas_vs_cpm(
|
|
303
|
+
run: Any,
|
|
304
|
+
cpm_finish: _date | None,
|
|
305
|
+
cpm_finish_at_day_start: bool | None,
|
|
306
|
+
calendar_for: Callable[[], Any] | None,
|
|
307
|
+
) -> dict[str, int | None]:
|
|
308
|
+
"""``{p50, p80, p95}`` of ``run`` vs the CPM finish, in working time (#4204)."""
|
|
309
|
+
return {
|
|
310
|
+
q: _delta_vs_cpm_days(
|
|
311
|
+
getattr(run, q),
|
|
312
|
+
cpm_finish,
|
|
313
|
+
percentile_at_day_start=getattr(run, f"{q}_at_day_start", None),
|
|
314
|
+
cpm_finish_at_day_start=cpm_finish_at_day_start,
|
|
315
|
+
calendar_for=calendar_for,
|
|
316
|
+
)
|
|
317
|
+
for q in ("p50", "p80", "p95")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _mc_run_readings(run: Any) -> dict[str, bool | None]:
|
|
322
|
+
"""The four edge-of-day readings of a persisted run, for a response payload."""
|
|
323
|
+
return {
|
|
324
|
+
f: getattr(run, f)
|
|
325
|
+
for f in (
|
|
326
|
+
"p50_at_day_start",
|
|
327
|
+
"p80_at_day_start",
|
|
328
|
+
"p95_at_day_start",
|
|
329
|
+
"cpm_finish_at_day_start",
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _cpm_finish_delta_days(earlier: Any, later: Any, calendar: Any) -> int | None:
|
|
335
|
+
"""Signed shift of ``ScheduleResult.project_finish`` between two CPM passes, in working time.
|
|
336
|
+
|
|
337
|
+
Unlike :func:`_date_delta_days`, this reads each finish with its edge of the
|
|
338
|
+
day, so the shown day hopping a weekend with no working-time move is 0
|
|
339
|
+
(:mod:`.finish_reading`, #4178). Still reported in calendar days.
|
|
340
|
+
"""
|
|
341
|
+
before = schedule_result_finish(earlier)
|
|
342
|
+
after = schedule_result_finish(later)
|
|
343
|
+
if before is None or after is None:
|
|
344
|
+
return None
|
|
345
|
+
return finish_shift_days(before, after, calendar)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _confidence_curve(histogram: list[dict[str, object]], total: int) -> list[dict[str, object]]:
|
|
349
|
+
"""Cumulative P(finish ≤ date) S-curve derived from the histogram buckets.
|
|
350
|
+
|
|
351
|
+
``histogram`` is the already-binned ``[{date, count}]`` list in ascending date
|
|
352
|
+
order; ``total`` is the number of simulated runs. Returns one ``{date, pct}``
|
|
353
|
+
point per bucket carrying the cumulative share of runs that finished on or
|
|
354
|
+
before that bucket — the same value ``MonteCarloDetailPanel`` previously
|
|
355
|
+
accumulated in the browser (#987). Bounded to the bucket count (≤30 points).
|
|
356
|
+
"""
|
|
357
|
+
if total <= 0:
|
|
358
|
+
return []
|
|
359
|
+
cumulative = 0
|
|
360
|
+
curve: list[dict[str, object]] = []
|
|
361
|
+
for bucket in histogram:
|
|
362
|
+
cumulative += cast(int, bucket["count"])
|
|
363
|
+
curve.append({"date": bucket["date"], "pct": round(cumulative / total * 100, 1)})
|
|
364
|
+
return curve
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _distribution_for_persist(payload: dict[str, Any]) -> dict[str, Any]:
|
|
368
|
+
"""Shrink the distribution payload to fit MC_DISTRIBUTION_MAX_BYTES for storage (#1231).
|
|
369
|
+
|
|
370
|
+
Takes the ``{histogram_buckets, confidence_curve, sensitivity}`` slice of the
|
|
371
|
+
full result and, if its serialized size exceeds the cap, down-samples the
|
|
372
|
+
histogram (keeps every Nth bucket, always retaining the first and last so the
|
|
373
|
+
spread endpoints survive) until it fits. The confidence curve is recomputed
|
|
374
|
+
from the thinned buckets so the two stay consistent; sensitivity is left intact
|
|
375
|
+
(it is bounded by the task count, not the bucket count).
|
|
376
|
+
|
|
377
|
+
Returns a NEW dict — the caller's full ``result_dict`` (and the cache copy it
|
|
378
|
+
feeds) is never mutated, so the live view still serves the full-resolution
|
|
379
|
+
histogram while the persisted row is bounded against a pathological run.
|
|
380
|
+
"""
|
|
381
|
+
dist = {
|
|
382
|
+
"histogram_buckets": payload.get("histogram_buckets", []),
|
|
383
|
+
"confidence_curve": payload.get("confidence_curve", []),
|
|
384
|
+
"sensitivity": payload.get("sensitivity", []),
|
|
385
|
+
}
|
|
386
|
+
cap = settings.MC_DISTRIBUTION_MAX_BYTES
|
|
387
|
+
if len(json.dumps(dist).encode()) <= cap:
|
|
388
|
+
return dist
|
|
389
|
+
|
|
390
|
+
buckets: list[dict[str, object]] = list(dist["histogram_buckets"])
|
|
391
|
+
total = sum(cast(int, b.get("count", 0)) for b in buckets)
|
|
392
|
+
# Increase the stride until the serialized payload fits. Always keep the first
|
|
393
|
+
# and last bucket so the distribution's min/max dates are preserved.
|
|
394
|
+
stride = 2
|
|
395
|
+
while len(json.dumps(dist).encode()) > cap and stride <= max(len(buckets), 2):
|
|
396
|
+
thinned = [b for i, b in enumerate(buckets) if i % stride == 0]
|
|
397
|
+
if buckets and buckets[-1] not in thinned:
|
|
398
|
+
thinned.append(buckets[-1])
|
|
399
|
+
dist = {
|
|
400
|
+
"histogram_buckets": thinned,
|
|
401
|
+
"confidence_curve": _confidence_curve(thinned, total),
|
|
402
|
+
"sensitivity": dist["sensitivity"],
|
|
403
|
+
}
|
|
404
|
+
stride += 1
|
|
405
|
+
return dist
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _build_sched_deps(project_pk: str, included_ids: set[str]) -> list[Any]:
|
|
409
|
+
"""Convert a project's DB dependencies to scheduler ``Dependency`` inputs.
|
|
410
|
+
|
|
411
|
+
Drops any edge whose predecessor or successor is absent from ``included_ids``:
|
|
412
|
+
cross-project dependencies and edges to non-committed (BACKLOG) tasks both dangle
|
|
413
|
+
and would make the engine reject the network as referencing an unknown task.
|
|
414
|
+
Mirrors the CPM guard in scheduling.tasks (ADR-0090) — a single-project run
|
|
415
|
+
simulates only this project's committed tasks.
|
|
416
|
+
|
|
417
|
+
Reads through ``Dependency.live``: a soft-deleted edge is a constraint the
|
|
418
|
+
user removed, and forecasting against it reports a finish the plan no longer
|
|
419
|
+
implies (#3532).
|
|
420
|
+
"""
|
|
421
|
+
from trueppm_scheduler.models import Dependency as SchedDependency
|
|
422
|
+
from trueppm_scheduler.models import DependencyType
|
|
423
|
+
|
|
424
|
+
db_deps = list(
|
|
425
|
+
Dependency.live.filter(predecessor__project_id=project_pk).select_related(
|
|
426
|
+
"predecessor", "successor"
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
return [
|
|
430
|
+
SchedDependency(
|
|
431
|
+
predecessor_id=str(d.predecessor_id),
|
|
432
|
+
successor_id=str(d.successor_id),
|
|
433
|
+
dep_type=DependencyType(d.dep_type),
|
|
434
|
+
lag=timedelta(days=d.lag),
|
|
435
|
+
)
|
|
436
|
+
for d in db_deps
|
|
437
|
+
if str(d.predecessor_id) in included_ids and str(d.successor_id) in included_ids
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _mc_histogram(dist: list[_date]) -> list[dict[str, object]]:
|
|
442
|
+
"""Bin a Monte Carlo finish-date distribution into ≤30 ascending ``{date, count}``
|
|
443
|
+
buckets.
|
|
444
|
+
|
|
445
|
+
``dist`` is the engine's sorted finish-date sample (earliest first). Returns an
|
|
446
|
+
empty list for an empty distribution. The midpoint date of each bucket is used as
|
|
447
|
+
its label so the histogram is symmetric around the sampled span.
|
|
448
|
+
"""
|
|
449
|
+
if not dist:
|
|
450
|
+
return []
|
|
451
|
+
min_ord = dist[0].toordinal()
|
|
452
|
+
max_ord = dist[-1].toordinal()
|
|
453
|
+
span = max(max_ord - min_ord, 1)
|
|
454
|
+
n_buckets = min(30, len(dist))
|
|
455
|
+
bucket_size = span / n_buckets
|
|
456
|
+
bucket_counts: dict[int, int] = {}
|
|
457
|
+
for d in dist:
|
|
458
|
+
idx = min(int((d.toordinal() - min_ord) / bucket_size), n_buckets - 1)
|
|
459
|
+
bucket_counts[idx] = bucket_counts.get(idx, 0) + 1
|
|
460
|
+
return [
|
|
461
|
+
{
|
|
462
|
+
"date": _date.fromordinal(min_ord + int((i + 0.5) * bucket_size)).isoformat(),
|
|
463
|
+
"count": bucket_counts.get(i, 0),
|
|
464
|
+
}
|
|
465
|
+
for i in range(n_buckets)
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _persist_mc_run_if_authorized(
|
|
470
|
+
request: Request,
|
|
471
|
+
project: Project,
|
|
472
|
+
*,
|
|
473
|
+
mc_result: Any,
|
|
474
|
+
n_simulations: int,
|
|
475
|
+
cpm_finish: _date | None,
|
|
476
|
+
task_count: int,
|
|
477
|
+
result_dict: dict[str, Any],
|
|
478
|
+
status_date: _date | None,
|
|
479
|
+
plan_version: int | None,
|
|
480
|
+
cpm_finish_at_day_start: bool | None = None,
|
|
481
|
+
) -> None:
|
|
482
|
+
"""Persist an author-attributed ``MonteCarloRun`` drift row for Scheduler+ callers.
|
|
483
|
+
|
|
484
|
+
The forecast *read* is intentionally open to any project member (a Viewer may pull
|
|
485
|
+
the band), but the persisted run is a different act: it attributes drift to its
|
|
486
|
+
author and grows the retained-run set (#1502). Gating the read at Member and the
|
|
487
|
+
write at Scheduler stops a Viewer/Member from spamming ``run_monte_carlo`` to
|
|
488
|
+
inflate history and pollute drift attribution over a schedule they cannot own. The
|
|
489
|
+
``mc_latest`` cache is a per-project read cache, not per-user history, so it stays
|
|
490
|
+
refreshed for every caller — only the attributed row is gated here.
|
|
491
|
+
|
|
492
|
+
Best-effort: a write failure inside the service is logged and swallowed by
|
|
493
|
+
``record_monte_carlo_run``; ``result_dict`` gains ``run_id`` only when persistence
|
|
494
|
+
succeeded. Mutates ``result_dict`` in place.
|
|
495
|
+
"""
|
|
496
|
+
from trueppm_api.apps.access.models import Role
|
|
497
|
+
from trueppm_api.apps.access.permissions import _membership_role
|
|
498
|
+
|
|
499
|
+
# Pass project.pk (canonical UUID), not the raw URL string, so this reuses the
|
|
500
|
+
# per-request role cache the earlier object-permission check already warmed under
|
|
501
|
+
# the same key — no second membership query.
|
|
502
|
+
role = _membership_role(request, project.pk)
|
|
503
|
+
if role is None or role < Role.SCHEDULER:
|
|
504
|
+
return
|
|
505
|
+
|
|
506
|
+
# Persist the same distribution slice the cache holds, but bounded to
|
|
507
|
+
# MC_DISTRIBUTION_MAX_BYTES via down-sampling (#1231) — the cache copy is
|
|
508
|
+
# full-resolution and untouched. Stored so the histogram + tornado survive cache
|
|
509
|
+
# expiry and a past run stays viewable.
|
|
510
|
+
distribution = _distribution_for_persist(result_dict)
|
|
511
|
+
run = record_monte_carlo_run(
|
|
512
|
+
str(project.pk),
|
|
513
|
+
p50=mc_result.p50,
|
|
514
|
+
p80=mc_result.p80,
|
|
515
|
+
p95=mc_result.p95,
|
|
516
|
+
n_simulations=n_simulations,
|
|
517
|
+
cpm_finish=cpm_finish,
|
|
518
|
+
task_count=task_count,
|
|
519
|
+
user=request.user,
|
|
520
|
+
distribution=distribution,
|
|
521
|
+
# Store the reason the run is (or is not) flat alongside it, so a later
|
|
522
|
+
# read from history can still tell "no estimates anywhere" apart from
|
|
523
|
+
# "estimates exist, variance is low" — the distinction the added-time
|
|
524
|
+
# metric is built on (#2483).
|
|
525
|
+
diagnostic=result_dict.get("forecast_diagnostic"),
|
|
526
|
+
# The data date actually fed to the engine for this run (ADR-0132's
|
|
527
|
+
# never-recorded substitution, #2638) — same value already echoed on
|
|
528
|
+
# result_dict below, so the persisted row and the response agree.
|
|
529
|
+
status_date=status_date,
|
|
530
|
+
# The plan version the run was computed against (#3140) — the same value
|
|
531
|
+
# already on result_dict, so the persisted row and the cache entry give the
|
|
532
|
+
# same answer once the TTL expires and the read falls back to history.
|
|
533
|
+
plan_version=plan_version,
|
|
534
|
+
# Which edge of its shown day each finish is (#4204), so a later history or
|
|
535
|
+
# `latest` read measures its deltas in working time too.
|
|
536
|
+
readings={
|
|
537
|
+
"p50_at_day_start": mc_result.p50_at_day_start,
|
|
538
|
+
"p80_at_day_start": mc_result.p80_at_day_start,
|
|
539
|
+
"p95_at_day_start": mc_result.p95_at_day_start,
|
|
540
|
+
"cpm_finish_at_day_start": cpm_finish_at_day_start,
|
|
541
|
+
},
|
|
542
|
+
)
|
|
543
|
+
if run is not None:
|
|
544
|
+
result_dict["run_id"] = str(run.id)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _mc_lag_delta_kwargs() -> dict[str, int]:
|
|
548
|
+
"""Request-path ``monte_carlo()`` kwargs bounding the lag-delta table (#4129).
|
|
549
|
+
|
|
550
|
+
Empty when ``MC_LAG_DELTA_CELL_CAP`` is ``None`` so the library default applies.
|
|
551
|
+
"""
|
|
552
|
+
cap = getattr(settings, "MC_LAG_DELTA_CELL_CAP", None)
|
|
553
|
+
return {} if cap is None else {"max_lag_delta_cells": cap}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class MonteCarloRunThrottle(ScopedRateThrottle):
|
|
557
|
+
"""Caps synchronous Monte Carlo runs per member (#1552) to bound DoS.
|
|
558
|
+
|
|
559
|
+
``run_monte_carlo`` executes an expensive simulation inline in the request
|
|
560
|
+
cycle, gated only by project membership. Scoped per-user via ``monte_carlo``
|
|
561
|
+
(10/min) so no single account can loop the endpoint to exhaust CPU, while a
|
|
562
|
+
human tuning estimates and re-running the forecast stays well under the cap.
|
|
563
|
+
|
|
564
|
+
``ScopedRateThrottle`` normally reads its scope from ``view.throttle_scope``,
|
|
565
|
+
but ``run_monte_carlo`` is a function-based ``@api_view`` that cannot carry
|
|
566
|
+
that attribute, so the scope is bound from this class instead. The cache key
|
|
567
|
+
is still per-user + scope (``get_cache_key`` is inherited unchanged).
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
scope = "monte_carlo"
|
|
571
|
+
|
|
572
|
+
def allow_request(self, request: Request, view: APIView) -> bool:
|
|
573
|
+
# Bind the fixed scope from the class rather than off the view (an
|
|
574
|
+
# @api_view FBV has no throttle_scope), then run the grandparent's
|
|
575
|
+
# sliding-window check directly. We deliberately skip
|
|
576
|
+
# ScopedRateThrottle.allow_request (it re-reads view.throttle_scope,
|
|
577
|
+
# absent on the FBV) by calling SimpleRateThrottle explicitly instead of
|
|
578
|
+
# via super() — same resolution, but honest about the intentional skip
|
|
579
|
+
# and it doesn't trip CodeQL's "super() first arg" reliability rule.
|
|
580
|
+
self.rate = self.get_rate()
|
|
581
|
+
self.num_requests, self.duration = self.parse_rate(self.rate)
|
|
582
|
+
return SimpleRateThrottle.allow_request(self, request, view)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@extend_schema(
|
|
586
|
+
request=OpenApiTypes.OBJECT,
|
|
587
|
+
responses={
|
|
588
|
+
200: OpenApiResponse(
|
|
589
|
+
response=MonteCarloForecastSerializer,
|
|
590
|
+
description=(
|
|
591
|
+
"Monte Carlo simulation result. Includes the engine result fields "
|
|
592
|
+
"(P50/P80/P95 finish dates, mean, std dev, etc.) plus "
|
|
593
|
+
"cpm_finish (deterministic CPM project finish, ISO 8601 or null), "
|
|
594
|
+
"p50_at_day_start/p80_at_day_start/p95_at_day_start and "
|
|
595
|
+
"cpm_finish_at_day_start (which edge of its shown day each finish is — true = "
|
|
596
|
+
"the start of the day, a start-of-day milestone finish; #4204), "
|
|
597
|
+
"delta_vs_cpm ({p50,p80,p95} signed calendar-day premium vs CPM, measured "
|
|
598
|
+
"in working time: the start of a Monday vs the end of the Friday before it "
|
|
599
|
+
"is 0), "
|
|
600
|
+
"confidence_curve ([{date, pct}] cumulative finish-by-date S-curve), "
|
|
601
|
+
"histogram_buckets ([{date, count}]), sensitivity ([{task_id, index}] "
|
|
602
|
+
"duration tornado — tasks that move the finish most, index 0..1, "
|
|
603
|
+
"ADR-0140), forecast_diagnostic ({deterministic, reason, tasks_total, "
|
|
604
|
+
"tasks_with_variance, tasks_pending_approval, agile_tasks_without_velocity, "
|
|
605
|
+
"tasks_estimates_below_plan} "
|
|
606
|
+
"— explains a flat forecast; reason is null when a real band exists), "
|
|
607
|
+
"last_run_at (ISO 8601), status_date (the data date this run was "
|
|
608
|
+
"actually computed against — project.status_date or today when unset, "
|
|
609
|
+
"ADR-0132/#2638), and the risk_premium_* family (ADR-0698): "
|
|
610
|
+
"risk_premium_state (not_run|unmeasurable|stale|zero|premium|negative — "
|
|
611
|
+
"the discriminant every client renders from, never null), "
|
|
612
|
+
"risk_premium_days, risk_premium_ratio, risk_premium_band (always null "
|
|
613
|
+
"until #2299), risk_premium_as_of, risk_premium_reason, "
|
|
614
|
+
"risk_premium_cpm_finish, risk_premium_p80. Derived at response time, "
|
|
615
|
+
"never cached, so the ratio never describes a remaining duration that "
|
|
616
|
+
"has since elapsed. Plus the forecast-staleness family (#3140): "
|
|
617
|
+
"forecast_staleness (current|project_changed|aged|unknown — the "
|
|
618
|
+
"discriminant every client renders from, never null; only `current` "
|
|
619
|
+
"means the forecast still describes the plan, and clients keep the "
|
|
620
|
+
"recompute action reachable on the other three), plan_version (the "
|
|
621
|
+
"project's sync version this run was computed against; null for runs "
|
|
622
|
+
"recorded before #3140, which classify as `unknown`, never `current`), "
|
|
623
|
+
"and plan_version_current (that version as of this response). "
|
|
624
|
+
"`project_changed` is deliberately not named `plan_changed`: the "
|
|
625
|
+
"underlying counter advances on ANY write in the project, so a "
|
|
626
|
+
"description or label edit raises it without moving a date. It is a "
|
|
627
|
+
"sound trigger for offering a recompute and NOT evidence that a "
|
|
628
|
+
"schedule value changed — do not narrate it as one."
|
|
629
|
+
),
|
|
630
|
+
),
|
|
631
|
+
400: OpenApiResponse(
|
|
632
|
+
response=OpenApiTypes.OBJECT,
|
|
633
|
+
description=(
|
|
634
|
+
"Cyclic dependency, invalid input, or out-of-range project span; or "
|
|
635
|
+
"an explicit n_simulations whose runs x tasks product exceeds "
|
|
636
|
+
"MC_CELL_CAP (#2839) — body {error: simulation_cell_cap_exceeded, "
|
|
637
|
+
"message, max_runs, task_count}, where max_runs is the largest count "
|
|
638
|
+
"this project admits. An omitted n_simulations is never refused for "
|
|
639
|
+
"size: it runs max_runs iterations and the 200 body's `runs` reports "
|
|
640
|
+
"the count actually simulated."
|
|
641
|
+
),
|
|
642
|
+
examples=[
|
|
643
|
+
OpenApiExample(
|
|
644
|
+
"simulation_cell_cap_exceeded",
|
|
645
|
+
value={
|
|
646
|
+
"error": "simulation_cell_cap_exceeded",
|
|
647
|
+
"message": "1000 simulation runs over 5000 tasks exceeds ...",
|
|
648
|
+
"max_runs": 500,
|
|
649
|
+
"task_count": 5000,
|
|
650
|
+
},
|
|
651
|
+
),
|
|
652
|
+
],
|
|
653
|
+
),
|
|
654
|
+
402: OpenApiResponse(
|
|
655
|
+
response=OpenApiTypes.OBJECT,
|
|
656
|
+
description=(
|
|
657
|
+
"OSS simulation cap exceeded (n_simulations above MC_SIMULATION_CAP "
|
|
658
|
+
"or too many tasks)."
|
|
659
|
+
),
|
|
660
|
+
examples=[
|
|
661
|
+
OpenApiExample(
|
|
662
|
+
"simulation_cap_exceeded",
|
|
663
|
+
value={
|
|
664
|
+
"error": "simulation_cap_exceeded",
|
|
665
|
+
"tier": "team",
|
|
666
|
+
"message": "Simulation count exceeds the community-edition cap.",
|
|
667
|
+
},
|
|
668
|
+
),
|
|
669
|
+
],
|
|
670
|
+
),
|
|
671
|
+
403: OpenApiResponse(
|
|
672
|
+
response=OpenApiTypes.OBJECT,
|
|
673
|
+
description=(
|
|
674
|
+
"Caller is not a member of the project, or the project is archived — a "
|
|
675
|
+
"run refreshes the project's forecast cache and, for Scheduler+, "
|
|
676
|
+
"appends a drift-history row, so it is a write."
|
|
677
|
+
),
|
|
678
|
+
),
|
|
679
|
+
404: OpenApiResponse(
|
|
680
|
+
response=OpenApiTypes.OBJECT,
|
|
681
|
+
description="Project does not exist.",
|
|
682
|
+
),
|
|
683
|
+
503: OpenApiResponse(
|
|
684
|
+
response=OpenApiTypes.OBJECT,
|
|
685
|
+
description=(
|
|
686
|
+
"This API process is already running MC_MAX_CONCURRENT simulations "
|
|
687
|
+
"and none finished within the wait window (#2839). Body {error: "
|
|
688
|
+
"simulation_busy, message}; retry after the Retry-After header."
|
|
689
|
+
),
|
|
690
|
+
),
|
|
691
|
+
},
|
|
692
|
+
)
|
|
693
|
+
@declare_scope_in_body(
|
|
694
|
+
"@api_view function: the generated WrappedAPIView carries no project_url_kwarg, so "
|
|
695
|
+
"has_permission cannot scope this POST. The gate is the explicit "
|
|
696
|
+
"IsProjectMember().has_object_permission(request, None, project) call in the body."
|
|
697
|
+
)
|
|
698
|
+
@api_view(["POST"])
|
|
699
|
+
@permission_classes([IsAuthenticated, IsProjectMember, IsProjectNotArchived])
|
|
700
|
+
@throttle_classes([MonteCarloRunThrottle])
|
|
701
|
+
def run_monte_carlo(request: Request, pk: str) -> Response:
|
|
702
|
+
"""Run a Monte Carlo probabilistic schedule simulation synchronously.
|
|
703
|
+
|
|
704
|
+
Monte Carlo is fast (< 100 ms vectorised) so this runs in the request/
|
|
705
|
+
response cycle rather than via Celery; the result is returned directly. The
|
|
706
|
+
per-project ``mc_latest`` read cache is refreshed for every caller, but a
|
|
707
|
+
persisted, author-attributed drift-history row (MonteCarloRun) is written
|
|
708
|
+
only for Scheduler+ — the forecast read is Member-level, its record is not
|
|
709
|
+
(#1502). Requests are rate-limited per user via the ``monte_carlo`` scope.
|
|
710
|
+
|
|
711
|
+
Synchronous-by-design decision (#1203): keeping this inline is only safe
|
|
712
|
+
because *every* multiplicative cost factor the run depends on is now bounded
|
|
713
|
+
by the engine — tasks (MC_TASK_CAP), simulation runs (MC_SIMULATION_CAP),
|
|
714
|
+
raw dependency edges (MAX_DEPENDENCIES), expanded edges (MAX_EXPANDED_EDGES),
|
|
715
|
+
the distinct-lag delta arrays (MC_LAG_DELTA_CELL_CAP, 5M int32 cells = ~20 MB —
|
|
716
|
+
the library's 50M-cell MAX_LAG_DELTA_CELLS is a batch-caller default), and the
|
|
717
|
+
velocity horizon (MAX_VELOCITY_SPRINTS) — and #1201 vectorised the previously O(edges x span)
|
|
718
|
+
per-edge lag precompute. With the input space bounded and the hot path
|
|
719
|
+
vectorised, the worst-case wall time stays within the request budget, so
|
|
720
|
+
moving to a Celery job (extra latency, result polling, a new failure surface)
|
|
721
|
+
would add cost without removing a real risk. The argument is about memory as
|
|
722
|
+
well as wall time (#4129): the lag-delta table scales with distinct lags x
|
|
723
|
+
span, not with runs, and was the one factor no request cap bounded (a project
|
|
724
|
+
inside every other cap peaked near 630 MB), so it is now capped at
|
|
725
|
+
MC_LAG_DELTA_CELL_CAP and the table stays at ~20 MB per simulation.
|
|
726
|
+
Concurrent-request admission is bounded per process by MC_MAX_CONCURRENT
|
|
727
|
+
(#2839, below). A project that breaches the edge
|
|
728
|
+
cap is rejected as InvalidScheduleInput (a ValueError) and surfaces below as a
|
|
729
|
+
clean 400 rather than a stalled worker.
|
|
730
|
+
|
|
731
|
+
That argument depends on cap *order*, which #3527 had to preserve deliberately.
|
|
732
|
+
Summary expansion moved onto this path, and it walks the edge list — so it now
|
|
733
|
+
runs behind ``build_sched_graph``'s own O(1) ``MAX_DEPENDENCIES`` length check
|
|
734
|
+
rather than waiting for ``_validate_project``, which the engine does not reach
|
|
735
|
+
until ``monte_carlo()`` is already called. Without that, the expensive per-edge
|
|
736
|
+
step would run ahead of the cheap guard that is supposed to bound it.
|
|
737
|
+
|
|
738
|
+
The per-dimension caps still let ``runs x tasks`` reach 5M cells (~234 MB traced
|
|
739
|
+
per simulation), so admission also caps that product (MC_CELL_CAP) and the
|
|
740
|
+
simulations one process runs at once (MC_MAX_CONCURRENT) — see
|
|
741
|
+
``mc_admission`` (#2839).
|
|
742
|
+
|
|
743
|
+
Request body (all optional):
|
|
744
|
+
n_simulations (int): Number of simulation runs. Defaults to the largest
|
|
745
|
+
count this project's size admits — settings.MC_SIMULATION_CAP, or
|
|
746
|
+
fewer under MC_CELL_CAP on a large project; the response's ``runs``
|
|
747
|
+
reports the count simulated. An explicit value is honored exactly or
|
|
748
|
+
refused; it must not exceed either cap.
|
|
749
|
+
|
|
750
|
+
Returns 200 on success, 402 if the OSS simulation cap is exceeded,
|
|
751
|
+
400 if the schedule input is invalid (e.g. the dependency-edge cap is
|
|
752
|
+
breached) or an explicit n_simulations breaches MC_CELL_CAP, 503 if the
|
|
753
|
+
process has no free simulation slot, 404 if the project does not exist,
|
|
754
|
+
403 if the caller lacks read access.
|
|
755
|
+
"""
|
|
756
|
+
from trueppm_scheduler.engine import CyclicDependencyError, SimulationCapExceeded, monte_carlo
|
|
757
|
+
from trueppm_scheduler.models import Project as SchedProject
|
|
758
|
+
|
|
759
|
+
try:
|
|
760
|
+
project = (
|
|
761
|
+
Project.objects.select_related("calendar", "program__calendar")
|
|
762
|
+
# calendar__exceptions + calendar_layers__calendar__exceptions:
|
|
763
|
+
# compose_project_calendar reads the base calendar's exceptions (#1491)
|
|
764
|
+
# AND every applied overlay's (#906); prefetch both to avoid an N+1.
|
|
765
|
+
.prefetch_related(
|
|
766
|
+
"tasks",
|
|
767
|
+
"calendar__exceptions",
|
|
768
|
+
"calendar_layers__calendar__exceptions",
|
|
769
|
+
# ADR-0441: the resolver reads the program calendar's exceptions when a
|
|
770
|
+
# project inherits its base calendar from the program.
|
|
771
|
+
"program__calendar__exceptions",
|
|
772
|
+
)
|
|
773
|
+
.get(pk=pk, is_deleted=False)
|
|
774
|
+
)
|
|
775
|
+
except Project.DoesNotExist:
|
|
776
|
+
return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
|
|
777
|
+
|
|
778
|
+
if not IsProjectMember().has_object_permission(request, None, project): # type: ignore[arg-type]
|
|
779
|
+
return Response({"detail": "Permission denied."}, status=status.HTTP_403_FORBIDDEN)
|
|
780
|
+
|
|
781
|
+
# Same #3414 shape as `trigger_schedule`: the declared `IsProjectNotArchived` cannot
|
|
782
|
+
# fire on an `@api_view` function whose route spells the project `pk`. A run is not a
|
|
783
|
+
# pure read — it refreshes the project's `mc_latest` cache and, for Scheduler+,
|
|
784
|
+
# appends an author-attributed `MonteCarloRun` row, so an archived plan's forecast
|
|
785
|
+
# history could be extended indefinitely after it was frozen.
|
|
786
|
+
assert_project_not_archived(project.pk)
|
|
787
|
+
|
|
788
|
+
cap: int | None = settings.MC_SIMULATION_CAP
|
|
789
|
+
# A JSON `null` (or absent) body parses to request.data == None; the body is
|
|
790
|
+
# documented as fully optional, so fall back to the default rather than
|
|
791
|
+
# letting None.get(...) raise AttributeError → 500 (#2310).
|
|
792
|
+
body = request.data if isinstance(request.data, dict) else {}
|
|
793
|
+
# Absent means "the admissible maximum for this project's size" (#2839), which
|
|
794
|
+
# `admit_runs` resolves once the network size is known; an explicit value is
|
|
795
|
+
# honored exactly or refused, never silently shrunk.
|
|
796
|
+
n_explicit = "n_simulations" in body
|
|
797
|
+
raw_n = body.get("n_simulations", cap or 1_000)
|
|
798
|
+
# int() coerces without raising on a JSON bool (bool is an int subtype) or a
|
|
799
|
+
# JSON float (silently truncated) — neither is the documented integer
|
|
800
|
+
# contract, so reject both before the cast rather than trusting int() to
|
|
801
|
+
# catch them (#4208). Digit strings ("100") stay allowed for callers that
|
|
802
|
+
# send n_simulations as text.
|
|
803
|
+
if isinstance(raw_n, bool) or not isinstance(raw_n, (int, str)):
|
|
804
|
+
return Response(
|
|
805
|
+
{"detail": "n_simulations must be an integer."},
|
|
806
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
807
|
+
)
|
|
808
|
+
try:
|
|
809
|
+
n_simulations: int = int(raw_n)
|
|
810
|
+
except (TypeError, ValueError):
|
|
811
|
+
return Response(
|
|
812
|
+
{"detail": "n_simulations must be an integer."},
|
|
813
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
814
|
+
)
|
|
815
|
+
if n_simulations < 1:
|
|
816
|
+
return Response(
|
|
817
|
+
{"detail": "n_simulations must be a positive integer."},
|
|
818
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
# The plan version this run is about to be computed against (#3140) — the
|
|
822
|
+
# project's ADR-0686 sync sequence, already on the instance loaded above, so
|
|
823
|
+
# this costs nothing. Read HERE, before the committed task set is loaded
|
|
824
|
+
# below, and the ordering is the correctness property: a write that lands
|
|
825
|
+
# between this line and the query leaves the run recorded against the older
|
|
826
|
+
# version and therefore reading stale. Capturing it afterwards would claim
|
|
827
|
+
# the run covered a write it never saw — false-fresh, the one direction that
|
|
828
|
+
# loses the user the Rerun action.
|
|
829
|
+
plan_version = project.last_sync_version
|
|
830
|
+
|
|
831
|
+
# Shared converter (#1491): includes the calendar's CalendarException
|
|
832
|
+
# holiday/shutdown ranges, which the previous inline construction dropped —
|
|
833
|
+
# so Monte Carlo silently scheduled straight through configured holidays.
|
|
834
|
+
sched_calendar = compose_project_calendar(project)
|
|
835
|
+
|
|
836
|
+
# Monte Carlo simulates committed delivery only — BACKLOG cards are not
|
|
837
|
+
# part of the forecast. ADR-0057 / Task.committed.
|
|
838
|
+
# select_related("sprint"): build_sched_tasks reads sprint.start_date for the
|
|
839
|
+
# ADR-0168 sprint-window floor; load it with the task to avoid an N+1.
|
|
840
|
+
db_tasks = list(Task.committed.filter(project=project).select_related("sprint"))
|
|
841
|
+
|
|
842
|
+
# Shared converter (ADR-0132): the deterministic CPM pass and Monte Carlo
|
|
843
|
+
# build their scheduler input through one function, so a field can never reach
|
|
844
|
+
# one engine and not the other — the drift that caused #1185 (MC had silently
|
|
845
|
+
# dropped planned_start). build_sched_tasks honors the planned_start floor for
|
|
846
|
+
# every task (the #1185 fix, now centralized) and withholds pending three-point
|
|
847
|
+
# estimates in SUGGEST_APPROVE mode.
|
|
848
|
+
suggest_approve = project.estimation_mode == EstimationMode.SUGGEST_APPROVE
|
|
849
|
+
sched_tasks = build_sched_tasks(db_tasks, suggest_approve=suggest_approve)
|
|
850
|
+
|
|
851
|
+
included_ids = {str(t.id) for t in db_tasks}
|
|
852
|
+
sched_deps = _build_sched_deps(pk, included_ids)
|
|
853
|
+
|
|
854
|
+
# Shared graph shaping (#3527): summary (phase) rows are grouping nodes, not
|
|
855
|
+
# schedulable work (ADR-0105), so they are stripped and their edges fanned down
|
|
856
|
+
# to leaves before the simulation — exactly as the deterministic CPM pass does.
|
|
857
|
+
# Sharing only build_sched_tasks was not enough: every phase used to enter the
|
|
858
|
+
# simulation as a never-started, unconstrained block floored at the data date,
|
|
859
|
+
# and the longest one became the simulated finish. That made the forecast flat,
|
|
860
|
+
# unrelated to the network, and pushed *further out* by recording real progress.
|
|
861
|
+
# Inside a try, like every other engine call here: build_sched_graph raises
|
|
862
|
+
# InvalidScheduleInput (a ValueError) for a Start-to-Start/Start-to-Finish link
|
|
863
|
+
# from a summary, a breached edge cap, or a malformed WBS. The project exception
|
|
864
|
+
# handler only reclassifies malformed-UUID ValueErrors, so an unguarded raise
|
|
865
|
+
# would be a 500 — and this endpoint promises a clean 400 for exactly these.
|
|
866
|
+
try:
|
|
867
|
+
graph = build_sched_graph(db_tasks, sched_tasks, sched_deps)
|
|
868
|
+
except (CyclicDependencyError, ValueError) as exc:
|
|
869
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
870
|
+
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
871
|
+
|
|
872
|
+
# Agile-aware Monte Carlo (#411, ADR-0065/0106): feed the team's completed-sprint
|
|
873
|
+
# throughput to the engine so SCRUM/story-point tasks sample sprints-to-completion
|
|
874
|
+
# from real velocity variance. Without this the engine's velocity path can never
|
|
875
|
+
# fire — every story falls back to its deterministic placeholder duration (1 day),
|
|
876
|
+
# so an all-agile project (e.g. a board-driven program project) forecasts a single
|
|
877
|
+
# flat date with no uncertainty. ([], None) when there is no velocity signal, which
|
|
878
|
+
# leaves a waterfall/PERT project's forecast unchanged.
|
|
879
|
+
from trueppm_api.apps.projects.services import scheduler_velocity_inputs
|
|
880
|
+
|
|
881
|
+
velocity_samples, sprint_length_days = scheduler_velocity_inputs(
|
|
882
|
+
project.pk, sched_calendar.working_days
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
# Data date for the forecast (ADR-0132): the project's explicit status date, or
|
|
886
|
+
# today when unset — so Monte Carlo never schedules remaining work in the past
|
|
887
|
+
# and pins completed work to its actuals. Named so the resolved value can be
|
|
888
|
+
# persisted on the run and echoed in the response (#2638) — previously this
|
|
889
|
+
# substitution happened inline and was never recorded anywhere.
|
|
890
|
+
mc_status_date = project.status_date or timezone.localdate()
|
|
891
|
+
|
|
892
|
+
# runs x n_tasks admission (#2839): the per-dimension caps let the product reach
|
|
893
|
+
# 5M cells (~234 MB traced per simulation); MC_CELL_CAP bounds the product.
|
|
894
|
+
admission = admit_runs(len(graph.tasks), n_simulations if n_explicit else None)
|
|
895
|
+
if admission.refusal is not None:
|
|
896
|
+
return admission.refusal
|
|
897
|
+
n_simulations = admission.runs
|
|
898
|
+
|
|
899
|
+
sched_project = SchedProject(
|
|
900
|
+
id=str(project.pk),
|
|
901
|
+
name=project.name,
|
|
902
|
+
start_date=project.start_date,
|
|
903
|
+
tasks=graph.tasks,
|
|
904
|
+
dependencies=graph.dependencies,
|
|
905
|
+
calendar=sched_calendar,
|
|
906
|
+
status_date=mc_status_date,
|
|
907
|
+
velocity_samples=velocity_samples or None,
|
|
908
|
+
sprint_length_days=sprint_length_days,
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
# Manual Monte Carlo span (#709): times the simulation inline in the request
|
|
913
|
+
# cycle and records the run count; a no-op span unless OTel is configured.
|
|
914
|
+
# The slot bounds how many simulations this process runs at once (#2839).
|
|
915
|
+
with simulation_slot() as admitted:
|
|
916
|
+
if not admitted:
|
|
917
|
+
return busy_response()
|
|
918
|
+
with monte_carlo_span(pk, simulation_count=n_simulations):
|
|
919
|
+
mc_result = monte_carlo(
|
|
920
|
+
sched_project,
|
|
921
|
+
runs=n_simulations,
|
|
922
|
+
max_runs=cap,
|
|
923
|
+
max_tasks=settings.MC_TASK_CAP,
|
|
924
|
+
**_mc_lag_delta_kwargs(),
|
|
925
|
+
)
|
|
926
|
+
except SimulationCapExceeded as exc:
|
|
927
|
+
return Response(
|
|
928
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
929
|
+
{
|
|
930
|
+
"error": "simulation_cap_exceeded",
|
|
931
|
+
"tier": "team",
|
|
932
|
+
"message": str(exc),
|
|
933
|
+
},
|
|
934
|
+
status=status.HTTP_402_PAYMENT_REQUIRED,
|
|
935
|
+
)
|
|
936
|
+
except (CyclicDependencyError, ValueError) as exc:
|
|
937
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
938
|
+
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
939
|
+
except OverflowError:
|
|
940
|
+
# Defense in depth: the engine's MAX_PROJECT_SPAN_DAYS guard makes this
|
|
941
|
+
# unreachable for any realistic start date, but never let a date-range
|
|
942
|
+
# overflow surface as a 500 (OverflowError is not a ValueError).
|
|
943
|
+
return Response(
|
|
944
|
+
{"detail": _DATE_RANGE_EXCEEDED_DETAIL},
|
|
945
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
dist = mc_result.distribution
|
|
949
|
+
histogram = _mc_histogram(dist)
|
|
950
|
+
|
|
951
|
+
# The deterministic CPM spine is the max early_finish of the committed tasks
|
|
952
|
+
# already loaded above — no extra query. It anchors delta_vs_cpm (the risk
|
|
953
|
+
# premium each percentile adds over the deterministic finish) and is itself
|
|
954
|
+
# the project's deterministic schedule finish (#987 — single source, no
|
|
955
|
+
# duplicate project field). Computed before result_dict so the cached latest
|
|
956
|
+
# payload carries it.
|
|
957
|
+
cpm_finish = max(
|
|
958
|
+
(t.early_finish for t in db_tasks if t.early_finish is not None),
|
|
959
|
+
default=None,
|
|
960
|
+
)
|
|
961
|
+
# Which edge of that day the CPM finish is (#4204) — the same reading rule as
|
|
962
|
+
# every other project finish (#4178), from the rows already in memory. A
|
|
963
|
+
# percentile is compared with it in working time, so a start-of-Monday
|
|
964
|
+
# milestone finish is not three days of risk premium over the end of Friday.
|
|
965
|
+
cpm_reading = latest_finish(
|
|
966
|
+
(t.early_finish, task_finish_at_day_start(t), t.wbs_path) for t in db_tasks
|
|
967
|
+
)
|
|
968
|
+
cpm_finish_at_day_start = cpm_reading[1] if cpm_reading is not None else None
|
|
969
|
+
|
|
970
|
+
# `last_run_at` lets the frontend surface a "Last run: 2h ago" freshness
|
|
971
|
+
# signal and decide whether to nudge a rerun (#335). Captured at cache-write
|
|
972
|
+
# time so it always tracks the most recent successful simulation, never the
|
|
973
|
+
# cache read. Held as a datetime as well as an ISO string because the risk
|
|
974
|
+
# premium ages the run against it (ADR-0698).
|
|
975
|
+
run_at = timezone.now()
|
|
976
|
+
result_dict = {
|
|
977
|
+
**mc_result.to_dict(),
|
|
978
|
+
"cpm_finish": cpm_finish.isoformat() if cpm_finish else None,
|
|
979
|
+
"cpm_finish_at_day_start": cpm_finish_at_day_start,
|
|
980
|
+
"delta_vs_cpm": _mc_deltas_vs_cpm(
|
|
981
|
+
mc_result, cpm_finish, cpm_finish_at_day_start, lambda: sched_calendar
|
|
982
|
+
),
|
|
983
|
+
"confidence_curve": _confidence_curve(histogram, len(dist)),
|
|
984
|
+
"histogram_buckets": histogram,
|
|
985
|
+
# Why the forecast has (or lacks) a band, so the UI can explain a flat
|
|
986
|
+
# result instead of presenting a misleadingly precise single date (#1340).
|
|
987
|
+
# Computed from the already-loaded committed task set + estimation mode +
|
|
988
|
+
# velocity signal — no extra query.
|
|
989
|
+
"forecast_diagnostic": forecast_diagnostic(
|
|
990
|
+
# The LEAF rows, not every committed row (#3527): the diagnostic explains
|
|
991
|
+
# what the simulation did, and a summary is not in the simulated network.
|
|
992
|
+
# Counting phases made `tasks_total` overstate the run and let a
|
|
993
|
+
# fully-complete project miss `all_complete` — a summary's stored
|
|
994
|
+
# percent_complete is 0, so every phase read as incomplete work.
|
|
995
|
+
graph.leaf_db_tasks,
|
|
996
|
+
suggest_approve=suggest_approve,
|
|
997
|
+
has_velocity_signal=bool(velocity_samples),
|
|
998
|
+
deterministic=(mc_result.p50 == mc_result.p80 == mc_result.p95),
|
|
999
|
+
),
|
|
1000
|
+
"last_run_at": run_at.isoformat(),
|
|
1001
|
+
# The plan version this run was computed against (#3140), captured above
|
|
1002
|
+
# before the task set was read. Carried on the cache entry — not only on
|
|
1003
|
+
# the persisted row below — because a run triggered by a *Member* refreshes
|
|
1004
|
+
# the cache but persists no attributed row (#1502), so for those runs the
|
|
1005
|
+
# cache entry is the only place this observation exists. Unlike the
|
|
1006
|
+
# risk-premium family, this is not derivable at read time from anything.
|
|
1007
|
+
"plan_version": plan_version,
|
|
1008
|
+
# The data date this run was actually computed against (ADR-0132, #2638):
|
|
1009
|
+
# the project's explicit status_date, or today when unset. Carried on the
|
|
1010
|
+
# cache entry too (it is part of ``result_dict``) so a cached read states
|
|
1011
|
+
# the same provenance as a fresh run, and on the persisted MonteCarloRun
|
|
1012
|
+
# row below so history stays re-derivable after the fact.
|
|
1013
|
+
"status_date": mc_status_date.isoformat(),
|
|
1014
|
+
}
|
|
1015
|
+
cache.set(mc_latest_cache_key(pk), result_dict, timeout=86400)
|
|
1016
|
+
|
|
1017
|
+
# Persist a forecast-drift history row (ADR-0175, #961) only for Scheduler+
|
|
1018
|
+
# callers; the read is Member-level but the attributed write is not (#1502).
|
|
1019
|
+
_persist_mc_run_if_authorized(
|
|
1020
|
+
request,
|
|
1021
|
+
project,
|
|
1022
|
+
mc_result=mc_result,
|
|
1023
|
+
n_simulations=n_simulations,
|
|
1024
|
+
cpm_finish=cpm_finish,
|
|
1025
|
+
# The size of the network actually simulated (#3527) — summaries are
|
|
1026
|
+
# stripped before the run, so counting them here would describe an input
|
|
1027
|
+
# the engine never saw.
|
|
1028
|
+
task_count=len(graph.tasks),
|
|
1029
|
+
result_dict=result_dict,
|
|
1030
|
+
status_date=mc_status_date,
|
|
1031
|
+
plan_version=plan_version,
|
|
1032
|
+
cpm_finish_at_day_start=cpm_finish_at_day_start,
|
|
1033
|
+
)
|
|
1034
|
+
# Added time (#2531, ADR-0698) rides the response but NOT the cache entry above.
|
|
1035
|
+
# Two of its terms depend on when the response is built rather than when the run
|
|
1036
|
+
# happened: `ratio` divides by the duration remaining as of today, which collapses
|
|
1037
|
+
# to null once the CPM finish passes, and `state` flips to `stale` with age. A
|
|
1038
|
+
# premium frozen at cache-write would keep reporting a share of a remainder that
|
|
1039
|
+
# has since elapsed, so it is derived per response instead.
|
|
1040
|
+
current_plan_version, current_recalculated_at = _current_plan_state(pk)
|
|
1041
|
+
return Response(
|
|
1042
|
+
{
|
|
1043
|
+
**result_dict,
|
|
1044
|
+
**risk_premium_from_values(
|
|
1045
|
+
p80=mc_result.p80,
|
|
1046
|
+
cpm_finish=cpm_finish,
|
|
1047
|
+
taken_at=run_at,
|
|
1048
|
+
diagnostic=result_dict["forecast_diagnostic"],
|
|
1049
|
+
today=timezone.localdate(),
|
|
1050
|
+
p80_at_day_start=mc_result.p80_at_day_start,
|
|
1051
|
+
cpm_finish_at_day_start=cpm_finish_at_day_start,
|
|
1052
|
+
calendar_for=lambda: sched_calendar,
|
|
1053
|
+
),
|
|
1054
|
+
# Staleness is re-read rather than assumed `current` (#3140). The project's
|
|
1055
|
+
# version is fetched again here because a synced write can land *during* the
|
|
1056
|
+
# simulation, and that run genuinely does not cover it — telling the user so
|
|
1057
|
+
# immediately is the whole point of the discriminant. One indexed pk read on
|
|
1058
|
+
# an endpoint that just ran a Monte Carlo simulation is not a cost worth
|
|
1059
|
+
# trading a false `current` for.
|
|
1060
|
+
**forecast_staleness_facts(
|
|
1061
|
+
plan_version=plan_version,
|
|
1062
|
+
plan_version_current=current_plan_version,
|
|
1063
|
+
taken_at=run_at,
|
|
1064
|
+
recalculated_at=current_recalculated_at,
|
|
1065
|
+
today=timezone.localdate(),
|
|
1066
|
+
),
|
|
1067
|
+
}
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
class MonteCarloLatestView(McpReadableViewMixin, APIView):
|
|
1072
|
+
"""Return the cached result of the most recent MC simulation for this project.
|
|
1073
|
+
|
|
1074
|
+
Returns 200 with the full result dict if a cached result exists, or 404 if
|
|
1075
|
+
no simulation has been run since the 24-hour cache TTL last expired.
|
|
1076
|
+
|
|
1077
|
+
Permission: Member (any role ≥ Viewer).
|
|
1078
|
+
"""
|
|
1079
|
+
|
|
1080
|
+
# ADR-0678 (#2482): projects/<pk>/monte-carlo/latest/
|
|
1081
|
+
mcp_scope = McpScope.PATH
|
|
1082
|
+
|
|
1083
|
+
mcp_compute_heavy = True # part of the F4 compute-heavy MCP tool set (#1808)
|
|
1084
|
+
# Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
|
|
1085
|
+
# this the project-scoped classes below resolve nothing and `has_permission`
|
|
1086
|
+
# returns True for everyone — the gate reads as live in review and enforces
|
|
1087
|
+
# nothing. Declared per view because `pk` means something else on other routes.
|
|
1088
|
+
project_url_kwarg = "pk"
|
|
1089
|
+
permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
|
|
1090
|
+
|
|
1091
|
+
@extend_schema(
|
|
1092
|
+
summary="Latest cached Monte Carlo forecast for the project",
|
|
1093
|
+
responses={
|
|
1094
|
+
200: OpenApiResponse(
|
|
1095
|
+
response=MonteCarloForecastSerializer,
|
|
1096
|
+
description=(
|
|
1097
|
+
"The most recent Monte Carlo result for the project. Keys: p50/p80/p95 "
|
|
1098
|
+
"(ISO-8601 finish dates), p50/p80/p95_at_day_start and cpm_finish, "
|
|
1099
|
+
"cpm_finish_at_day_start (which edge of its shown day each finish is; "
|
|
1100
|
+
"null on a run persisted before #4204), delta_vs_cpm ({p50, p80, p95} "
|
|
1101
|
+
"calendar-day deltas vs the CPM finish, measured in working time), "
|
|
1102
|
+
"runs (n_simulations), "
|
|
1103
|
+
"confidence_curve, histogram_buckets, sensitivity, last_run_at, "
|
|
1104
|
+
"status_date (the data date this run was computed against — "
|
|
1105
|
+
"project.status_date or today when unset, ADR-0132/#2638; null for "
|
|
1106
|
+
"runs persisted before #2638), from_history (true when served from a "
|
|
1107
|
+
"persisted run after the 24h cache TTL expired), and the risk_premium_* "
|
|
1108
|
+
"family (ADR-0698): "
|
|
1109
|
+
"risk_premium_state (not_run|unmeasurable|stale|zero|premium|negative — "
|
|
1110
|
+
"the discriminant every client renders from, never null), "
|
|
1111
|
+
"risk_premium_days, risk_premium_ratio, risk_premium_band (always null "
|
|
1112
|
+
"until #2299), risk_premium_as_of, risk_premium_reason, "
|
|
1113
|
+
"risk_premium_cpm_finish, risk_premium_p80. The premium is derived on "
|
|
1114
|
+
"every response and is never stored in the cache entry. Legacy runs with "
|
|
1115
|
+
"no stored distribution return empty "
|
|
1116
|
+
"confidence_curve/histogram_buckets/sensitivity arrays. "
|
|
1117
|
+
"Plus the forecast-staleness family (#3140): forecast_staleness "
|
|
1118
|
+
"(current|project_changed|aged|unknown — never null; only `current` "
|
|
1119
|
+
"means the forecast still describes the plan, and a client keeps the "
|
|
1120
|
+
"recompute action reachable on the other three), plan_version (the "
|
|
1121
|
+
"project's sync version this run was computed against — unlike the "
|
|
1122
|
+
"risk_premium family this one IS carried on the cache entry and the "
|
|
1123
|
+
"persisted row, because it is a run-time observation that cannot be "
|
|
1124
|
+
"re-derived later; null for runs recorded before #3140, which "
|
|
1125
|
+
"classify as `unknown`, never `current`), and plan_version_current "
|
|
1126
|
+
"(that version as of this response). `project_changed` is "
|
|
1127
|
+
"deliberately not named `plan_changed`: the underlying counter "
|
|
1128
|
+
"advances on ANY write in the project, so a description or label "
|
|
1129
|
+
"edit raises it without moving a date. It is a sound trigger for "
|
|
1130
|
+
"offering a recompute and NOT evidence that a schedule value "
|
|
1131
|
+
"changed — do not narrate it as one."
|
|
1132
|
+
),
|
|
1133
|
+
),
|
|
1134
|
+
404: OpenApiResponse(
|
|
1135
|
+
response=OpenApiTypes.OBJECT,
|
|
1136
|
+
description="No simulation result available for this project ({detail}).",
|
|
1137
|
+
),
|
|
1138
|
+
},
|
|
1139
|
+
)
|
|
1140
|
+
def get(self, request: Request, pk: str) -> Response:
|
|
1141
|
+
"""Return the latest cached Monte Carlo result for the project.
|
|
1142
|
+
|
|
1143
|
+
Falls back to the most recent persisted ``MonteCarloRun`` when the 24-hour
|
|
1144
|
+
cache has expired (ADR-0175): the latest forecast now survives past the
|
|
1145
|
+
TTL. As of #1231 the run also persists its distribution, so when present the
|
|
1146
|
+
fallback returns the real ``histogram_buckets``/``confidence_curve``/
|
|
1147
|
+
``sensitivity`` instead of empty arrays — the histogram survives cache
|
|
1148
|
+
expiry. Legacy runs (no persisted distribution) still fall back to empty,
|
|
1149
|
+
and the frontend renders the empty-state prose for them.
|
|
1150
|
+
"""
|
|
1151
|
+
project = get_object_or_404(Project, pk=pk, is_deleted=False)
|
|
1152
|
+
self.check_object_permissions(request, project)
|
|
1153
|
+
cached = cache.get(mc_latest_cache_key(pk))
|
|
1154
|
+
if cached is not None:
|
|
1155
|
+
# Added time is layered on at read time, never taken from the entry — the
|
|
1156
|
+
# entry has no premium keys to take (ADR-0698 §2). A cached forecast can be
|
|
1157
|
+
# served for 24 hours, and both `risk_premium_ratio` (a share of the
|
|
1158
|
+
# duration still remaining today) and `risk_premium_state` (which ages into
|
|
1159
|
+
# `stale`) would be wrong by the time it is read back.
|
|
1160
|
+
return Response(
|
|
1161
|
+
{
|
|
1162
|
+
**cached,
|
|
1163
|
+
**risk_premium_for_forecast_payload(
|
|
1164
|
+
cached,
|
|
1165
|
+
today=timezone.localdate(),
|
|
1166
|
+
calendar_for=lambda: compose_project_calendar(project),
|
|
1167
|
+
),
|
|
1168
|
+
# Same read-time discipline, different reason (#3140): the entry
|
|
1169
|
+
# carries the plan version the run SAW, and the project has been free
|
|
1170
|
+
# to move for up to 24 hours since. `project` is already loaded, so
|
|
1171
|
+
# the comparison costs nothing. An entry written before #3140 has no
|
|
1172
|
+
# `plan_version` key and classifies as `unknown` — honest, and it
|
|
1173
|
+
# self-heals on the next run, which is why `MC_LATEST_CACHE_VERSION`
|
|
1174
|
+
# is deliberately NOT bumped here: unlike the #2833 engine fix, no
|
|
1175
|
+
# cached *number* is wrong, only unplaceable.
|
|
1176
|
+
**forecast_staleness_for_payload(
|
|
1177
|
+
cached,
|
|
1178
|
+
plan_version_current=project.last_sync_version,
|
|
1179
|
+
recalculated_at=project.recalculated_at,
|
|
1180
|
+
today=timezone.localdate(),
|
|
1181
|
+
),
|
|
1182
|
+
}
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
latest = MonteCarloRun.objects.filter(project_id=pk).order_by("-taken_at").first()
|
|
1186
|
+
if latest is None:
|
|
1187
|
+
return Response(
|
|
1188
|
+
{"detail": "No simulation result available."},
|
|
1189
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
1190
|
+
)
|
|
1191
|
+
# cpm_finish + delta_vs_cpm survive the TTL because both are persisted on the
|
|
1192
|
+
# run (ADR-0175). The distribution (#1231) now survives too when persisted:
|
|
1193
|
+
# use the stored buckets/curve/sensitivity, falling back to empty arrays for
|
|
1194
|
+
# legacy runs that pre-date persistence (the frontend shows the empty-state
|
|
1195
|
+
# prose then). Response shape is unchanged (snake_case keys).
|
|
1196
|
+
dist = latest.distribution or {}
|
|
1197
|
+
|
|
1198
|
+
# Composed only if a start-of-day reading makes the working-time comparison
|
|
1199
|
+
# need it (#4204); memoized so the deltas and the premium share one.
|
|
1200
|
+
calendar_memo: list[Any] = []
|
|
1201
|
+
|
|
1202
|
+
def calendar_for() -> Any:
|
|
1203
|
+
if not calendar_memo:
|
|
1204
|
+
calendar_memo.append(compose_project_calendar(project))
|
|
1205
|
+
return calendar_memo[0]
|
|
1206
|
+
|
|
1207
|
+
return Response(
|
|
1208
|
+
{
|
|
1209
|
+
# The live-run payload has carried `project_id` since #172; the
|
|
1210
|
+
# from-history branch omitted it, and the client maps it straight
|
|
1211
|
+
# through as a non-optional field. Harmless while nothing read it —
|
|
1212
|
+
# but the added-time card's "Add estimates →" target is built from it
|
|
1213
|
+
# (#2531), so past the 24h TTL that link resolved to
|
|
1214
|
+
# `/projects/undefined`.
|
|
1215
|
+
"project_id": str(latest.project_id),
|
|
1216
|
+
"p50": latest.p50.isoformat() if latest.p50 else None,
|
|
1217
|
+
"p80": latest.p80.isoformat() if latest.p80 else None,
|
|
1218
|
+
"p95": latest.p95.isoformat() if latest.p95 else None,
|
|
1219
|
+
"cpm_finish": latest.cpm_finish.isoformat() if latest.cpm_finish else None,
|
|
1220
|
+
# null on a run persisted before #4204 (reading unknown).
|
|
1221
|
+
**_mc_run_readings(latest),
|
|
1222
|
+
"delta_vs_cpm": _mc_deltas_vs_cpm(
|
|
1223
|
+
latest, latest.cpm_finish, latest.cpm_finish_at_day_start, calendar_for
|
|
1224
|
+
),
|
|
1225
|
+
"confidence_curve": dist.get("confidence_curve", []),
|
|
1226
|
+
"runs": latest.n_simulations,
|
|
1227
|
+
"histogram_buckets": dist.get("histogram_buckets", []),
|
|
1228
|
+
"sensitivity": dist.get("sensitivity", []),
|
|
1229
|
+
# Persisted with the run since #2483, so the "why is this flat"
|
|
1230
|
+
# explanation now survives cache expiry alongside the distribution.
|
|
1231
|
+
# Legacy rows have none; the client already treats an absent
|
|
1232
|
+
# diagnostic as "no reason known", never as "no estimates".
|
|
1233
|
+
"forecast_diagnostic": latest.diagnostic,
|
|
1234
|
+
"last_run_at": latest.taken_at.isoformat(),
|
|
1235
|
+
# Persisted with the run since #2638; legacy rows predate the field
|
|
1236
|
+
# and read back as null rather than a guessed value (same pattern as
|
|
1237
|
+
# `diagnostic`/`distribution` above).
|
|
1238
|
+
"status_date": latest.status_date.isoformat() if latest.status_date else None,
|
|
1239
|
+
"from_history": True,
|
|
1240
|
+
# The same call `ProjectOverviewView` makes, on the same row — so the
|
|
1241
|
+
# Overview card and every forecast surface read one derivation of what
|
|
1242
|
+
# a premium of 0 means, and cannot disagree (ADR-0698).
|
|
1243
|
+
**build_risk_premium(latest, today=timezone.localdate(), calendar_for=calendar_for),
|
|
1244
|
+
# Persisted with the run since #3140. Legacy rows carry none and classify
|
|
1245
|
+
# as `unknown` rather than `current` — the client then keeps Rerun
|
|
1246
|
+
# reachable and makes no staleness claim it cannot support.
|
|
1247
|
+
**forecast_staleness_from_run(
|
|
1248
|
+
latest,
|
|
1249
|
+
plan_version_current=project.last_sync_version,
|
|
1250
|
+
recalculated_at=project.recalculated_at,
|
|
1251
|
+
today=timezone.localdate(),
|
|
1252
|
+
),
|
|
1253
|
+
}
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
# Fixed RNG seed for the what-if endpoint (#993). Both the baseline and the
|
|
1258
|
+
# perturbed Monte Carlo runs sample with THIS seed, so they draw the *same*
|
|
1259
|
+
# per-run duration samples for every unperturbed task. The delta between the two
|
|
1260
|
+
# forecasts therefore isolates the effect of the perturbation rather than RNG
|
|
1261
|
+
# noise, and the endpoint is a pure function of its inputs — the same what-if
|
|
1262
|
+
# query always returns the same forecast, which a reproducible "slip this task"
|
|
1263
|
+
# demo (and a stable test assertion) both depend on.
|
|
1264
|
+
WHATIF_MC_SEED = 993_993
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _target_kind_refusal(task: Task) -> Response | None:
|
|
1268
|
+
"""400 for a what-if target that cannot be perturbed, else ``None``.
|
|
1269
|
+
|
|
1270
|
+
A milestone is a zero-duration gate. A completed task (#4515) is read with the
|
|
1271
|
+
engine's ``_is_complete`` predicate (ADR-0136): an ``actual_finish`` or 100%.
|
|
1272
|
+
Completed work is pinned by its actuals, so a duration shift would hand back an
|
|
1273
|
+
unmoved forecast as a zero-impact what-if. The engine refuses too; this earlier
|
|
1274
|
+
check keeps the 400 ahead of run admission and the busy slot.
|
|
1275
|
+
"""
|
|
1276
|
+
if task.is_milestone:
|
|
1277
|
+
return Response(
|
|
1278
|
+
{"detail": "A milestone is a zero-duration gate and cannot be perturbed."},
|
|
1279
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1280
|
+
)
|
|
1281
|
+
if task.actual_finish is None and (task.percent_complete or 0) < 100:
|
|
1282
|
+
return None
|
|
1283
|
+
return Response(
|
|
1284
|
+
{
|
|
1285
|
+
"detail": (
|
|
1286
|
+
"A completed task's dates are actuals, so a duration change "
|
|
1287
|
+
"cannot move the forecast; pick a task that still has work left."
|
|
1288
|
+
)
|
|
1289
|
+
},
|
|
1290
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
class MonteCarloWhatIfView(McpReadableViewMixin, APIView):
|
|
1295
|
+
"""Non-mutating Monte Carlo what-if: perturb one task's duration, recompute (#993).
|
|
1296
|
+
|
|
1297
|
+
Backs the MCP ``whatif`` read tool (#504/#603): accept a ``task_id`` plus a
|
|
1298
|
+
duration perturbation, run CPM + Monte Carlo **in memory without persisting
|
|
1299
|
+
anything**, and return the resulting P50/P80/P95, the deterministic CPM finish,
|
|
1300
|
+
whether the critical path changed, and the signed deltas versus the current
|
|
1301
|
+
(unperturbed) forecast.
|
|
1302
|
+
|
|
1303
|
+
Why GET, not POST: the endpoint has no side effects — it writes no rows, sets no
|
|
1304
|
+
cache, and enqueues no recompute — so it is a pure read/compute, correctly
|
|
1305
|
+
modeled as GET. That is also what makes it reachable by an ``mcp:read`` API
|
|
1306
|
+
token: ``McpReadableViewMixin`` confines token callers to safe methods, so a
|
|
1307
|
+
POST could never back a *read* MCP tool. Query params carry the perturbation
|
|
1308
|
+
(``?task_id=&duration_delta=`` or ``&new_duration=``).
|
|
1309
|
+
|
|
1310
|
+
Non-persistence guarantee: this view never calls ``.save()``,
|
|
1311
|
+
``record_monte_carlo_run``, ``cache.set``, or ``enqueue_recalculate``. The
|
|
1312
|
+
perturbation is applied to freshly-built in-memory scheduler dataclasses; the
|
|
1313
|
+
Django rows are only read. ``schedule()`` and ``monte_carlo()`` both operate on
|
|
1314
|
+
copies and do not mutate their input project.
|
|
1315
|
+
|
|
1316
|
+
Permission: Member (any role >= Viewer), the same project-read gate as the
|
|
1317
|
+
``run_monte_carlo`` / latest / history forecast endpoints.
|
|
1318
|
+
"""
|
|
1319
|
+
|
|
1320
|
+
# ADR-0678 (#2482): projects/<pk>/monte-carlo/whatif/
|
|
1321
|
+
mcp_scope = McpScope.PATH
|
|
1322
|
+
|
|
1323
|
+
mcp_compute_heavy = True # two CPM passes + two Monte Carlo runs per call (#1808 F4)
|
|
1324
|
+
# Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
|
|
1325
|
+
# this the project-scoped classes below resolve nothing and `has_permission`
|
|
1326
|
+
# returns True for everyone — the gate reads as live in review and enforces
|
|
1327
|
+
# nothing. Declared per view because `pk` means something else on other routes.
|
|
1328
|
+
project_url_kwarg = "pk"
|
|
1329
|
+
permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
|
|
1330
|
+
throttle_classes = [ScopedRateThrottle]
|
|
1331
|
+
throttle_scope = "monte_carlo_whatif"
|
|
1332
|
+
|
|
1333
|
+
@extend_schema(
|
|
1334
|
+
summary="Non-mutating Monte Carlo what-if for a single task duration change",
|
|
1335
|
+
parameters=[
|
|
1336
|
+
OpenApiParameter(
|
|
1337
|
+
"task_id",
|
|
1338
|
+
OpenApiTypes.UUID,
|
|
1339
|
+
required=True,
|
|
1340
|
+
description="The committed task whose duration to perturb.",
|
|
1341
|
+
),
|
|
1342
|
+
OpenApiParameter(
|
|
1343
|
+
"duration_delta",
|
|
1344
|
+
OpenApiTypes.INT,
|
|
1345
|
+
description=(
|
|
1346
|
+
"Signed day offset applied to the task's current duration "
|
|
1347
|
+
"(e.g. 5 to slip it a working week later, -2 to pull it in). "
|
|
1348
|
+
"Supply exactly one of duration_delta or new_duration."
|
|
1349
|
+
),
|
|
1350
|
+
),
|
|
1351
|
+
OpenApiParameter(
|
|
1352
|
+
"new_duration",
|
|
1353
|
+
OpenApiTypes.INT,
|
|
1354
|
+
description=(
|
|
1355
|
+
"Absolute day count to set the task's duration to (>= 0). "
|
|
1356
|
+
"Supply exactly one of duration_delta or new_duration."
|
|
1357
|
+
),
|
|
1358
|
+
),
|
|
1359
|
+
OpenApiParameter(
|
|
1360
|
+
"n_simulations",
|
|
1361
|
+
OpenApiTypes.INT,
|
|
1362
|
+
description=(
|
|
1363
|
+
"Monte Carlo iterations. Defaults to the largest count this "
|
|
1364
|
+
"project's size admits: MC_SIMULATION_CAP, or fewer under "
|
|
1365
|
+
"MC_CELL_CAP (runs x tasks) on a large project — `runs` in the "
|
|
1366
|
+
"response reports the count simulated. An explicit value is "
|
|
1367
|
+
"honored exactly or refused (402 above MC_SIMULATION_CAP, 400 "
|
|
1368
|
+
"above the size-derived maximum)."
|
|
1369
|
+
),
|
|
1370
|
+
),
|
|
1371
|
+
],
|
|
1372
|
+
responses={
|
|
1373
|
+
200: OpenApiResponse(
|
|
1374
|
+
response=MonteCarloWhatIfResponseSerializer,
|
|
1375
|
+
description=(
|
|
1376
|
+
"What-if forecast. Keys: task_id; applied ({base_duration_days, "
|
|
1377
|
+
"duration_delta_days, new_duration_days}); current and whatif (each "
|
|
1378
|
+
"{p50, p80, p95, their *_at_day_start readings, cpm_finish (ISO-8601 or "
|
|
1379
|
+
"null), cpm_finish_at_day_start, critical_path ([task_id])}); "
|
|
1380
|
+
"critical_path_changed (bool — did the set of critical tasks change); "
|
|
1381
|
+
"delta_vs_current ({p50, p80, p95, cpm_finish} signed calendar-day shifts, "
|
|
1382
|
+
"positive = later/worse, null when a date is missing; every field is measured "
|
|
1383
|
+
"in working time (#4178, #4204), so a finish that only moves from the end of "
|
|
1384
|
+
"a Friday to the start of the next Monday is 0); runs (the iteration "
|
|
1385
|
+
"count actually simulated per leg — may be below MC_SIMULATION_CAP when "
|
|
1386
|
+
"n_simulations is omitted on a large project, #2839); "
|
|
1387
|
+
"seed (fixed RNG seed shared by both runs so the delta isolates the change); "
|
|
1388
|
+
"cpm_status_date and mc_status_date (ADR-0132/#2638, floor armed on the "
|
|
1389
|
+
"CPM path by ADR-0752 §4) — the resolved data dates (project.status_date, "
|
|
1390
|
+
"or today when unset) fed to the deterministic and Monte Carlo passes "
|
|
1391
|
+
"respectively, shared by both current and whatif since this view runs both "
|
|
1392
|
+
"in one call; this view never persists, so these are the only provenance a "
|
|
1393
|
+
"caller gets for how the forecast was computed."
|
|
1394
|
+
),
|
|
1395
|
+
),
|
|
1396
|
+
400: OpenApiResponse(
|
|
1397
|
+
response=OpenApiTypes.OBJECT,
|
|
1398
|
+
description=(
|
|
1399
|
+
"Invalid input: malformed/absent params, both or neither of "
|
|
1400
|
+
"duration_delta/new_duration, task_id not a committed task in this "
|
|
1401
|
+
"project, a milestone (zero-duration) target, a completed task (its "
|
|
1402
|
+
"dates are actuals), a summary (phase) target "
|
|
1403
|
+
"whose span is rolled up from its children and so cannot be perturbed, "
|
|
1404
|
+
"a cyclic dependency, or an out-of-range project span; or an explicit "
|
|
1405
|
+
"n_simulations above the size-derived maximum (MC_CELL_CAP, #2839) — "
|
|
1406
|
+
"body {error: simulation_cell_cap_exceeded, message, max_runs, "
|
|
1407
|
+
"task_count}."
|
|
1408
|
+
),
|
|
1409
|
+
),
|
|
1410
|
+
402: OpenApiResponse(
|
|
1411
|
+
response=OpenApiTypes.OBJECT,
|
|
1412
|
+
description="OSS simulation cap exceeded (n_simulations or task count too high).",
|
|
1413
|
+
),
|
|
1414
|
+
404: OpenApiResponse(
|
|
1415
|
+
response=OpenApiTypes.OBJECT,
|
|
1416
|
+
description="Project does not exist.",
|
|
1417
|
+
),
|
|
1418
|
+
503: OpenApiResponse(
|
|
1419
|
+
response=OpenApiTypes.OBJECT,
|
|
1420
|
+
description=(
|
|
1421
|
+
"No free Monte Carlo slot in this API process (MC_MAX_CONCURRENT, "
|
|
1422
|
+
"#2839). Body {error: simulation_busy, message}; retry after "
|
|
1423
|
+
"Retry-After."
|
|
1424
|
+
),
|
|
1425
|
+
),
|
|
1426
|
+
},
|
|
1427
|
+
)
|
|
1428
|
+
def get(self, request: Request, pk: str) -> Response:
|
|
1429
|
+
"""Run the perturbed forecast in memory and return the deltas (see class doc)."""
|
|
1430
|
+
from trueppm_scheduler.engine import (
|
|
1431
|
+
CyclicDependencyError,
|
|
1432
|
+
SimulationCapExceeded,
|
|
1433
|
+
monte_carlo,
|
|
1434
|
+
perturb_task_duration,
|
|
1435
|
+
schedule,
|
|
1436
|
+
)
|
|
1437
|
+
from trueppm_scheduler.models import Project as SchedProject
|
|
1438
|
+
|
|
1439
|
+
from trueppm_api.apps.projects.services import scheduler_velocity_inputs
|
|
1440
|
+
|
|
1441
|
+
req = MonteCarloWhatIfRequestSerializer(data=request.query_params)
|
|
1442
|
+
req.is_valid(raise_exception=True)
|
|
1443
|
+
params = req.validated_data
|
|
1444
|
+
task_id = str(params["task_id"])
|
|
1445
|
+
|
|
1446
|
+
try:
|
|
1447
|
+
project = (
|
|
1448
|
+
Project.objects.select_related("calendar", "program__calendar")
|
|
1449
|
+
.prefetch_related(
|
|
1450
|
+
"tasks",
|
|
1451
|
+
"calendar__exceptions",
|
|
1452
|
+
"calendar_layers__calendar__exceptions",
|
|
1453
|
+
# ADR-0441: the resolver reads the program calendar's exceptions when a
|
|
1454
|
+
# project inherits its base calendar from the program.
|
|
1455
|
+
"program__calendar__exceptions",
|
|
1456
|
+
)
|
|
1457
|
+
.get(pk=pk, is_deleted=False)
|
|
1458
|
+
)
|
|
1459
|
+
except Project.DoesNotExist:
|
|
1460
|
+
return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
|
|
1461
|
+
|
|
1462
|
+
# Object-level membership + not-archived gate (same read level as the
|
|
1463
|
+
# forecast endpoints). APIView.get has no get_object(), so enforce explicitly.
|
|
1464
|
+
self.check_object_permissions(request, project)
|
|
1465
|
+
|
|
1466
|
+
cap: int | None = settings.MC_SIMULATION_CAP
|
|
1467
|
+
# None = "the admissible maximum for this project's size" (#2839), resolved by
|
|
1468
|
+
# `admit_runs` once the leaf network is built below.
|
|
1469
|
+
requested_runs: int | None = (
|
|
1470
|
+
int(params["n_simulations"]) if "n_simulations" in params else None
|
|
1471
|
+
)
|
|
1472
|
+
|
|
1473
|
+
# Build the baseline scheduler input exactly as run_monte_carlo does, through
|
|
1474
|
+
# the shared converters (ADR-0132/#1491) so the what-if forecast is computed on
|
|
1475
|
+
# the identical graph the real forecast uses — same calendar exceptions, same
|
|
1476
|
+
# planned_start floors, same velocity signal.
|
|
1477
|
+
sched_calendar = compose_project_calendar(project)
|
|
1478
|
+
db_tasks = list(Task.committed.filter(project=project).select_related("sprint"))
|
|
1479
|
+
if not db_tasks:
|
|
1480
|
+
return Response(
|
|
1481
|
+
{"detail": "Project has no schedulable (committed) tasks to forecast."},
|
|
1482
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
# The perturbation target must be one of THIS project's committed tasks. A
|
|
1486
|
+
# BACKLOG card, a task in another project, or an unknown id is rejected as a
|
|
1487
|
+
# clean 400 (not 404 — the project exists and the caller can read it; only the
|
|
1488
|
+
# task reference is invalid) and never leaks whether the id exists elsewhere.
|
|
1489
|
+
target_db = next((t for t in db_tasks if str(t.id) == task_id), None)
|
|
1490
|
+
if target_db is None:
|
|
1491
|
+
return Response(
|
|
1492
|
+
{"detail": "task_id is not a committed task in this project."},
|
|
1493
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
# Milestone and completed refusals; checked before admission so the 400 wins over run
|
|
1497
|
+
# admission (402/413) and a busy slot (503); see `_target_kind_refusal`.
|
|
1498
|
+
if (refusal := _target_kind_refusal(target_db)) is not None:
|
|
1499
|
+
return refusal
|
|
1500
|
+
|
|
1501
|
+
base_duration_days = int(target_db.duration)
|
|
1502
|
+
if "duration_delta" in params:
|
|
1503
|
+
delta_days = int(params["duration_delta"])
|
|
1504
|
+
else:
|
|
1505
|
+
# new_duration is absolute; convert to the equivalent signed shift so both
|
|
1506
|
+
# inputs flow through one perturbation path.
|
|
1507
|
+
delta_days = int(params["new_duration"]) - base_duration_days
|
|
1508
|
+
new_duration_days = max(0, base_duration_days + delta_days)
|
|
1509
|
+
|
|
1510
|
+
suggest_approve = project.estimation_mode == EstimationMode.SUGGEST_APPROVE
|
|
1511
|
+
baseline_tasks = build_sched_tasks(db_tasks, suggest_approve=suggest_approve)
|
|
1512
|
+
|
|
1513
|
+
included_ids = {str(t.id) for t in db_tasks}
|
|
1514
|
+
sched_deps = _build_sched_deps(pk, included_ids)
|
|
1515
|
+
|
|
1516
|
+
# Same leaf-only shaping the real forecast uses (#3527, ADR-0105) — a what-if
|
|
1517
|
+
# answered against a network containing phantom phases is not a what-if about
|
|
1518
|
+
# this project's schedule.
|
|
1519
|
+
try:
|
|
1520
|
+
graph = build_sched_graph(db_tasks, baseline_tasks, sched_deps)
|
|
1521
|
+
except (CyclicDependencyError, ValueError) as exc:
|
|
1522
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
1523
|
+
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
1524
|
+
|
|
1525
|
+
# A summary has no duration of its own — its span is rolled up from its leaves
|
|
1526
|
+
# — so it is absent from the network built above, and perturbing it would
|
|
1527
|
+
# silently change nothing. Refuse rather than hand back an unmoved forecast as
|
|
1528
|
+
# though the delta had been applied. Checked after the shaping because that is
|
|
1529
|
+
# what defines "is a summary" here, and before any simulation runs.
|
|
1530
|
+
if str(target_db.id) in graph.summary_ids:
|
|
1531
|
+
return Response(
|
|
1532
|
+
{
|
|
1533
|
+
"detail": (
|
|
1534
|
+
"A summary task's duration is rolled up from its children and "
|
|
1535
|
+
"cannot be perturbed; pick one of its leaf tasks instead."
|
|
1536
|
+
)
|
|
1537
|
+
},
|
|
1538
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
leaf_tasks = graph.tasks
|
|
1542
|
+
leaf_deps = graph.dependencies
|
|
1543
|
+
|
|
1544
|
+
# runs x n_tasks admission (#2839). Both simulations below share one network
|
|
1545
|
+
# size (a duration perturbation adds no task), so one admission covers both.
|
|
1546
|
+
admission = admit_runs(len(leaf_tasks), requested_runs)
|
|
1547
|
+
if admission.refusal is not None:
|
|
1548
|
+
return admission.refusal
|
|
1549
|
+
n_simulations = admission.runs
|
|
1550
|
+
|
|
1551
|
+
velocity_samples, sprint_length_days = scheduler_velocity_inputs(
|
|
1552
|
+
project.pk, sched_calendar.working_days
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
# The deterministic CPM and Monte Carlo both floor a null status_date at
|
|
1556
|
+
# today (ADR-0752 §4, correcting ADR-0132 §1 — the CPM path used to pass
|
|
1557
|
+
# a null status_date raw, meaning "no floor at all"). Both are resolved
|
|
1558
|
+
# through the same shared helper so this read-time `current` block can
|
|
1559
|
+
# never drift from the persisted deterministic run
|
|
1560
|
+
# (`scheduling/tasks.py::_run_schedule`) on which data date it used —
|
|
1561
|
+
# keeping current.cpm_finish identical to the persisted deterministic
|
|
1562
|
+
# finish and current P50/P80/P95 identical to run_monte_carlo.
|
|
1563
|
+
cpm_status_date = resolve_cpm_status_date(project.status_date)
|
|
1564
|
+
mc_status_date = resolve_cpm_status_date(project.status_date)
|
|
1565
|
+
|
|
1566
|
+
def _make_project(tasks: list[Any], status_date: _date | None) -> Any:
|
|
1567
|
+
return SchedProject(
|
|
1568
|
+
id=str(project.pk),
|
|
1569
|
+
name=project.name,
|
|
1570
|
+
start_date=project.start_date,
|
|
1571
|
+
tasks=tasks,
|
|
1572
|
+
dependencies=leaf_deps,
|
|
1573
|
+
calendar=sched_calendar,
|
|
1574
|
+
status_date=status_date,
|
|
1575
|
+
velocity_samples=velocity_samples or None,
|
|
1576
|
+
sprint_length_days=sprint_length_days,
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
try:
|
|
1580
|
+
# One shared Monte Carlo span (#709) over the full what-if computation —
|
|
1581
|
+
# baseline + perturbed CPM and simulation — through the same helper the
|
|
1582
|
+
# real forecast uses, so the span name and attributes cannot drift.
|
|
1583
|
+
# One process slot (#2839) covers both simulations: they run in sequence.
|
|
1584
|
+
with simulation_slot() as admitted:
|
|
1585
|
+
if not admitted:
|
|
1586
|
+
return busy_response()
|
|
1587
|
+
with monte_carlo_span(pk, simulation_count=n_simulations):
|
|
1588
|
+
# The ENGINE owns the perturbation (#3533). Which input a task's
|
|
1589
|
+
# sampled duration comes from is the engine's own priority rule
|
|
1590
|
+
# (velocity → PERT triple → deterministic); an API-side shift of
|
|
1591
|
+
# the fields this view happens to know about moved the CPM finish
|
|
1592
|
+
# while leaving a velocity-sampled task's bands exactly where they
|
|
1593
|
+
# were, and reported both. Passing the fully-built project — with
|
|
1594
|
+
# the velocity signal on it — is what lets the engine reach the
|
|
1595
|
+
# input it will actually sample.
|
|
1596
|
+
perturbed_tasks = perturb_task_duration(
|
|
1597
|
+
_make_project(leaf_tasks, cpm_status_date), task_id, delta_days
|
|
1598
|
+
).tasks
|
|
1599
|
+
baseline_cpm = schedule(_make_project(leaf_tasks, cpm_status_date))
|
|
1600
|
+
perturbed_cpm = schedule(_make_project(perturbed_tasks, cpm_status_date))
|
|
1601
|
+
baseline_mc = monte_carlo(
|
|
1602
|
+
_make_project(leaf_tasks, mc_status_date),
|
|
1603
|
+
runs=n_simulations,
|
|
1604
|
+
seed=WHATIF_MC_SEED,
|
|
1605
|
+
max_runs=cap,
|
|
1606
|
+
max_tasks=settings.MC_TASK_CAP,
|
|
1607
|
+
**_mc_lag_delta_kwargs(),
|
|
1608
|
+
)
|
|
1609
|
+
perturbed_mc = monte_carlo(
|
|
1610
|
+
_make_project(perturbed_tasks, mc_status_date),
|
|
1611
|
+
runs=n_simulations,
|
|
1612
|
+
seed=WHATIF_MC_SEED,
|
|
1613
|
+
max_runs=cap,
|
|
1614
|
+
max_tasks=settings.MC_TASK_CAP,
|
|
1615
|
+
**_mc_lag_delta_kwargs(),
|
|
1616
|
+
)
|
|
1617
|
+
except SimulationCapExceeded as exc:
|
|
1618
|
+
return Response(
|
|
1619
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
1620
|
+
{"error": "simulation_cap_exceeded", "tier": "team", "message": str(exc)},
|
|
1621
|
+
status=status.HTTP_402_PAYMENT_REQUIRED,
|
|
1622
|
+
)
|
|
1623
|
+
except (CyclicDependencyError, ValueError) as exc:
|
|
1624
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
1625
|
+
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
1626
|
+
except OverflowError:
|
|
1627
|
+
return Response(
|
|
1628
|
+
{"detail": _DATE_RANGE_EXCEEDED_DETAIL},
|
|
1629
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
# A membership change in the critical set is the meaningful "the shape of the
|
|
1633
|
+
# plan moved" signal — compared as sets so a pure reordering along an unchanged
|
|
1634
|
+
# path does not read as a change.
|
|
1635
|
+
critical_path_changed = set(baseline_cpm.critical_path) != set(perturbed_cpm.critical_path)
|
|
1636
|
+
|
|
1637
|
+
def _leg(mc: Any, cpm: Any) -> dict[str, Any]:
|
|
1638
|
+
cpm_reading = schedule_result_finish(cpm)
|
|
1639
|
+
return {
|
|
1640
|
+
"p50": mc.p50.isoformat(),
|
|
1641
|
+
"p80": mc.p80.isoformat(),
|
|
1642
|
+
"p95": mc.p95.isoformat(),
|
|
1643
|
+
# Which edge of its shown day each finish is (#4204).
|
|
1644
|
+
"p50_at_day_start": mc.p50_at_day_start,
|
|
1645
|
+
"p80_at_day_start": mc.p80_at_day_start,
|
|
1646
|
+
"p95_at_day_start": mc.p95_at_day_start,
|
|
1647
|
+
"cpm_finish": cpm.project_finish.isoformat(),
|
|
1648
|
+
"cpm_finish_at_day_start": cpm_reading[1] if cpm_reading is not None else None,
|
|
1649
|
+
"critical_path": list(cpm.critical_path),
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
current = _leg(baseline_mc, baseline_cpm)
|
|
1653
|
+
whatif = _leg(perturbed_mc, perturbed_cpm)
|
|
1654
|
+
return Response(
|
|
1655
|
+
{
|
|
1656
|
+
"task_id": task_id,
|
|
1657
|
+
"applied": {
|
|
1658
|
+
"base_duration_days": base_duration_days,
|
|
1659
|
+
"duration_delta_days": delta_days,
|
|
1660
|
+
"new_duration_days": new_duration_days,
|
|
1661
|
+
},
|
|
1662
|
+
"current": current,
|
|
1663
|
+
"whatif": whatif,
|
|
1664
|
+
"critical_path_changed": critical_path_changed,
|
|
1665
|
+
"delta_vs_current": {
|
|
1666
|
+
# In working time (#4204), like `cpm_finish` below.
|
|
1667
|
+
**_mc_percentile_deltas(perturbed_mc, baseline_mc, lambda: sched_calendar),
|
|
1668
|
+
# Measured in working time (#4178): a what-if that only moves
|
|
1669
|
+
# a milestone finish between the end of a Friday and the start
|
|
1670
|
+
# of the next Monday is no shift, and one that moves it from the
|
|
1671
|
+
# start of Monday to the end of Monday is a real one.
|
|
1672
|
+
"cpm_finish": _cpm_finish_delta_days(
|
|
1673
|
+
baseline_cpm, perturbed_cpm, sched_calendar
|
|
1674
|
+
),
|
|
1675
|
+
},
|
|
1676
|
+
"runs": n_simulations,
|
|
1677
|
+
"seed": WHATIF_MC_SEED,
|
|
1678
|
+
# The data dates fed to each engine for both the current and whatif
|
|
1679
|
+
# passes (ADR-0132, floor armed on the CPM path by ADR-0752 §4) —
|
|
1680
|
+
# this view never persists, so these are the only provenance an
|
|
1681
|
+
# AI/MCP or human caller gets for how "current" was computed. Both
|
|
1682
|
+
# baseline and perturbed share one resolved value per engine within
|
|
1683
|
+
# a single call, so one top-level pair covers both `current` and
|
|
1684
|
+
# `whatif` rather than duplicating it under each (#2638). Both are
|
|
1685
|
+
# always a resolved date now (never null) — a null status_date no
|
|
1686
|
+
# longer means "no floor at all" on the CPM path.
|
|
1687
|
+
"cpm_status_date": cpm_status_date.isoformat(),
|
|
1688
|
+
"mc_status_date": mc_status_date.isoformat(),
|
|
1689
|
+
}
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
# Hard ceiling on a single forecast-history response, independent of the
|
|
1694
|
+
# retention cap. OSS retention (MC_HISTORY_CAP=100) is already below this; the
|
|
1695
|
+
# ceiling only bites the Enterprise unlimited-retention case so the endpoint can
|
|
1696
|
+
# never stream an unbounded payload (ADR-0175).
|
|
1697
|
+
MC_HISTORY_RESPONSE_MAX = 500
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _annotate_run_deltas(
|
|
1701
|
+
runs: list[MonteCarloRun], calendar_for: Callable[[], Any] | None = None
|
|
1702
|
+
) -> None:
|
|
1703
|
+
"""Attach a computed-on-read ``_delta`` to each run vs the next-older run (ADR-0108).
|
|
1704
|
+
|
|
1705
|
+
``runs`` is newest-first; positive days = the forecast slipped later (worse). The
|
|
1706
|
+
oldest row has no predecessor, so its ``_delta`` stays ``None`` (baseline). Mutates
|
|
1707
|
+
each run in place for the serializer to read. Measured in working time from each
|
|
1708
|
+
run's persisted readings (#4204); ``calendar_for`` is called only when a reading
|
|
1709
|
+
is a start-of-day one.
|
|
1710
|
+
"""
|
|
1711
|
+
for i, run in enumerate(runs):
|
|
1712
|
+
older = runs[i + 1] if i + 1 < len(runs) else None
|
|
1713
|
+
if older is None:
|
|
1714
|
+
run._delta = None # type: ignore[attr-defined]
|
|
1715
|
+
else:
|
|
1716
|
+
run._delta = _mc_percentile_deltas(run, older, calendar_for) # type: ignore[attr-defined]
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _mc_attribution_visible(request: Request, project: Project, pk: str) -> bool:
|
|
1720
|
+
"""Whether the caller may see run-author attribution, per the effective audience.
|
|
1721
|
+
|
|
1722
|
+
Resolved per-workspace (ADR-0144): ADMIN_OWNER → role ≥ ADMIN; SCHEDULER_PLUS →
|
|
1723
|
+
role ≥ SCHEDULER; NONE → never. Default ADMIN_OWNER reproduces the prior hardcoded
|
|
1724
|
+
gate exactly. Drift must not become a named-individual signal below the configured
|
|
1725
|
+
audience (VoC Morgan).
|
|
1726
|
+
"""
|
|
1727
|
+
from trueppm_api.apps.access.models import Role
|
|
1728
|
+
from trueppm_api.apps.access.permissions import _membership_role
|
|
1729
|
+
from trueppm_api.apps.scheduling.forecast_history_settings import (
|
|
1730
|
+
resolve_effective_mc_history,
|
|
1731
|
+
)
|
|
1732
|
+
from trueppm_api.apps.scheduling.models import MCAttributionAudience
|
|
1733
|
+
|
|
1734
|
+
role = _membership_role(request, pk)
|
|
1735
|
+
audience = resolve_effective_mc_history(project, "mc_history_attribution_audience")
|
|
1736
|
+
if audience == MCAttributionAudience.NONE:
|
|
1737
|
+
return False
|
|
1738
|
+
if audience == MCAttributionAudience.SCHEDULER_PLUS:
|
|
1739
|
+
return role is not None and role >= Role.SCHEDULER
|
|
1740
|
+
return role is not None and role >= Role.ADMIN # ADMIN_OWNER (default)
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
@extend_schema(
|
|
1744
|
+
parameters=[
|
|
1745
|
+
OpenApiParameter(
|
|
1746
|
+
"expand",
|
|
1747
|
+
str,
|
|
1748
|
+
description=(
|
|
1749
|
+
"Set to 'distribution' to include each run's persisted distribution "
|
|
1750
|
+
"({histogram_buckets, confidence_curve, sensitivity}) payload (#1231). "
|
|
1751
|
+
"Omitted by default to keep the list lightweight; distribution is null "
|
|
1752
|
+
"unless expanded or for legacy runs with no stored distribution."
|
|
1753
|
+
),
|
|
1754
|
+
),
|
|
1755
|
+
],
|
|
1756
|
+
responses={
|
|
1757
|
+
200: OpenApiResponse(
|
|
1758
|
+
response=inline_serializer(
|
|
1759
|
+
name="MonteCarloHistoryResponse",
|
|
1760
|
+
fields={
|
|
1761
|
+
"results": MonteCarloRunSerializer(many=True),
|
|
1762
|
+
"cap": drf_serializers.IntegerField(allow_null=True),
|
|
1763
|
+
"enabled": drf_serializers.BooleanField(),
|
|
1764
|
+
},
|
|
1765
|
+
),
|
|
1766
|
+
description=(
|
|
1767
|
+
"Project Monte Carlo run history (ADR-0175/0143). When the "
|
|
1768
|
+
"per-workspace config disables history, enabled is false and results "
|
|
1769
|
+
"is empty. Each run carries P50/P80/P95, cpm_finish, n_simulations, "
|
|
1770
|
+
"task_count, status_date (the data date the run was computed against "
|
|
1771
|
+
"— null for runs persisted before #2638), a per-percentile delta vs "
|
|
1772
|
+
"the previous run (null on the oldest row), triggered_by_name "
|
|
1773
|
+
"(non-null only for the resolved attribution audience), and "
|
|
1774
|
+
"distribution (only when ?expand=distribution)."
|
|
1775
|
+
),
|
|
1776
|
+
),
|
|
1777
|
+
404: OpenApiResponse(
|
|
1778
|
+
response=OpenApiTypes.OBJECT,
|
|
1779
|
+
description="Project does not exist.",
|
|
1780
|
+
),
|
|
1781
|
+
},
|
|
1782
|
+
)
|
|
1783
|
+
class MonteCarloHistoryView(APIView):
|
|
1784
|
+
"""Project Monte Carlo run history — forecast drift over time (ADR-0175, #961).
|
|
1785
|
+
|
|
1786
|
+
Returns persisted runs newest-first, capped at ``settings.MC_HISTORY_CAP``,
|
|
1787
|
+
each with a computed-on-read per-percentile delta versus the immediately
|
|
1788
|
+
previous (older) run. The run-author attribution (``triggered_by_name``) is
|
|
1789
|
+
serialized only for Admin/Owner (role ≥ ADMIN); every other member sees the
|
|
1790
|
+
drift values without attribution, so forecast drift cannot become a
|
|
1791
|
+
named-individual performance signal (VoC Morgan).
|
|
1792
|
+
|
|
1793
|
+
Permission: Member (any role ≥ Viewer), consistent with the forecast read.
|
|
1794
|
+
"""
|
|
1795
|
+
|
|
1796
|
+
# Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
|
|
1797
|
+
# this the project-scoped classes below resolve nothing and `has_permission`
|
|
1798
|
+
# returns True for everyone — the gate reads as live in review and enforces
|
|
1799
|
+
# nothing. Declared per view because `pk` means something else on other routes.
|
|
1800
|
+
project_url_kwarg = "pk"
|
|
1801
|
+
permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
|
|
1802
|
+
|
|
1803
|
+
def get(self, request: Request, pk: str) -> Response:
|
|
1804
|
+
"""Return the capped, newest-first run history with per-run deltas."""
|
|
1805
|
+
from trueppm_api.apps.scheduling.forecast_history_settings import (
|
|
1806
|
+
resolve_effective_mc_history,
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
project = get_object_or_404(Project, pk=pk, is_deleted=False)
|
|
1810
|
+
self.check_object_permissions(request, project)
|
|
1811
|
+
|
|
1812
|
+
# Per-workspace forecast-history config (ADR-0144, #1232), inheritable
|
|
1813
|
+
# Workspace → Program → Project. When the effective config disables history,
|
|
1814
|
+
# return 200 with an empty list + enabled:false rather than 403/404 — the
|
|
1815
|
+
# feature is off, not access-denied, so the FE renders a "history off" state.
|
|
1816
|
+
if not resolve_effective_mc_history(project, "mc_history_enabled"):
|
|
1817
|
+
return Response({"results": [], "cap": None, "enabled": False})
|
|
1818
|
+
|
|
1819
|
+
# The retention cap is now per-workspace-effective (clamped to
|
|
1820
|
+
# MC_HISTORY_HARD_CAP in the resolver) rather than the global constant.
|
|
1821
|
+
cap: int | None = resolve_effective_mc_history(project, "mc_history_retention_cap")
|
|
1822
|
+
# Hard response ceiling: even when the cap is None (Enterprise unlimited
|
|
1823
|
+
# *retention*), a single API response must stay bounded — keep the newest
|
|
1824
|
+
# MC_HISTORY_RESPONSE_MAX rows. select_related avoids an N+1 on triggered_by
|
|
1825
|
+
# when attribution is serialized.
|
|
1826
|
+
limit = cap if cap is not None else MC_HISTORY_RESPONSE_MAX
|
|
1827
|
+
# ?expand=distribution opts a single detail fetch into the heavier per-run
|
|
1828
|
+
# distribution payload (#1231); the default list stays lightweight.
|
|
1829
|
+
expand_distribution = request.query_params.get("expand") == "distribution"
|
|
1830
|
+
qs = (
|
|
1831
|
+
MonteCarloRun.objects.filter(project_id=pk)
|
|
1832
|
+
.select_related("triggered_by")
|
|
1833
|
+
.order_by("-taken_at")
|
|
1834
|
+
)
|
|
1835
|
+
# The distribution column is up to MC_DISTRIBUTION_MAX_BYTES per row; the
|
|
1836
|
+
# serializer never reads it on the default list path, so defer it there to
|
|
1837
|
+
# avoid fetching + JSON-parsing ~limit×32KB only to discard it.
|
|
1838
|
+
if not expand_distribution:
|
|
1839
|
+
qs = qs.defer("distribution")
|
|
1840
|
+
runs = list(qs[:limit])
|
|
1841
|
+
calendar_memo: list[Any] = []
|
|
1842
|
+
|
|
1843
|
+
def calendar_for() -> Any:
|
|
1844
|
+
# Composed at most once, and only if some run's reading needs it (#4204).
|
|
1845
|
+
if not calendar_memo:
|
|
1846
|
+
calendar_memo.append(compose_project_calendar(project))
|
|
1847
|
+
return calendar_memo[0]
|
|
1848
|
+
|
|
1849
|
+
_annotate_run_deltas(runs, calendar_for)
|
|
1850
|
+
can_see_attribution = _mc_attribution_visible(request, project, pk)
|
|
1851
|
+
|
|
1852
|
+
data = MonteCarloRunSerializer(
|
|
1853
|
+
runs,
|
|
1854
|
+
many=True,
|
|
1855
|
+
context={
|
|
1856
|
+
"request": request,
|
|
1857
|
+
"can_see_attribution": can_see_attribution,
|
|
1858
|
+
"expand_distribution": expand_distribution,
|
|
1859
|
+
},
|
|
1860
|
+
).data
|
|
1861
|
+
return Response({"results": data, "cap": cap, "enabled": True})
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
@extend_schema(
|
|
1865
|
+
parameters=[
|
|
1866
|
+
# Day-grained window, declared as `date` to match the burn-series
|
|
1867
|
+
# contract (#1378) — the new 0.3 computed analytics endpoints expose
|
|
1868
|
+
# since/until with one consistent type. The runtime stays lenient: a
|
|
1869
|
+
# bare ISO date is the documented format, and a full ISO datetime is
|
|
1870
|
+
# still accepted as a backward-compatible superset (see _parse_bound).
|
|
1871
|
+
OpenApiParameter(
|
|
1872
|
+
"since",
|
|
1873
|
+
OpenApiTypes.DATE,
|
|
1874
|
+
description="Only snapshots captured at or after this ISO 8601 date (YYYY-MM-DD).",
|
|
1875
|
+
),
|
|
1876
|
+
OpenApiParameter(
|
|
1877
|
+
"until",
|
|
1878
|
+
OpenApiTypes.DATE,
|
|
1879
|
+
description="Only snapshots captured at or before this ISO 8601 date (YYYY-MM-DD).",
|
|
1880
|
+
),
|
|
1881
|
+
],
|
|
1882
|
+
responses={200: ProjectForecastSnapshotSerializer(many=True)},
|
|
1883
|
+
)
|
|
1884
|
+
class ForecastSnapshotListView(ListAPIView[ProjectForecastSnapshot]):
|
|
1885
|
+
"""Project-grain forecast snapshot history (ADR-0154, #388).
|
|
1886
|
+
|
|
1887
|
+
Returns the project's forecast snapshots newest-first (paginated), the
|
|
1888
|
+
persisted record of how the CPM finish and Monte Carlo percentiles drifted
|
|
1889
|
+
over time. Read-only — rows are server-generated on recompute and by the daily
|
|
1890
|
+
floor; there is no write surface. ``?since=``/``?until=`` bound the window by
|
|
1891
|
+
``captured_at`` (ISO 8601 date YYYY-MM-DD, the documented format; a full ISO
|
|
1892
|
+
datetime is also accepted and interpreted literally, a bare date as midnight UTC).
|
|
1893
|
+
|
|
1894
|
+
Permission: Member (any role ≥ Viewer), the project-read gate — consistent
|
|
1895
|
+
with the Monte Carlo history read.
|
|
1896
|
+
"""
|
|
1897
|
+
|
|
1898
|
+
serializer_class = ProjectForecastSnapshotSerializer
|
|
1899
|
+
# Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
|
|
1900
|
+
# this the project-scoped classes below resolve nothing and `has_permission`
|
|
1901
|
+
# returns True for everyone — the gate reads as live in review and enforces
|
|
1902
|
+
# nothing. Declared per view because `pk` means something else on other routes.
|
|
1903
|
+
project_url_kwarg = "pk"
|
|
1904
|
+
permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
|
|
1905
|
+
|
|
1906
|
+
def get_queryset(self) -> models.QuerySet[ProjectForecastSnapshot]:
|
|
1907
|
+
"""Project-scoped, optionally date-bounded, newest-first."""
|
|
1908
|
+
project = get_object_or_404(Project, pk=self.kwargs["pk"], is_deleted=False)
|
|
1909
|
+
# IsProjectMember/IsProjectNotArchived are object-level — this list view has
|
|
1910
|
+
# no get_object(), so enforce them explicitly against the resolved project.
|
|
1911
|
+
self.check_object_permissions(self.request, project)
|
|
1912
|
+
|
|
1913
|
+
qs = ProjectForecastSnapshot.objects.filter(project_id=project.pk).order_by("-captured_at")
|
|
1914
|
+
since = self._parse_bound(self.request.query_params.get("since"))
|
|
1915
|
+
until = self._parse_bound(self.request.query_params.get("until"))
|
|
1916
|
+
if since is not None:
|
|
1917
|
+
qs = qs.filter(captured_at__gte=since)
|
|
1918
|
+
if until is not None:
|
|
1919
|
+
qs = qs.filter(captured_at__lte=until)
|
|
1920
|
+
return qs
|
|
1921
|
+
|
|
1922
|
+
@staticmethod
|
|
1923
|
+
def _parse_bound(raw: str | None) -> Any:
|
|
1924
|
+
"""Parse an ISO date (documented) or full datetime to a timezone-aware bound.
|
|
1925
|
+
|
|
1926
|
+
A bare ``YYYY-MM-DD`` is the documented since/until format (#1378),
|
|
1927
|
+
interpreted as midnight in the default timezone; a full ISO datetime is
|
|
1928
|
+
still accepted. ``parse_datetime`` parses a date-only string into a *naive*
|
|
1929
|
+
midnight, so any naive result is made aware here — otherwise the bound
|
|
1930
|
+
would be compared naive against the aware ``captured_at`` column (a Django
|
|
1931
|
+
RuntimeWarning and a silent offset bug).
|
|
1932
|
+
"""
|
|
1933
|
+
if not raw:
|
|
1934
|
+
return None
|
|
1935
|
+
dt = parse_datetime(raw)
|
|
1936
|
+
if dt is None:
|
|
1937
|
+
d = parse_date(raw)
|
|
1938
|
+
if d is None:
|
|
1939
|
+
return None
|
|
1940
|
+
dt = _datetime(d.year, d.month, d.day)
|
|
1941
|
+
if timezone.is_naive(dt):
|
|
1942
|
+
dt = timezone.make_aware(dt)
|
|
1943
|
+
return dt
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
class FailedTaskPagination(PageNumberPagination):
|
|
1947
|
+
"""Bounded page-number pagination for the dead-letter queue (#1317).
|
|
1948
|
+
|
|
1949
|
+
The list mixin already paged through the project default, but the bound was
|
|
1950
|
+
implicit; the dead-letter table can grow to thousands, so the cap is made
|
|
1951
|
+
explicit here. Page-number (not cursor) is retained deliberately — the
|
|
1952
|
+
inspector header shows a total ``count``, which CursorPagination omits.
|
|
1953
|
+
"""
|
|
1954
|
+
|
|
1955
|
+
page_size = 50
|
|
1956
|
+
page_size_query_param = "page_size"
|
|
1957
|
+
max_page_size = 200
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
@extend_schema_view(
|
|
1961
|
+
list=extend_schema(
|
|
1962
|
+
parameters=[
|
|
1963
|
+
OpenApiParameter(
|
|
1964
|
+
"status",
|
|
1965
|
+
str,
|
|
1966
|
+
description=(
|
|
1967
|
+
"Filter by status: dead, pending_retry, dismissed, retried. "
|
|
1968
|
+
"Invalid values are ignored (no filter)."
|
|
1969
|
+
),
|
|
1970
|
+
),
|
|
1971
|
+
OpenApiParameter(
|
|
1972
|
+
"task_name", str, description="Case-insensitive substring match on task_name."
|
|
1973
|
+
),
|
|
1974
|
+
OpenApiParameter(
|
|
1975
|
+
"failed_after",
|
|
1976
|
+
str,
|
|
1977
|
+
description="ISO-8601; keep tasks with last_failed_at at or after this time.",
|
|
1978
|
+
),
|
|
1979
|
+
OpenApiParameter(
|
|
1980
|
+
"failed_before",
|
|
1981
|
+
str,
|
|
1982
|
+
description="ISO-8601; keep tasks with last_failed_at at or before this time.",
|
|
1983
|
+
),
|
|
1984
|
+
],
|
|
1985
|
+
),
|
|
1986
|
+
)
|
|
1987
|
+
class FailedTaskViewSet(IdempotencyMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet): # type: ignore[type-arg]
|
|
1988
|
+
"""Workspace-operator endpoint for dead-lettered Celery tasks.
|
|
1989
|
+
|
|
1990
|
+
List/detail/requeue/drop and the bulk ``requeue_all``/``drop_all``: workspace
|
|
1991
|
+
operators (Django superusers, :class:`IsWorkspaceOperator`) only (#4009). The
|
|
1992
|
+
serializer exposes tracebacks, args, and kwargs which may contain internal
|
|
1993
|
+
paths or partial secrets and must not be visible to unprivileged members.
|
|
1994
|
+
|
|
1995
|
+
**Not gated on Django ``is_staff`` (#4009).** Before #4009 this viewset used
|
|
1996
|
+
``IsAdminUser``, an undocumented second admin axis that is not governed by
|
|
1997
|
+
``WorkspaceRole`` and is not self-grantable — but also not visible anywhere in
|
|
1998
|
+
the RBAC docs. Moving to ``IsWorkspaceOperator`` folds dead-letter access into
|
|
1999
|
+
the one stored-principal story the resource catalog and mail transport already
|
|
2000
|
+
use (ADR-0034, ADR-0213 C1) and narrows the population: an ``is_staff=True``
|
|
2001
|
+
account that is not also a superuser now gets 403 where it previously passed.
|
|
2002
|
+
|
|
2003
|
+
The list endpoint backs the dead-letter inspector (#694, ADR-0172) and
|
|
2004
|
+
accepts read-only filters: ``?status=`` (one of the FailedTaskStatus
|
|
2005
|
+
values), ``?task_name=`` (case-insensitive substring), and
|
|
2006
|
+
``?failed_after=`` / ``?failed_before=`` (ISO-8601, filtered on
|
|
2007
|
+
``last_failed_at``). Results keep the model default ordering
|
|
2008
|
+
(``-last_failed_at``, newest failure first).
|
|
2009
|
+
|
|
2010
|
+
Write actions (#695, ADR-0210):
|
|
2011
|
+
- ``requeue`` re-enqueues a parked task with an operator-chosen backoff,
|
|
2012
|
+
round-tripping through the durable workflow backend (ADR-0080) rather than
|
|
2013
|
+
the old raw ``send_task`` side channel.
|
|
2014
|
+
- ``drop`` soft-removes a parked task (→ DISMISSED) with an optional audit
|
|
2015
|
+
note; the row is retained so the audit survives (ADR-0084 "no silent
|
|
2016
|
+
discards").
|
|
2017
|
+
- ``requeue_all`` / ``drop_all`` apply the same action across the *current
|
|
2018
|
+
filter set*, bounded to ``FAILED_TASK_BULK_ACTION_MAX``.
|
|
2019
|
+
"""
|
|
2020
|
+
|
|
2021
|
+
serializer_class = FailedTaskSerializer
|
|
2022
|
+
permission_classes = [IsWorkspaceOperator]
|
|
2023
|
+
pagination_class = FailedTaskPagination
|
|
2024
|
+
queryset = FailedTask.objects.all()
|
|
2025
|
+
|
|
2026
|
+
def get_queryset(self) -> models.QuerySet[FailedTask]:
|
|
2027
|
+
"""Apply the inspector's read-only filters to the dead-letter list.
|
|
2028
|
+
|
|
2029
|
+
Invalid filter values are ignored rather than raising, so a malformed
|
|
2030
|
+
bookmarked URL degrades to "no filter" instead of a 400 — an operator
|
|
2031
|
+
diagnosing an incident should never be blocked by a bad query string.
|
|
2032
|
+
|
|
2033
|
+
``select_related('resolved_by')`` avoids an N+1 on the resolved-status list
|
|
2034
|
+
views (``?status=retried``/``dismissed``): the ``resolved_by_display``
|
|
2035
|
+
serializer field reads the operator FK, which would otherwise lazy-load
|
|
2036
|
+
once per row (ADR-0210 audit fields).
|
|
2037
|
+
"""
|
|
2038
|
+
qs = FailedTask.objects.select_related("resolved_by")
|
|
2039
|
+
|
|
2040
|
+
status_filter = self.request.query_params.get("status")
|
|
2041
|
+
if status_filter in FailedTaskStatus.values:
|
|
2042
|
+
qs = qs.filter(status=status_filter)
|
|
2043
|
+
|
|
2044
|
+
task_name = self.request.query_params.get("task_name")
|
|
2045
|
+
if task_name:
|
|
2046
|
+
qs = qs.filter(task_name__icontains=task_name)
|
|
2047
|
+
|
|
2048
|
+
failed_after = parse_datetime(self.request.query_params.get("failed_after", ""))
|
|
2049
|
+
if failed_after is not None:
|
|
2050
|
+
qs = qs.filter(last_failed_at__gte=failed_after)
|
|
2051
|
+
|
|
2052
|
+
failed_before = parse_datetime(self.request.query_params.get("failed_before", ""))
|
|
2053
|
+
if failed_before is not None:
|
|
2054
|
+
qs = qs.filter(last_failed_at__lte=failed_before)
|
|
2055
|
+
|
|
2056
|
+
return qs
|
|
2057
|
+
|
|
2058
|
+
def _start_requeue_workflow(self, failed: FailedTask, backoff_seconds: int) -> str:
|
|
2059
|
+
"""Start the durable requeue workflow for one task (ADR-0210 §1).
|
|
2060
|
+
|
|
2061
|
+
Routes the re-enqueue through the #652 outbox-composing workflow backend
|
|
2062
|
+
(``start_workflow``) rather than a raw ``send_task`` side channel, so a
|
|
2063
|
+
broker outage cannot silently drop the requeue. The idempotency key ties one
|
|
2064
|
+
requeue to one *observed failure* (``id`` + ``failure_count``): a
|
|
2065
|
+
double-clicked or replayed requeue collides on the key and returns the
|
|
2066
|
+
existing workflow instead of double-enqueuing.
|
|
2067
|
+
"""
|
|
2068
|
+
return start_workflow(
|
|
2069
|
+
REQUEUE_WORKFLOW,
|
|
2070
|
+
{
|
|
2071
|
+
"failed_task_id": str(failed.id),
|
|
2072
|
+
"task_name": failed.task_name,
|
|
2073
|
+
"args": failed.args,
|
|
2074
|
+
"kwargs": failed.kwargs,
|
|
2075
|
+
"backoff_seconds": backoff_seconds,
|
|
2076
|
+
},
|
|
2077
|
+
idempotency_key=f"requeue:{failed.id}:{failed.failure_count}",
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
@extend_schema(
|
|
2081
|
+
request=FailedTaskRequeueSerializer,
|
|
2082
|
+
responses={200: FailedTaskSerializer},
|
|
2083
|
+
summary="Requeue a dead-lettered task with an operator-chosen backoff",
|
|
2084
|
+
)
|
|
2085
|
+
@action(detail=True, methods=["post"], permission_classes=[IsWorkspaceOperator])
|
|
2086
|
+
def requeue(self, request: Request, pk: str | None = None) -> Response:
|
|
2087
|
+
"""Re-enqueue a parked task via the durable workflow backend (ADR-0210).
|
|
2088
|
+
|
|
2089
|
+
Only ``dead``/``pending_retry`` tasks are requeueable; a terminal task is a
|
|
2090
|
+
400. The re-enqueue is durable (workflow outbox); the ``backoff_seconds`` is
|
|
2091
|
+
a best-effort Celery countdown on the re-dispatched task.
|
|
2092
|
+
"""
|
|
2093
|
+
failed = self.get_object()
|
|
2094
|
+
if failed.status not in _ACTIONABLE_FAILED_STATUSES:
|
|
2095
|
+
return Response(
|
|
2096
|
+
{"detail": f"Cannot requeue a task with status '{failed.status}'."},
|
|
2097
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
2098
|
+
)
|
|
2099
|
+
payload = FailedTaskRequeueSerializer(data=request.data)
|
|
2100
|
+
payload.is_valid(raise_exception=True)
|
|
2101
|
+
backoff_seconds = payload.validated_data["backoff_seconds"]
|
|
2102
|
+
|
|
2103
|
+
# Start the durable workflow first, then stamp the row: the side effect is the
|
|
2104
|
+
# durable part, so if the status update failed the workflow still ran and a
|
|
2105
|
+
# re-run dedupes on the idempotency key. Both commit together under
|
|
2106
|
+
# ATOMIC_REQUESTS, so a mid-action error rolls the whole thing back.
|
|
2107
|
+
workflow_id = self._start_requeue_workflow(failed, backoff_seconds)
|
|
2108
|
+
FailedTask.objects.filter(pk=failed.pk).update(
|
|
2109
|
+
status=FailedTaskStatus.RETRIED,
|
|
2110
|
+
resolved_by=request.user,
|
|
2111
|
+
resolved_at=timezone.now(),
|
|
2112
|
+
)
|
|
2113
|
+
failed.refresh_from_db()
|
|
2114
|
+
logger.info(
|
|
2115
|
+
"dead-letter requeue: task %s (%s) requeued by user %s, backoff=%ss, workflow=%s",
|
|
2116
|
+
failed.id,
|
|
2117
|
+
failed.task_name,
|
|
2118
|
+
getattr(request.user, "pk", None),
|
|
2119
|
+
backoff_seconds,
|
|
2120
|
+
workflow_id,
|
|
2121
|
+
)
|
|
2122
|
+
data = dict(FailedTaskSerializer(failed).data)
|
|
2123
|
+
data["workflow_id"] = workflow_id
|
|
2124
|
+
return Response(data)
|
|
2125
|
+
|
|
2126
|
+
@extend_schema(
|
|
2127
|
+
request=FailedTaskDropSerializer,
|
|
2128
|
+
responses={200: FailedTaskSerializer},
|
|
2129
|
+
summary="Drop (dismiss) a dead-lettered task with an optional audit note",
|
|
2130
|
+
)
|
|
2131
|
+
@action(detail=True, methods=["post"], permission_classes=[IsWorkspaceOperator])
|
|
2132
|
+
def drop(self, request: Request, pk: str | None = None) -> Response:
|
|
2133
|
+
"""Soft-remove a parked task (→ DISMISSED) with an optional audit note.
|
|
2134
|
+
|
|
2135
|
+
The row is retained (not hard-deleted) so the note/operator/timestamp audit
|
|
2136
|
+
survives; it drops out of the operator's default ``dead``/``pending_retry``
|
|
2137
|
+
view. Re-dropping an already-dismissed task is an idempotent no-op.
|
|
2138
|
+
"""
|
|
2139
|
+
failed = self.get_object()
|
|
2140
|
+
payload = FailedTaskDropSerializer(data=request.data)
|
|
2141
|
+
payload.is_valid(raise_exception=True)
|
|
2142
|
+
note = payload.validated_data["note"]
|
|
2143
|
+
|
|
2144
|
+
if failed.status == FailedTaskStatus.DISMISSED:
|
|
2145
|
+
return Response(FailedTaskSerializer(failed).data)
|
|
2146
|
+
|
|
2147
|
+
FailedTask.objects.filter(pk=failed.pk).update(
|
|
2148
|
+
status=FailedTaskStatus.DISMISSED,
|
|
2149
|
+
resolution_note=note,
|
|
2150
|
+
resolved_by=request.user,
|
|
2151
|
+
resolved_at=timezone.now(),
|
|
2152
|
+
)
|
|
2153
|
+
failed.refresh_from_db()
|
|
2154
|
+
logger.info(
|
|
2155
|
+
"dead-letter drop: task %s (%s) dropped by user %s%s",
|
|
2156
|
+
failed.id,
|
|
2157
|
+
failed.task_name,
|
|
2158
|
+
getattr(request.user, "pk", None),
|
|
2159
|
+
" with note" if note else "",
|
|
2160
|
+
)
|
|
2161
|
+
return Response(FailedTaskSerializer(failed).data)
|
|
2162
|
+
|
|
2163
|
+
@extend_schema(
|
|
2164
|
+
request=FailedTaskRequeueSerializer,
|
|
2165
|
+
responses={200: OpenApiResponse(description="{processed, matched, capped}")},
|
|
2166
|
+
summary="Requeue every actionable task in the current filter set (bounded)",
|
|
2167
|
+
)
|
|
2168
|
+
@action(
|
|
2169
|
+
detail=False,
|
|
2170
|
+
methods=["post"],
|
|
2171
|
+
url_path="requeue-all",
|
|
2172
|
+
permission_classes=[IsWorkspaceOperator],
|
|
2173
|
+
)
|
|
2174
|
+
def requeue_all(self, request: Request) -> Response:
|
|
2175
|
+
"""Bulk-requeue the current filter set, bounded (ADR-0210 §4).
|
|
2176
|
+
|
|
2177
|
+
Applies the inspector's ``get_queryset`` filters, restricts to actionable
|
|
2178
|
+
(``dead``/``pending_retry``) tasks, and processes at most
|
|
2179
|
+
``FAILED_TASK_BULK_ACTION_MAX`` (oldest-first). ``capped`` in the response
|
|
2180
|
+
signals that more remain and the operator should repeat.
|
|
2181
|
+
"""
|
|
2182
|
+
payload = FailedTaskRequeueSerializer(data=request.data)
|
|
2183
|
+
payload.is_valid(raise_exception=True)
|
|
2184
|
+
backoff_seconds = payload.validated_data["backoff_seconds"]
|
|
2185
|
+
|
|
2186
|
+
qs = self.get_queryset().filter(status__in=_ACTIONABLE_FAILED_STATUSES)
|
|
2187
|
+
matched = qs.count()
|
|
2188
|
+
batch = list(qs.order_by("last_failed_at")[:FAILED_TASK_BULK_ACTION_MAX])
|
|
2189
|
+
for failed in batch:
|
|
2190
|
+
self._start_requeue_workflow(failed, backoff_seconds)
|
|
2191
|
+
if batch:
|
|
2192
|
+
FailedTask.objects.filter(pk__in=[f.pk for f in batch]).update(
|
|
2193
|
+
status=FailedTaskStatus.RETRIED,
|
|
2194
|
+
resolved_by=request.user,
|
|
2195
|
+
resolved_at=timezone.now(),
|
|
2196
|
+
)
|
|
2197
|
+
logger.info(
|
|
2198
|
+
"dead-letter requeue_all: %d of %d task(s) requeued by user %s, backoff=%ss",
|
|
2199
|
+
len(batch),
|
|
2200
|
+
matched,
|
|
2201
|
+
getattr(request.user, "pk", None),
|
|
2202
|
+
backoff_seconds,
|
|
2203
|
+
)
|
|
2204
|
+
return Response(
|
|
2205
|
+
{
|
|
2206
|
+
"processed": len(batch),
|
|
2207
|
+
"matched": matched,
|
|
2208
|
+
"capped": matched > FAILED_TASK_BULK_ACTION_MAX,
|
|
2209
|
+
}
|
|
2210
|
+
)
|
|
2211
|
+
|
|
2212
|
+
@extend_schema(
|
|
2213
|
+
request=FailedTaskDropSerializer,
|
|
2214
|
+
responses={200: OpenApiResponse(description="{processed, matched, capped}")},
|
|
2215
|
+
summary="Drop every task in the current filter set (bounded)",
|
|
2216
|
+
)
|
|
2217
|
+
@action(
|
|
2218
|
+
detail=False,
|
|
2219
|
+
methods=["post"],
|
|
2220
|
+
url_path="drop-all",
|
|
2221
|
+
permission_classes=[IsWorkspaceOperator],
|
|
2222
|
+
)
|
|
2223
|
+
def drop_all(self, request: Request) -> Response:
|
|
2224
|
+
"""Bulk-drop the current filter set, bounded (ADR-0210 §4).
|
|
2225
|
+
|
|
2226
|
+
Applies the inspector's ``get_queryset`` filters, excludes already-dismissed
|
|
2227
|
+
rows, and dismisses at most ``FAILED_TASK_BULK_ACTION_MAX`` (oldest-first) in
|
|
2228
|
+
a single bounded ``UPDATE`` — no per-row loop. ``capped`` signals more remain.
|
|
2229
|
+
"""
|
|
2230
|
+
payload = FailedTaskDropSerializer(data=request.data)
|
|
2231
|
+
payload.is_valid(raise_exception=True)
|
|
2232
|
+
note = payload.validated_data["note"]
|
|
2233
|
+
|
|
2234
|
+
qs = self.get_queryset().exclude(status=FailedTaskStatus.DISMISSED)
|
|
2235
|
+
matched = qs.count()
|
|
2236
|
+
ids = list(
|
|
2237
|
+
qs.order_by("last_failed_at").values_list("pk", flat=True)[:FAILED_TASK_BULK_ACTION_MAX]
|
|
2238
|
+
)
|
|
2239
|
+
processed = 0
|
|
2240
|
+
if ids:
|
|
2241
|
+
processed = FailedTask.objects.filter(pk__in=ids).update(
|
|
2242
|
+
status=FailedTaskStatus.DISMISSED,
|
|
2243
|
+
resolution_note=note,
|
|
2244
|
+
resolved_by=request.user,
|
|
2245
|
+
resolved_at=timezone.now(),
|
|
2246
|
+
)
|
|
2247
|
+
logger.info(
|
|
2248
|
+
"dead-letter drop_all: %d of %d task(s) dropped by user %s",
|
|
2249
|
+
processed,
|
|
2250
|
+
matched,
|
|
2251
|
+
getattr(request.user, "pk", None),
|
|
2252
|
+
)
|
|
2253
|
+
return Response(
|
|
2254
|
+
{
|
|
2255
|
+
"processed": processed,
|
|
2256
|
+
"matched": matched,
|
|
2257
|
+
"capped": matched > FAILED_TASK_BULK_ACTION_MAX,
|
|
2258
|
+
}
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
# ---------------------------------------------------------------------------
|
|
2263
|
+
# Velocity suggestions (ADR-0065)
|
|
2264
|
+
# ---------------------------------------------------------------------------
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
@extend_schema_view(
|
|
2268
|
+
list=extend_schema(
|
|
2269
|
+
parameters=[
|
|
2270
|
+
OpenApiParameter(
|
|
2271
|
+
"task",
|
|
2272
|
+
OpenApiTypes.UUID,
|
|
2273
|
+
location=OpenApiParameter.QUERY,
|
|
2274
|
+
required=False,
|
|
2275
|
+
description="Filter to velocity suggestions for this task.",
|
|
2276
|
+
),
|
|
2277
|
+
OpenApiParameter(
|
|
2278
|
+
"pending",
|
|
2279
|
+
OpenApiTypes.BOOL,
|
|
2280
|
+
location=OpenApiParameter.QUERY,
|
|
2281
|
+
required=False,
|
|
2282
|
+
description=(
|
|
2283
|
+
"When true, restrict to suggestions awaiting a decision "
|
|
2284
|
+
"(neither accepted nor dismissed)."
|
|
2285
|
+
),
|
|
2286
|
+
),
|
|
2287
|
+
],
|
|
2288
|
+
),
|
|
2289
|
+
)
|
|
2290
|
+
class VelocitySuggestionViewSet(
|
|
2291
|
+
IdempotencyMixin,
|
|
2292
|
+
ListModelMixin,
|
|
2293
|
+
RetrieveModelMixin,
|
|
2294
|
+
GenericViewSet[VelocitySuggestion],
|
|
2295
|
+
):
|
|
2296
|
+
"""Velocity-calibration suggestions surfaced to the PM (ADR-0065).
|
|
2297
|
+
|
|
2298
|
+
Lifecycle:
|
|
2299
|
+
- sprint close generates a row per task in the closing sprint via
|
|
2300
|
+
``compute_velocity_suggestions``
|
|
2301
|
+
- the PM accepts (writes ``most_likely_duration`` + enqueues
|
|
2302
|
+
ScheduleRequest) or dismisses (audit-only stamp) from the Task Detail
|
|
2303
|
+
Drawer
|
|
2304
|
+
- both terminal decisions are non-destructive: the row is preserved for
|
|
2305
|
+
audit; subsequent sprint closes may add new rows (one per task per
|
|
2306
|
+
sprint) but cannot overwrite a settled decision.
|
|
2307
|
+
|
|
2308
|
+
Auth model:
|
|
2309
|
+
- list / retrieve: any project member (Viewer+) — read access is fine
|
|
2310
|
+
because the surface is informational
|
|
2311
|
+
- accept / dismiss: PM or above (the action either writes a CPM input or
|
|
2312
|
+
records a governance decision; either way it's PM-scope)
|
|
2313
|
+
|
|
2314
|
+
Filtering: ``?task=<task_id>`` is the primary filter the drawer uses.
|
|
2315
|
+
``?pending=true`` restricts to suggestions awaiting a decision.
|
|
2316
|
+
"""
|
|
2317
|
+
|
|
2318
|
+
serializer_class = VelocitySuggestionSerializer
|
|
2319
|
+
permission_classes = [IsAuthenticated]
|
|
2320
|
+
queryset = VelocitySuggestion.objects.all()
|
|
2321
|
+
|
|
2322
|
+
def get_queryset(self) -> models.QuerySet[VelocitySuggestion]:
|
|
2323
|
+
qs = (
|
|
2324
|
+
VelocitySuggestion.objects.select_related("task", "sprint")
|
|
2325
|
+
.filter(task__is_deleted=False)
|
|
2326
|
+
.order_by("-created_at")
|
|
2327
|
+
)
|
|
2328
|
+
|
|
2329
|
+
task_id = self.request.query_params.get("task")
|
|
2330
|
+
if task_id:
|
|
2331
|
+
qs = qs.filter(task_id=task_id)
|
|
2332
|
+
|
|
2333
|
+
if self.request.query_params.get("pending", "").lower() == "true":
|
|
2334
|
+
qs = qs.filter(accepted_at__isnull=True, dismissed_at__isnull=True)
|
|
2335
|
+
|
|
2336
|
+
# Membership gate: only return rows whose task belongs to a project the
|
|
2337
|
+
# caller is a member of. We rely on the access app's project-membership
|
|
2338
|
+
# subquery rather than per-row checks so the list endpoint stays fast.
|
|
2339
|
+
# IsAuthenticated excludes AnonymousUser before this runs; the isinstance
|
|
2340
|
+
# guard is there to narrow the type for mypy.
|
|
2341
|
+
from django.contrib.auth.models import AbstractBaseUser
|
|
2342
|
+
|
|
2343
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
2344
|
+
|
|
2345
|
+
user = self.request.user
|
|
2346
|
+
if not isinstance(user, AbstractBaseUser):
|
|
2347
|
+
return qs.none()
|
|
2348
|
+
# is_deleted=False (#819): without this, a user whose membership was
|
|
2349
|
+
# soft-deleted still sees velocity suggestions for that project via the
|
|
2350
|
+
# list endpoint. The accept/dismiss actions go through IsProjectAdmin
|
|
2351
|
+
# which already filters soft-deleted, so the write path is safe — only
|
|
2352
|
+
# the list path was leaking.
|
|
2353
|
+
member_project_ids = ProjectMembership.objects.filter(
|
|
2354
|
+
user=user, is_deleted=False, project__is_deleted=False
|
|
2355
|
+
).values_list("project_id", flat=True)
|
|
2356
|
+
qs = qs.filter(task__project_id__in=member_project_ids)
|
|
2357
|
+
return qs
|
|
2358
|
+
|
|
2359
|
+
@staticmethod
|
|
2360
|
+
def _broadcast_settled(project_id: Any, suggestion_pk: Any, event_type: str) -> None:
|
|
2361
|
+
"""Announce that a velocity suggestion was accepted or dismissed (#2845).
|
|
2362
|
+
|
|
2363
|
+
Both decisions mutate shared state a collaborator has on screen — the
|
|
2364
|
+
suggestion disappears from the pending list for everyone — and neither
|
|
2365
|
+
emitted anything before. Deferred to commit and built from plain scalars so
|
|
2366
|
+
the closure never re-reads an ORM instance (broadcast-check H-1). The payload
|
|
2367
|
+
is ID-only: a client re-reads through the serializer, which re-applies the
|
|
2368
|
+
ADR-0104 velocity gate the response itself goes through.
|
|
2369
|
+
"""
|
|
2370
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
2371
|
+
|
|
2372
|
+
project_id_str = str(project_id)
|
|
2373
|
+
suggestion_id_str = str(suggestion_pk)
|
|
2374
|
+
transaction.on_commit(
|
|
2375
|
+
lambda: broadcast_board_event(project_id_str, event_type, {"id": suggestion_id_str})
|
|
2376
|
+
)
|
|
2377
|
+
|
|
2378
|
+
# ``request=None`` asserted rather than inferred (#2840): both decisions are
|
|
2379
|
+
# carried by the URL alone. The pre-fix schema published no requestBody here
|
|
2380
|
+
# only because VelocitySuggestionSerializer exposes no writable field — an
|
|
2381
|
+
# accident of the fallback that any future writable field would have reversed.
|
|
2382
|
+
# ``responses`` names the 409 the idempotency guards return so a client is not
|
|
2383
|
+
# left inferring it from the prose.
|
|
2384
|
+
@extend_schema(
|
|
2385
|
+
summary="Accept a velocity suggestion",
|
|
2386
|
+
request=None,
|
|
2387
|
+
responses={
|
|
2388
|
+
200: VelocitySuggestionSerializer,
|
|
2389
|
+
409: OpenApiResponse(description="Suggestion has already been dismissed."),
|
|
2390
|
+
422: OpenApiResponse(
|
|
2391
|
+
description="Accepting would violate the task's three-point estimate ordering."
|
|
2392
|
+
),
|
|
2393
|
+
},
|
|
2394
|
+
)
|
|
2395
|
+
@action(
|
|
2396
|
+
detail=True,
|
|
2397
|
+
methods=["post"],
|
|
2398
|
+
permission_classes=[IsAuthenticated, IsProjectAdmin, IsProjectNotArchived],
|
|
2399
|
+
)
|
|
2400
|
+
def accept(self, request: Request, pk: str | None = None) -> Response:
|
|
2401
|
+
"""Apply the suggested duration to the task and enqueue a CPM recompute.
|
|
2402
|
+
|
|
2403
|
+
Idempotent: a suggestion that is already accepted returns 200 with the
|
|
2404
|
+
existing row. Re-applying after dismiss is rejected with 409 so the
|
|
2405
|
+
audit trail of the original decision is preserved.
|
|
2406
|
+
"""
|
|
2407
|
+
suggestion = self.get_object()
|
|
2408
|
+
# Object-level permission keys off the task's project.
|
|
2409
|
+
self.check_object_permissions(request, suggestion.task)
|
|
2410
|
+
|
|
2411
|
+
if suggestion.accepted_at is not None:
|
|
2412
|
+
# get_serializer injects request context so the serializer's ADR-0104
|
|
2413
|
+
# velocity gate fires on these action responses too (#949).
|
|
2414
|
+
return Response(self.get_serializer(suggestion).data)
|
|
2415
|
+
if suggestion.dismissed_at is not None:
|
|
2416
|
+
return Response(
|
|
2417
|
+
{"detail": "Suggestion has already been dismissed."},
|
|
2418
|
+
status=status.HTTP_409_CONFLICT,
|
|
2419
|
+
)
|
|
2420
|
+
|
|
2421
|
+
task = suggestion.task
|
|
2422
|
+
project_id = task.project_id
|
|
2423
|
+
|
|
2424
|
+
# A velocity suggestion writes most_likely_duration alone. When the task
|
|
2425
|
+
# already carries a complete three-point estimate, overwriting the middle
|
|
2426
|
+
# value can break the optimistic <= most_likely <= pessimistic invariant
|
|
2427
|
+
# the scheduler enforces — the invalid triple then detonates the next
|
|
2428
|
+
# CPM/program recompute instead of failing here (#2002). Refuse so the PM
|
|
2429
|
+
# adjusts the optimistic/pessimistic estimates rather than corrupting them
|
|
2430
|
+
# silently. No-op for the common case where opt/pess are unset.
|
|
2431
|
+
if not three_point_estimates_ordered(
|
|
2432
|
+
task.optimistic_duration,
|
|
2433
|
+
suggestion.suggested_duration,
|
|
2434
|
+
task.pessimistic_duration,
|
|
2435
|
+
):
|
|
2436
|
+
return Response(
|
|
2437
|
+
{
|
|
2438
|
+
"detail": (
|
|
2439
|
+
"Accepting this suggestion would set most likely duration to "
|
|
2440
|
+
f"{suggestion.suggested_duration} days, which violates this "
|
|
2441
|
+
"task's three-point estimate ordering (optimistic ≤ most "
|
|
2442
|
+
f"likely ≤ pessimistic; optimistic is {task.optimistic_duration}, "
|
|
2443
|
+
f"pessimistic is {task.pessimistic_duration}). Adjust the "
|
|
2444
|
+
"optimistic/pessimistic estimates first."
|
|
2445
|
+
)
|
|
2446
|
+
},
|
|
2447
|
+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
2448
|
+
)
|
|
2449
|
+
|
|
2450
|
+
with transaction.atomic():
|
|
2451
|
+
task.most_likely_duration = suggestion.suggested_duration
|
|
2452
|
+
task.save(update_fields=["most_likely_duration"])
|
|
2453
|
+
VelocitySuggestion.objects.filter(pk=suggestion.pk).update(
|
|
2454
|
+
accepted_at=timezone.now(),
|
|
2455
|
+
accepted_by=request.user,
|
|
2456
|
+
)
|
|
2457
|
+
# CPM cascade: the new most_likely_duration shifts the Monte Carlo
|
|
2458
|
+
# forecast on the next run. Enqueue inside the transaction so the
|
|
2459
|
+
# request is visible to the drain only after the task save commits.
|
|
2460
|
+
transaction.on_commit(
|
|
2461
|
+
lambda: enqueue_recalculate(
|
|
2462
|
+
str(project_id),
|
|
2463
|
+
reason=ScheduleRequestReason.TASK_CHANGE,
|
|
2464
|
+
)
|
|
2465
|
+
)
|
|
2466
|
+
# #2845: the recalc above does NOT cover this write. most_likely_duration
|
|
2467
|
+
# is a Monte Carlo input and is absent from _CPM_DELTA_FIELDS, so the
|
|
2468
|
+
# recalc's task_dates_updated delta — built from the subset of tasks whose
|
|
2469
|
+
# _CPM_DELTA_FIELDS actually moved — will not carry this task unless its
|
|
2470
|
+
# deterministic CPM output happens to shift for an unrelated reason. And
|
|
2471
|
+
# cpm_complete, which does fire unconditionally, is explicitly documented
|
|
2472
|
+
# on the client as pill-and-stats only ("the tasks cache is maintained by
|
|
2473
|
+
# task_dates_updated", ADR-0091). So there was no live-update path of any
|
|
2474
|
+
# kind for a field a collaborator can see change.
|
|
2475
|
+
#
|
|
2476
|
+
# Two events, because two things changed: the task's own field (the
|
|
2477
|
+
# canonical ADR-0152 delta, which the existing frontend handler already
|
|
2478
|
+
# knows how to apply) and the suggestion row settling.
|
|
2479
|
+
#
|
|
2480
|
+
# Scalars snapshotted before the closure, and both deferred to commit —
|
|
2481
|
+
# broadcast_task_updated is best-effort and its docstring requires the
|
|
2482
|
+
# caller to wrap it (broadcast-check H-1).
|
|
2483
|
+
from trueppm_api.apps.sync.broadcast import broadcast_task_updated
|
|
2484
|
+
|
|
2485
|
+
project_id_str = str(project_id)
|
|
2486
|
+
task_id_str = str(task.pk)
|
|
2487
|
+
task_version = task.server_version
|
|
2488
|
+
actor_id_str = str(request.user.pk)
|
|
2489
|
+
transaction.on_commit(
|
|
2490
|
+
lambda: broadcast_task_updated(
|
|
2491
|
+
project_id_str,
|
|
2492
|
+
task_id=task_id_str,
|
|
2493
|
+
changed_fields=["most_likely_duration"],
|
|
2494
|
+
version=task_version,
|
|
2495
|
+
actor_id=actor_id_str,
|
|
2496
|
+
)
|
|
2497
|
+
)
|
|
2498
|
+
self._broadcast_settled(project_id, suggestion.pk, "velocity_suggestion_accepted")
|
|
2499
|
+
|
|
2500
|
+
suggestion.refresh_from_db()
|
|
2501
|
+
# get_serializer injects request context so the serializer's ADR-0104
|
|
2502
|
+
# velocity gate fires on these action responses too (#949).
|
|
2503
|
+
return Response(self.get_serializer(suggestion).data)
|
|
2504
|
+
|
|
2505
|
+
@extend_schema(
|
|
2506
|
+
summary="Dismiss a velocity suggestion",
|
|
2507
|
+
request=None,
|
|
2508
|
+
responses={
|
|
2509
|
+
200: VelocitySuggestionSerializer,
|
|
2510
|
+
409: OpenApiResponse(description="Suggestion has already been accepted."),
|
|
2511
|
+
},
|
|
2512
|
+
)
|
|
2513
|
+
@action(
|
|
2514
|
+
detail=True,
|
|
2515
|
+
methods=["post"],
|
|
2516
|
+
permission_classes=[IsAuthenticated, IsProjectAdmin, IsProjectNotArchived],
|
|
2517
|
+
)
|
|
2518
|
+
def dismiss(self, request: Request, pk: str | None = None) -> Response:
|
|
2519
|
+
"""Mark a suggestion as dismissed without touching the task.
|
|
2520
|
+
|
|
2521
|
+
Idempotent: a suggestion that is already dismissed returns 200 with the
|
|
2522
|
+
existing row. Dismissing after accept is rejected with 409 to preserve
|
|
2523
|
+
the original decision.
|
|
2524
|
+
"""
|
|
2525
|
+
suggestion = self.get_object()
|
|
2526
|
+
self.check_object_permissions(request, suggestion.task)
|
|
2527
|
+
|
|
2528
|
+
if suggestion.dismissed_at is not None:
|
|
2529
|
+
# get_serializer injects request context so the serializer's ADR-0104
|
|
2530
|
+
# velocity gate fires on these action responses too (#949).
|
|
2531
|
+
return Response(self.get_serializer(suggestion).data)
|
|
2532
|
+
if suggestion.accepted_at is not None:
|
|
2533
|
+
return Response(
|
|
2534
|
+
{"detail": "Suggestion has already been accepted."},
|
|
2535
|
+
status=status.HTTP_409_CONFLICT,
|
|
2536
|
+
)
|
|
2537
|
+
|
|
2538
|
+
with transaction.atomic():
|
|
2539
|
+
VelocitySuggestion.objects.filter(pk=suggestion.pk).update(
|
|
2540
|
+
dismissed_at=timezone.now(),
|
|
2541
|
+
dismissed_by=request.user,
|
|
2542
|
+
)
|
|
2543
|
+
# #2845: dismissing removes the row from every other member's pending
|
|
2544
|
+
# list. Wrapped in atomic() so the on_commit registration inside
|
|
2545
|
+
# _broadcast_settled has a transaction to hang off — outside one it would
|
|
2546
|
+
# fire immediately, before the UPDATE is durable.
|
|
2547
|
+
self._broadcast_settled(
|
|
2548
|
+
suggestion.task.project_id, suggestion.pk, "velocity_suggestion_dismissed"
|
|
2549
|
+
)
|
|
2550
|
+
suggestion.refresh_from_db()
|
|
2551
|
+
# get_serializer injects request context so the serializer's ADR-0104
|
|
2552
|
+
# velocity gate fires on these action responses too (#949).
|
|
2553
|
+
return Response(self.get_serializer(suggestion).data)
|
|
2554
|
+
|
|
2555
|
+
|
|
2556
|
+
# ---------------------------------------------------------------------------
|
|
2557
|
+
# Schedule value derivation (ADR-0218, #1058)
|
|
2558
|
+
# ---------------------------------------------------------------------------
|
|
2559
|
+
|
|
2560
|
+
# The CPM quantities the derivation endpoint can explain. Mirrors
|
|
2561
|
+
# trueppm_scheduler.Quantity so the API contract and the engine cannot drift.
|
|
2562
|
+
# scheduled_start (ADR-0752) is included so a consumer — human or MCP client —
|
|
2563
|
+
# can ask "why does this task's SPAN start here", not only its remaining-work
|
|
2564
|
+
# window; scheduled_finish is deliberately NOT a member (it is always
|
|
2565
|
+
# identical to early_finish, so asking for it twice would invite two answers).
|
|
2566
|
+
_CPM_DERIVATION_QUANTITIES = frozenset(
|
|
2567
|
+
{
|
|
2568
|
+
"early_start",
|
|
2569
|
+
"early_finish",
|
|
2570
|
+
"late_start",
|
|
2571
|
+
"late_finish",
|
|
2572
|
+
"total_float",
|
|
2573
|
+
"free_float",
|
|
2574
|
+
"scheduled_start",
|
|
2575
|
+
}
|
|
2576
|
+
)
|
|
2577
|
+
# The Monte Carlo percentile quantities, whose derivation is the risk premium over
|
|
2578
|
+
# the deterministic CPM finish plus the ADR-0140 sensitivity drivers (#987).
|
|
2579
|
+
_MC_DERIVATION_QUANTITIES = frozenset({"p50", "p80", "p95"})
|
|
2580
|
+
|
|
2581
|
+
|
|
2582
|
+
def _build_cpm_sched_project(project: Project, pk: str) -> Any:
|
|
2583
|
+
"""Assemble the scheduler ``Project`` for a deterministic CPM derivation run.
|
|
2584
|
+
|
|
2585
|
+
Mirrors the input construction of ``scheduling.tasks._run_schedule`` — the
|
|
2586
|
+
**deterministic pass that computes the stored schedule** — through the same shared
|
|
2587
|
+
converters (``compose_project_calendar`` / ``build_sched_tasks`` /
|
|
2588
|
+
``build_sched_graph``), the same committed-only task set, the same
|
|
2589
|
+
cross-project/non-committed edge drop, and the same data-date and velocity inputs.
|
|
2590
|
+
A derivation explains a value a user is reading off a task row, and that value came
|
|
2591
|
+
from ``_run_schedule``; matching anything else explains a network nobody was shown.
|
|
2592
|
+
|
|
2593
|
+
This docstring used to name :func:`run_monte_carlo` as the thing to mirror, which
|
|
2594
|
+
is how it inherited that endpoint's defect wholesale: before #3527 neither applied
|
|
2595
|
+
the ADR-0105 summary strip, so the mirror was faithful and both were wrong. Naming
|
|
2596
|
+
the deterministic pass instead makes the next drift detectable, because
|
|
2597
|
+
``_run_schedule`` is the only one of the three whose output is persisted and
|
|
2598
|
+
therefore independently checkable.
|
|
2599
|
+
|
|
2600
|
+
A summary row is consequently absent from the returned graph, and a derivation
|
|
2601
|
+
request naming one now gets the ``UnknownTaskError`` 404 — correct, because a
|
|
2602
|
+
summary's dates are rolled up from its leaves and were never produced by a CPM pass
|
|
2603
|
+
that could be explained.
|
|
2604
|
+
"""
|
|
2605
|
+
from trueppm_scheduler.models import Project as SchedProject
|
|
2606
|
+
|
|
2607
|
+
from trueppm_api.apps.projects.services import scheduler_velocity_inputs
|
|
2608
|
+
|
|
2609
|
+
sched_calendar = compose_project_calendar(project)
|
|
2610
|
+
db_tasks = list(Task.committed.filter(project=project).select_related("sprint"))
|
|
2611
|
+
suggest_approve = project.estimation_mode == EstimationMode.SUGGEST_APPROVE
|
|
2612
|
+
sched_tasks = build_sched_tasks(db_tasks, suggest_approve=suggest_approve)
|
|
2613
|
+
|
|
2614
|
+
included_ids = {str(t.id) for t in db_tasks}
|
|
2615
|
+
# Was a second, byte-identical copy of _build_sched_deps' body (#3527). Behaviour
|
|
2616
|
+
# was the same, so nothing was wrong today — but a duplicated edge filter is the
|
|
2617
|
+
# very drift class this issue is an instance of, and the next change to the edge
|
|
2618
|
+
# rules (soft-deleted dependencies, #3532) would have had to find both.
|
|
2619
|
+
#
|
|
2620
|
+
# #3532 is that next change, and it landed on the deduplicated shape: the
|
|
2621
|
+
# soft-delete filter lives once, in _build_sched_deps' Dependency.live read, and
|
|
2622
|
+
# the derivation inherits it here rather than carrying its own copy.
|
|
2623
|
+
sched_deps = _build_sched_deps(pk, included_ids)
|
|
2624
|
+
|
|
2625
|
+
velocity_samples, sprint_length_days = scheduler_velocity_inputs(
|
|
2626
|
+
project.pk, sched_calendar.working_days
|
|
2627
|
+
)
|
|
2628
|
+
|
|
2629
|
+
graph = build_sched_graph(db_tasks, sched_tasks, sched_deps)
|
|
2630
|
+
|
|
2631
|
+
return SchedProject(
|
|
2632
|
+
id=str(project.pk),
|
|
2633
|
+
name=project.name,
|
|
2634
|
+
start_date=project.start_date,
|
|
2635
|
+
tasks=graph.tasks,
|
|
2636
|
+
dependencies=graph.dependencies,
|
|
2637
|
+
calendar=sched_calendar,
|
|
2638
|
+
status_date=resolve_cpm_status_date(project.status_date),
|
|
2639
|
+
velocity_samples=velocity_samples or None,
|
|
2640
|
+
sprint_length_days=sprint_length_days,
|
|
2641
|
+
)
|
|
2642
|
+
|
|
2643
|
+
|
|
2644
|
+
class ScheduleDerivationView(McpReadableViewMixin, APIView):
|
|
2645
|
+
"""The server-computed *why* behind a computed schedule value (ADR-0218, #1058).
|
|
2646
|
+
|
|
2647
|
+
``GET /projects/<pk>/schedule/derivation/?task_id=<id>&quantity=<quantity>``
|
|
2648
|
+
|
|
2649
|
+
Returns the derivation of a single computed value — the driving predecessor/
|
|
2650
|
+
successor chain, the binding constraint, each term's lag and calendar-snap
|
|
2651
|
+
contribution, and which CPM pass (forward/backward/float) set it. The
|
|
2652
|
+
derivation is computed server-side from the engine's own pass data by
|
|
2653
|
+
:func:`trueppm_scheduler.derive_value`; it is never recomputed in the browser
|
|
2654
|
+
and nothing is fabricated (rule 120) — a contribution is emitted only for a
|
|
2655
|
+
constraint the engine actually evaluates.
|
|
2656
|
+
|
|
2657
|
+
For a Monte Carlo percentile quantity (``p50``/``p80``/``p95``) the "why" is
|
|
2658
|
+
the deterministic ``cpm_finish``, the signed ``delta_vs_cpm`` risk premium, and
|
|
2659
|
+
the ADR-0140 sensitivity drivers of the latest persisted run (#987) — the
|
|
2660
|
+
existing engine output surfaced honestly, not a second recomputation.
|
|
2661
|
+
|
|
2662
|
+
RBAC: any project member (Viewer+) may read a derivation; ``McpReadableViewMixin``
|
|
2663
|
+
additionally exposes it to an ``mcp:read`` API token confined to safe methods,
|
|
2664
|
+
so an AI/MCP agent can cite the *why* alongside the value.
|
|
2665
|
+
"""
|
|
2666
|
+
|
|
2667
|
+
# ADR-0678 (#2482): projects/<pk>/schedule/derivation/
|
|
2668
|
+
mcp_scope = McpScope.PATH
|
|
2669
|
+
|
|
2670
|
+
# Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
|
|
2671
|
+
# this the project-scoped classes below resolve nothing and `has_permission`
|
|
2672
|
+
# returns True for everyone — the gate reads as live in review and enforces
|
|
2673
|
+
# nothing. Declared per view because `pk` means something else on other routes.
|
|
2674
|
+
project_url_kwarg = "pk"
|
|
2675
|
+
permission_classes = [IsAuthenticated, IsProjectMember, IsProjectNotArchived]
|
|
2676
|
+
|
|
2677
|
+
# A CPM quantity runs a full ``schedule()`` pass inside ``derive_value`` on every
|
|
2678
|
+
# call (no precomputed result is passed), so this is as compute-heavy as its
|
|
2679
|
+
# Monte Carlo siblings and was missed by #1808's enumeration (#2839). The scope
|
|
2680
|
+
# bounds human callers; ``mcp_compute_heavy`` stacks the tighter per-token bucket.
|
|
2681
|
+
mcp_compute_heavy = True
|
|
2682
|
+
throttle_classes = [ScopedRateThrottle]
|
|
2683
|
+
throttle_scope = "cpm_on_read"
|
|
2684
|
+
|
|
2685
|
+
@extend_schema(
|
|
2686
|
+
operation_id="project_schedule_derivation",
|
|
2687
|
+
summary="Derivation (the why) of a computed schedule value",
|
|
2688
|
+
parameters=[
|
|
2689
|
+
OpenApiParameter(
|
|
2690
|
+
name="task_id",
|
|
2691
|
+
type=OpenApiTypes.STR,
|
|
2692
|
+
location=OpenApiParameter.QUERY,
|
|
2693
|
+
required=True,
|
|
2694
|
+
description="The task whose computed value is being explained.",
|
|
2695
|
+
),
|
|
2696
|
+
OpenApiParameter(
|
|
2697
|
+
name="quantity",
|
|
2698
|
+
type=OpenApiTypes.STR,
|
|
2699
|
+
location=OpenApiParameter.QUERY,
|
|
2700
|
+
required=True,
|
|
2701
|
+
description=(
|
|
2702
|
+
"Which computed value to explain: a CPM quantity "
|
|
2703
|
+
"(early_start, early_finish, late_start, late_finish, "
|
|
2704
|
+
"total_float, free_float, scheduled_start) or a Monte Carlo "
|
|
2705
|
+
"percentile (p50, p80, p95). CPM quantities return {task_id, task_name, "
|
|
2706
|
+
"quantity, value, pass, is_critical, binding, contributions[]}, "
|
|
2707
|
+
"each contribution carrying its source task, dep_type, lag_days, "
|
|
2708
|
+
"imposed_date, calendar_days_added, slack_days, and is_binding. "
|
|
2709
|
+
"Percentiles return {quantity, value, value_at_day_start, cpm_finish, "
|
|
2710
|
+
"cpm_finish_at_day_start, delta_vs_cpm_days (measured in working time, "
|
|
2711
|
+
"#4204), drivers[]} from the latest simulation."
|
|
2712
|
+
),
|
|
2713
|
+
),
|
|
2714
|
+
],
|
|
2715
|
+
responses={
|
|
2716
|
+
200: OpenApiResponse(
|
|
2717
|
+
response=PolymorphicProxySerializer(
|
|
2718
|
+
component_name="ScheduleDerivationResponse",
|
|
2719
|
+
serializers=[
|
|
2720
|
+
ScheduleCpmDerivationSerializer,
|
|
2721
|
+
ScheduleMonteCarloDerivationSerializer,
|
|
2722
|
+
],
|
|
2723
|
+
# Neither shape carries a field naming which one it is — the
|
|
2724
|
+
# caller already knows, from the `quantity` query param it sent,
|
|
2725
|
+
# which shape comes back. A discriminator field would have to be
|
|
2726
|
+
# fabricated onto the wire payload, which ADR-0218's "nothing is
|
|
2727
|
+
# fabricated" rule (rule 120) forbids just to satisfy
|
|
2728
|
+
# client-generator tooling.
|
|
2729
|
+
resource_type_field_name=None,
|
|
2730
|
+
),
|
|
2731
|
+
description=(
|
|
2732
|
+
"The derivation of the requested value. A CPM quantity returns "
|
|
2733
|
+
"ScheduleCpmDerivationResponse; a Monte Carlo percentile "
|
|
2734
|
+
"(p50/p80/p95) returns ScheduleMonteCarloDerivationResponse, which "
|
|
2735
|
+
"additionally carries forecast_staleness "
|
|
2736
|
+
"(current|project_changed|aged|unknown), plan_version and "
|
|
2737
|
+
"plan_version_current (#3140), so a cited percentile states whether "
|
|
2738
|
+
"the run it comes from still describes the current plan rather than "
|
|
2739
|
+
"presenting a superseded value with full derivation confidence. "
|
|
2740
|
+
"`project_changed` means the project was written to (or its CPM "
|
|
2741
|
+
"output recomputed) since the run — grounds for rerunning, NOT "
|
|
2742
|
+
"evidence that a schedule date changed."
|
|
2743
|
+
),
|
|
2744
|
+
),
|
|
2745
|
+
400: OpenApiResponse(
|
|
2746
|
+
description="Missing/unknown quantity, or invalid schedule input."
|
|
2747
|
+
),
|
|
2748
|
+
404: OpenApiResponse(
|
|
2749
|
+
description="Unknown project or task, or no simulation available."
|
|
2750
|
+
),
|
|
2751
|
+
},
|
|
2752
|
+
)
|
|
2753
|
+
def get(self, request: Request, pk: str) -> Response:
|
|
2754
|
+
from trueppm_scheduler.derive import UnknownTaskError, derive_value
|
|
2755
|
+
from trueppm_scheduler.engine import CyclicDependencyError
|
|
2756
|
+
|
|
2757
|
+
quantity = request.query_params.get("quantity")
|
|
2758
|
+
if not quantity:
|
|
2759
|
+
return Response(
|
|
2760
|
+
{"detail": "The 'quantity' query parameter is required."},
|
|
2761
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
2762
|
+
)
|
|
2763
|
+
if quantity not in _CPM_DERIVATION_QUANTITIES and quantity not in _MC_DERIVATION_QUANTITIES:
|
|
2764
|
+
allowed = ", ".join(sorted(_CPM_DERIVATION_QUANTITIES | _MC_DERIVATION_QUANTITIES))
|
|
2765
|
+
return Response(
|
|
2766
|
+
{"detail": f"Unknown quantity {quantity!r}; must be one of: {allowed}."},
|
|
2767
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
2768
|
+
)
|
|
2769
|
+
|
|
2770
|
+
project = get_object_or_404(
|
|
2771
|
+
Project.objects.select_related("calendar", "program__calendar").prefetch_related(
|
|
2772
|
+
"tasks",
|
|
2773
|
+
"calendar__exceptions",
|
|
2774
|
+
"calendar_layers__calendar__exceptions",
|
|
2775
|
+
# ADR-0441: the resolver reads the program calendar's exceptions when a
|
|
2776
|
+
# project inherits its base calendar from the program.
|
|
2777
|
+
"program__calendar__exceptions",
|
|
2778
|
+
),
|
|
2779
|
+
pk=pk,
|
|
2780
|
+
is_deleted=False,
|
|
2781
|
+
)
|
|
2782
|
+
# 404 (not 403) for a non-member: the object-scope oracle keeps a project's
|
|
2783
|
+
# existence from leaking to a non-member (mirrors MonteCarloLatestView).
|
|
2784
|
+
self.check_object_permissions(request, project)
|
|
2785
|
+
|
|
2786
|
+
if quantity in _MC_DERIVATION_QUANTITIES:
|
|
2787
|
+
return self._monte_carlo_derivation(project, quantity)
|
|
2788
|
+
|
|
2789
|
+
task_id = request.query_params.get("task_id")
|
|
2790
|
+
if not task_id:
|
|
2791
|
+
return Response(
|
|
2792
|
+
{"detail": "The 'task_id' query parameter is required for a CPM quantity."},
|
|
2793
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
2794
|
+
)
|
|
2795
|
+
|
|
2796
|
+
# _build_cpm_sched_project now shapes the graph (#3527), so it can raise the
|
|
2797
|
+
# engine's InvalidScheduleInput. It is inside the try for the same reason
|
|
2798
|
+
# derive_value is: an unguarded ValueError here becomes a 500, and this view
|
|
2799
|
+
# already documents a 400 for "invalid schedule input".
|
|
2800
|
+
try:
|
|
2801
|
+
sched_project = _build_cpm_sched_project(project, pk)
|
|
2802
|
+
if not sched_project.tasks:
|
|
2803
|
+
return Response(
|
|
2804
|
+
{"detail": "Project has no committed tasks to schedule."},
|
|
2805
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
2806
|
+
)
|
|
2807
|
+
derivation = derive_value(sched_project, task_id, quantity)
|
|
2808
|
+
except UnknownTaskError:
|
|
2809
|
+
return Response(
|
|
2810
|
+
{"detail": f"Task {task_id!r} is not in the scheduled network."},
|
|
2811
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
2812
|
+
)
|
|
2813
|
+
except (CyclicDependencyError, ValueError) as exc:
|
|
2814
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
2815
|
+
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
2816
|
+
except OverflowError:
|
|
2817
|
+
return Response(
|
|
2818
|
+
{"detail": _DATE_RANGE_EXCEEDED_DETAIL},
|
|
2819
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
2820
|
+
)
|
|
2821
|
+
return Response(derivation.to_dict())
|
|
2822
|
+
|
|
2823
|
+
def _monte_carlo_derivation(self, project: Project, quantity: str) -> Response:
|
|
2824
|
+
"""Derive a Monte Carlo percentile from the latest run (#987).
|
|
2825
|
+
|
|
2826
|
+
The percentile's "why" is the deterministic ``cpm_finish`` it is measured
|
|
2827
|
+
against, the signed ``delta_vs_cpm`` days of risk premium, and the ADR-0140
|
|
2828
|
+
sensitivity drivers (the tasks whose duration most moves the finish). All
|
|
2829
|
+
three are already computed on the run — this surfaces them, it does not
|
|
2830
|
+
recompute the forecast.
|
|
2831
|
+
|
|
2832
|
+
Reads the shared ``mc_latest`` cache first, falling back to the most recent
|
|
2833
|
+
persisted ``MonteCarloRun`` — the same order as ``MonteCarloLatestView``.
|
|
2834
|
+
The cache-first read matters since #1502: a Member/Viewer-triggered run
|
|
2835
|
+
refreshes the cache but persists no attributed row, and the derivation is
|
|
2836
|
+
part of the open read surface, so it must see cache-only forecasts.
|
|
2837
|
+
"""
|
|
2838
|
+
# This endpoint exists so an agent can cite the *why* beside the value
|
|
2839
|
+
# (ADR-0218). A percentile whose run predates the current plan is a stale
|
|
2840
|
+
# citation delivered with full derivation confidence, so the staleness
|
|
2841
|
+
# discriminant travels with the derivation too (#3140) — otherwise
|
|
2842
|
+
# `get_monte_carlo_forecast` and `get_schedule_derivation` report opposite
|
|
2843
|
+
# trustworthiness for one run.
|
|
2844
|
+
# Read off the project `get()` already loaded — it is fetched without
|
|
2845
|
+
# `.only()`/`.defer()`, so both columns are in memory and this costs nothing.
|
|
2846
|
+
# `_current_plan_state` exists for the caller that does NOT hold the row.
|
|
2847
|
+
pk = project.pk
|
|
2848
|
+
plan_version_current = project.last_sync_version
|
|
2849
|
+
recalculated_at = project.recalculated_at
|
|
2850
|
+
today = timezone.localdate()
|
|
2851
|
+
cached = cache.get(mc_latest_cache_key(pk))
|
|
2852
|
+
if cached is not None:
|
|
2853
|
+
return Response(
|
|
2854
|
+
{
|
|
2855
|
+
"quantity": quantity,
|
|
2856
|
+
"value": cached.get(quantity),
|
|
2857
|
+
"pass": "monte_carlo",
|
|
2858
|
+
"cpm_finish": cached.get("cpm_finish"),
|
|
2859
|
+
"value_at_day_start": cached.get(f"{quantity}_at_day_start"),
|
|
2860
|
+
"cpm_finish_at_day_start": cached.get("cpm_finish_at_day_start"),
|
|
2861
|
+
"delta_vs_cpm_days": (cached.get("delta_vs_cpm") or {}).get(quantity),
|
|
2862
|
+
"drivers": cached.get("sensitivity", []),
|
|
2863
|
+
"runs": cached.get("runs"),
|
|
2864
|
+
"last_run_at": cached.get("last_run_at"),
|
|
2865
|
+
**forecast_staleness_for_payload(
|
|
2866
|
+
cached,
|
|
2867
|
+
plan_version_current=plan_version_current,
|
|
2868
|
+
recalculated_at=recalculated_at,
|
|
2869
|
+
today=today,
|
|
2870
|
+
),
|
|
2871
|
+
}
|
|
2872
|
+
)
|
|
2873
|
+
latest = MonteCarloRun.objects.filter(project_id=pk).order_by("-taken_at").first()
|
|
2874
|
+
if latest is None:
|
|
2875
|
+
return Response(
|
|
2876
|
+
{"detail": "No simulation result available; run a Monte Carlo forecast first."},
|
|
2877
|
+
status=status.HTTP_404_NOT_FOUND,
|
|
2878
|
+
)
|
|
2879
|
+
percentile_date = {"p50": latest.p50, "p80": latest.p80, "p95": latest.p95}[quantity]
|
|
2880
|
+
dist = latest.distribution or {}
|
|
2881
|
+
return Response(
|
|
2882
|
+
{
|
|
2883
|
+
"quantity": quantity,
|
|
2884
|
+
"value": percentile_date.isoformat() if percentile_date else None,
|
|
2885
|
+
"pass": "monte_carlo",
|
|
2886
|
+
"cpm_finish": latest.cpm_finish.isoformat() if latest.cpm_finish else None,
|
|
2887
|
+
"value_at_day_start": getattr(latest, f"{quantity}_at_day_start"),
|
|
2888
|
+
"cpm_finish_at_day_start": latest.cpm_finish_at_day_start,
|
|
2889
|
+
"delta_vs_cpm_days": _delta_vs_cpm_days(
|
|
2890
|
+
percentile_date,
|
|
2891
|
+
latest.cpm_finish,
|
|
2892
|
+
percentile_at_day_start=getattr(latest, f"{quantity}_at_day_start"),
|
|
2893
|
+
cpm_finish_at_day_start=latest.cpm_finish_at_day_start,
|
|
2894
|
+
calendar_for=lambda: compose_project_calendar(project),
|
|
2895
|
+
),
|
|
2896
|
+
# ADR-0140 tornado: the tasks whose duration variance drives the
|
|
2897
|
+
# finish — the honest "why" behind a probabilistic date.
|
|
2898
|
+
"drivers": dist.get("sensitivity", []),
|
|
2899
|
+
"runs": latest.n_simulations,
|
|
2900
|
+
"last_run_at": latest.taken_at.isoformat(),
|
|
2901
|
+
**forecast_staleness_from_run(
|
|
2902
|
+
latest,
|
|
2903
|
+
plan_version_current=plan_version_current,
|
|
2904
|
+
recalculated_at=recalculated_at,
|
|
2905
|
+
today=today,
|
|
2906
|
+
),
|
|
2907
|
+
}
|
|
2908
|
+
)
|