trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
"""Service layer for scheduling outbox operations.
|
|
2
|
+
|
|
3
|
+
This module is the canonical home for ``enqueue_recalculate`` — the function
|
|
4
|
+
that writes a ``ScheduleRequest`` outbox row and attempts an immediate
|
|
5
|
+
best-effort Celery dispatch. It lives in the ``scheduling`` app because it
|
|
6
|
+
directly manages ``ScheduleRequest``, which is owned by that app.
|
|
7
|
+
|
|
8
|
+
It also owns ``compute_velocity_suggestions`` — the sprint-close hook that
|
|
9
|
+
generates ``VelocitySuggestion`` rows from rolling team velocity (ADR-0065).
|
|
10
|
+
|
|
11
|
+
Call sites:
|
|
12
|
+
- ``projects.views`` — task/dependency mutations (via ``transaction.on_commit``)
|
|
13
|
+
- ``scheduling.views`` — manual trigger endpoint (direct call, inside
|
|
14
|
+
``ATOMIC_REQUESTS`` transaction)
|
|
15
|
+
- ``msproject.tasks`` — post-import recalculation (direct call, Celery task
|
|
16
|
+
has already committed its writes)
|
|
17
|
+
- ``projects.tasks.close_sprint`` — calls ``compute_velocity_suggestions``
|
|
18
|
+
inside the same atomic block as the SPRINT_CLOSED ScheduleRequest.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import statistics
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import date
|
|
28
|
+
from decimal import Decimal
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from django.db import IntegrityError, transaction
|
|
32
|
+
from django.utils import timezone
|
|
33
|
+
|
|
34
|
+
from trueppm_api.apps.scheduling.models import ScheduleRequestReason
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from trueppm_api.apps.projects.models import Calendar, Sprint
|
|
38
|
+
from trueppm_api.apps.scheduling.models import MonteCarloRun
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Minimum number of prior closed sprints required before any velocity
|
|
43
|
+
# suggestion is generated. Three sprints is the smallest sample that yields a
|
|
44
|
+
# stable rolling average; below that the suggestion is noise and would erode
|
|
45
|
+
# PM trust in the surface.
|
|
46
|
+
MIN_CLOSED_SPRINTS_FOR_SUGGESTION = 3
|
|
47
|
+
|
|
48
|
+
# Rolling window for team_velocity_per_day. ADR-0065 picks six sprints as the
|
|
49
|
+
# balance between responsiveness to a real velocity shift and smoothing of
|
|
50
|
+
# one-off scope churn.
|
|
51
|
+
VELOCITY_ROLLING_WINDOW = 6
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_cpm_status_date(status_date: date | None) -> date:
|
|
55
|
+
"""Resolve a project's CPM data date, flooring a null value at today.
|
|
56
|
+
|
|
57
|
+
Mirrors the Monte Carlo resolution that already lived at each call site
|
|
58
|
+
(``mc_status_date = project.status_date or timezone.localdate()``) so the
|
|
59
|
+
deterministic CPM pass and the read-time ``current`` block use the exact
|
|
60
|
+
same convention (ADR-0752 §4, correcting ADR-0132 §1). Before this, a null
|
|
61
|
+
``status_date`` passed raw to the CPM path meant "no floor at all" — this
|
|
62
|
+
function is the single place that resolution happens, so the persisted
|
|
63
|
+
recalculation (``scheduling/tasks.py::_run_schedule``) and every read-time
|
|
64
|
+
caller (the Monte Carlo what-if ``current`` block, the schedule-derivation
|
|
65
|
+
builder) cannot drift on which data date they used.
|
|
66
|
+
"""
|
|
67
|
+
return status_date or timezone.localdate()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def enqueue_recalculate(
|
|
71
|
+
project_id: str,
|
|
72
|
+
reason: ScheduleRequestReason = ScheduleRequestReason.TASK_CHANGE,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Insert a ScheduleRequest outbox row and attempt immediate dispatch.
|
|
75
|
+
|
|
76
|
+
Safe to call from:
|
|
77
|
+
- ``transaction.on_commit()`` callbacks (HTTP request context)
|
|
78
|
+
- Celery task bodies (no ambient transaction; ``atomic()`` opens its own)
|
|
79
|
+
|
|
80
|
+
The ``reason`` is recorded on the outbox row purely for forensics — it does
|
|
81
|
+
not change dispatch behavior. When the same project already has a PENDING
|
|
82
|
+
row, the existing row's reason is preserved (whatever triggered the queue
|
|
83
|
+
first wins) so debugging "why did this recalc fire?" still points at the
|
|
84
|
+
initial cause rather than the last edit to pile on.
|
|
85
|
+
|
|
86
|
+
If a pending row already exists for the project we adopt it — coalescing
|
|
87
|
+
every edit that happened while it was waiting into a single CPM run — and
|
|
88
|
+
still attempt the immediate dispatch. Without this, a stranded pending
|
|
89
|
+
row (e.g. from an earlier broker outage) would silently swallow every
|
|
90
|
+
subsequent edit until ``drain_schedule_queue`` ran, which is the failure
|
|
91
|
+
mode that produced #314.
|
|
92
|
+
|
|
93
|
+
If the broker is unavailable the row is left PENDING and
|
|
94
|
+
``drain_schedule_queue`` picks it up within 30 seconds.
|
|
95
|
+
"""
|
|
96
|
+
from trueppm_api.apps.scheduling.models import ScheduleRequest, ScheduleRequestStatus
|
|
97
|
+
|
|
98
|
+
req: ScheduleRequest
|
|
99
|
+
try:
|
|
100
|
+
with transaction.atomic():
|
|
101
|
+
req = ScheduleRequest.objects.create(project_id=project_id, reason=reason)
|
|
102
|
+
except IntegrityError:
|
|
103
|
+
# A pending row already exists. Adopt it instead of returning — the
|
|
104
|
+
# existing row may be stranded (the request that created it failed to
|
|
105
|
+
# dispatch, e.g. broker outage) and every subsequent edit will pile up
|
|
106
|
+
# on it. Re-dispatching it now coalesces all those edits into a single
|
|
107
|
+
# CPM run with the latest data.
|
|
108
|
+
existing = ScheduleRequest.objects.filter(
|
|
109
|
+
project_id=project_id,
|
|
110
|
+
status=ScheduleRequestStatus.PENDING,
|
|
111
|
+
).first()
|
|
112
|
+
if existing is None:
|
|
113
|
+
# Race: row transitioned out of PENDING between insert + lookup.
|
|
114
|
+
# The other writer already dispatched it; nothing to do.
|
|
115
|
+
return
|
|
116
|
+
req = existing
|
|
117
|
+
|
|
118
|
+
# Best-effort immediate dispatch — reduces recalculation latency when the
|
|
119
|
+
# broker is healthy. Failure here is not fatal; the row stays PENDING.
|
|
120
|
+
from trueppm_api.apps.scheduling.tasks import recalculate_schedule
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
result = recalculate_schedule.delay(project_id)
|
|
124
|
+
except Exception:
|
|
125
|
+
# Use logger.exception so the broker error and stack trace are visible.
|
|
126
|
+
# A previous regression (#314) silently swallowed `OperationalError:
|
|
127
|
+
# Connection refused` here for weeks because logger.warning hid the
|
|
128
|
+
# cause — the cascade quietly stopped firing for every dependency edit.
|
|
129
|
+
logger.exception(
|
|
130
|
+
"enqueue_recalculate: could not immediately dispatch for project %s "
|
|
131
|
+
"— drain task will pick it up within 30 s",
|
|
132
|
+
project_id,
|
|
133
|
+
)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
from django.utils import timezone
|
|
137
|
+
|
|
138
|
+
# Guard the PENDING→DISPATCHED transition with a savepoint: another row for
|
|
139
|
+
# this project may already be DISPATCHED (stranded by a missing worker — the
|
|
140
|
+
# CI integration env, or a Celery outage in prod) and the partial unique
|
|
141
|
+
# index `schedule_request_one_dispatched_per_project` will reject the
|
|
142
|
+
# update. Treat that as the same situation as a broker outage: leave the
|
|
143
|
+
# row PENDING and let `drain_schedule_queue` coalesce on the next tick.
|
|
144
|
+
try:
|
|
145
|
+
with transaction.atomic():
|
|
146
|
+
ScheduleRequest.objects.filter(id=req.id, status=ScheduleRequestStatus.PENDING).update(
|
|
147
|
+
status=ScheduleRequestStatus.DISPATCHED,
|
|
148
|
+
celery_task_id=result.id,
|
|
149
|
+
dispatched_at=timezone.now(),
|
|
150
|
+
)
|
|
151
|
+
except IntegrityError:
|
|
152
|
+
logger.warning(
|
|
153
|
+
"enqueue_recalculate: project %s already has a DISPATCHED outbox row "
|
|
154
|
+
"— leaving new request PENDING for drain_schedule_queue to coalesce",
|
|
155
|
+
project_id,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def redispatch_dead_lettered_task(
|
|
160
|
+
task_name: str,
|
|
161
|
+
args: list[Any] | tuple[Any, ...],
|
|
162
|
+
kwargs: dict[str, Any],
|
|
163
|
+
countdown: int = 0,
|
|
164
|
+
) -> str:
|
|
165
|
+
"""Re-enqueue a dead-lettered Celery task, optionally after a backoff.
|
|
166
|
+
|
|
167
|
+
The engine boundary for the requeue workflow (ADR-0210): the
|
|
168
|
+
``scheduling.requeue_failed_task`` consumer activity calls this rather than
|
|
169
|
+
importing ``celery`` itself (ADR-0080 §E consumer import discipline). By the
|
|
170
|
+
time execution reaches here the requeue has already round-tripped through the
|
|
171
|
+
durable workflow outbox, so this is the final best-effort hop that puts the
|
|
172
|
+
original task back on the broker.
|
|
173
|
+
|
|
174
|
+
``countdown`` is the operator-chosen backoff in seconds, applied as a Celery
|
|
175
|
+
``countdown`` (best-effort, broker-side — ADR-0210 §2). ``0`` means dispatch
|
|
176
|
+
immediately. Returns the Celery ``AsyncResult`` id of the re-dispatched task so
|
|
177
|
+
the workflow can record it in history.
|
|
178
|
+
"""
|
|
179
|
+
from celery import current_app
|
|
180
|
+
|
|
181
|
+
async_result = current_app.send_task(
|
|
182
|
+
task_name,
|
|
183
|
+
args=list(args) if args else [],
|
|
184
|
+
kwargs=dict(kwargs) if kwargs else {},
|
|
185
|
+
countdown=countdown or None,
|
|
186
|
+
)
|
|
187
|
+
logger.info(
|
|
188
|
+
"redispatch_dead_lettered_task: re-enqueued %s (countdown=%ss) as %s",
|
|
189
|
+
task_name,
|
|
190
|
+
countdown,
|
|
191
|
+
async_result.id,
|
|
192
|
+
)
|
|
193
|
+
return str(async_result.id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Monte Carlo run history — ADR-0175 (#961)
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def record_monte_carlo_run(
|
|
202
|
+
project_id: str | uuid.UUID,
|
|
203
|
+
*,
|
|
204
|
+
p50: date | None,
|
|
205
|
+
p80: date | None,
|
|
206
|
+
p95: date | None,
|
|
207
|
+
n_simulations: int,
|
|
208
|
+
cpm_finish: date | None = None,
|
|
209
|
+
task_count: int | None = None,
|
|
210
|
+
user: Any = None,
|
|
211
|
+
distribution: dict[str, Any] | None = None,
|
|
212
|
+
diagnostic: dict[str, Any] | None = None,
|
|
213
|
+
status_date: date | None = None,
|
|
214
|
+
plan_version: int | None = None,
|
|
215
|
+
) -> MonteCarloRun | None:
|
|
216
|
+
"""Persist one project-level Monte Carlo run for the forecast history (ADR-0175).
|
|
217
|
+
|
|
218
|
+
Called synchronously from ``run_monte_carlo`` after the simulation returns.
|
|
219
|
+
Persistence is **best-effort**: the simulation result is the primary
|
|
220
|
+
deliverable, so a write failure is logged and swallowed (the caller still
|
|
221
|
+
returns the computed result; the history simply misses that row). Returns the
|
|
222
|
+
created ``MonteCarloRun`` or ``None`` on failure.
|
|
223
|
+
|
|
224
|
+
``user`` is stored as ``triggered_by`` and is serialized only to the resolved
|
|
225
|
+
attribution audience (ADR-0144 / VoC): forecast drift must not become a
|
|
226
|
+
named-individual signal at the team level. An ``AnonymousUser`` (never expected
|
|
227
|
+
here behind IsAuthenticated) is normalized to ``None``.
|
|
228
|
+
|
|
229
|
+
``distribution`` is the size-bounded ``{histogram_buckets, confidence_curve,
|
|
230
|
+
sensitivity}`` payload (#1231) persisted so the histogram survives cache expiry;
|
|
231
|
+
``None`` (legacy / no distribution) renders the empty-state prose on read.
|
|
232
|
+
|
|
233
|
+
``diagnostic`` is the :func:`forecast_diagnostic` payload (#1340) telling a later
|
|
234
|
+
reader *why* the run is flat. It is what keeps a structural zero (no estimates
|
|
235
|
+
anywhere) distinguishable from a measured zero (estimates exist, variance is
|
|
236
|
+
genuinely low) once the run is read back from history rather than the cache —
|
|
237
|
+
see the field docstring on :class:`MonteCarloRun` (#2483).
|
|
238
|
+
|
|
239
|
+
``status_date`` is the data date the simulation was actually computed against —
|
|
240
|
+
the caller's already-resolved ``project.status_date or timezone.localdate()``
|
|
241
|
+
(ADR-0132) — so a persisted run states which "today" produced its percentiles
|
|
242
|
+
instead of leaving that substitution unrecorded (#2638).
|
|
243
|
+
|
|
244
|
+
``plan_version`` is the project's ``last_sync_version`` (ADR-0686) as the caller read
|
|
245
|
+
it *before* loading the task set, so a later reader can tell whether the plan has been
|
|
246
|
+
written to since — the server-owned answer to "is this forecast still current?" that
|
|
247
|
+
replaced a session-local counter in the web client (#3140). ``None`` for callers that
|
|
248
|
+
do not supply one; such a run classifies as ``unknown``, never ``current``.
|
|
249
|
+
"""
|
|
250
|
+
from trueppm_api.apps.scheduling.models import MonteCarloRun
|
|
251
|
+
|
|
252
|
+
triggered_by = user if getattr(user, "is_authenticated", False) else None
|
|
253
|
+
try:
|
|
254
|
+
return MonteCarloRun.objects.create(
|
|
255
|
+
project_id=project_id,
|
|
256
|
+
p50=p50,
|
|
257
|
+
p80=p80,
|
|
258
|
+
p95=p95,
|
|
259
|
+
cpm_finish=cpm_finish,
|
|
260
|
+
n_simulations=n_simulations,
|
|
261
|
+
task_count=task_count,
|
|
262
|
+
triggered_by=triggered_by,
|
|
263
|
+
distribution=distribution,
|
|
264
|
+
diagnostic=diagnostic,
|
|
265
|
+
status_date=status_date,
|
|
266
|
+
plan_version=plan_version,
|
|
267
|
+
)
|
|
268
|
+
except Exception:
|
|
269
|
+
logger.exception(
|
|
270
|
+
"record_monte_carlo_run: failed to persist run for project %s "
|
|
271
|
+
"— returning computed result without history row",
|
|
272
|
+
project_id,
|
|
273
|
+
)
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Velocity calibration — ADR-0065
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _sprint_working_days(sprint: Sprint) -> int:
|
|
283
|
+
"""Count Mon–Fri days in the sprint window, inclusive of both endpoints."""
|
|
284
|
+
from trueppm_api.apps.projects.services import _working_days
|
|
285
|
+
|
|
286
|
+
return _working_days(sprint.start_date, sprint.finish_date)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def compute_team_velocity_per_day(
|
|
290
|
+
project_id: str | uuid.UUID,
|
|
291
|
+
*,
|
|
292
|
+
exclude_sprint_id: str | uuid.UUID | None = None,
|
|
293
|
+
) -> Decimal | None:
|
|
294
|
+
"""Return rolling team_velocity_per_day for the project, or None if undefined.
|
|
295
|
+
|
|
296
|
+
Uses the last ``VELOCITY_ROLLING_WINDOW`` completed sprints excluding the
|
|
297
|
+
optional ``exclude_sprint_id`` (typically the sprint that just closed —
|
|
298
|
+
its velocity is what we're calibrating *toward*, so feeding it back into
|
|
299
|
+
the rolling window would double-weight it on the very first run).
|
|
300
|
+
|
|
301
|
+
Draws from ``velocity_eligible_sprints`` (ADR-0113) so a sprint flagged
|
|
302
|
+
``exclude_from_velocity`` — a setup/ramp-up "Sprint 0" — never contaminates
|
|
303
|
+
the calibration the CPM duration suggestions are derived from. This is the
|
|
304
|
+
single source of truth for "counts toward velocity"; do not re-filter here.
|
|
305
|
+
|
|
306
|
+
Returns ``None`` when fewer than ``MIN_CLOSED_SPRINTS_FOR_SUGGESTION``
|
|
307
|
+
prior sprints have completed; callers must treat this as "not enough
|
|
308
|
+
history to suggest" rather than "velocity is zero".
|
|
309
|
+
"""
|
|
310
|
+
from trueppm_api.apps.projects.services import velocity_eligible_sprints
|
|
311
|
+
|
|
312
|
+
qs = velocity_eligible_sprints(project_id)
|
|
313
|
+
if exclude_sprint_id is not None:
|
|
314
|
+
qs = qs.exclude(pk=exclude_sprint_id)
|
|
315
|
+
closed = list(qs[:VELOCITY_ROLLING_WINDOW])
|
|
316
|
+
|
|
317
|
+
if len(closed) < MIN_CLOSED_SPRINTS_FOR_SUGGESTION:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
per_day_samples: list[float] = []
|
|
321
|
+
for s in closed:
|
|
322
|
+
if s.completed_points is None:
|
|
323
|
+
continue
|
|
324
|
+
working_days = _sprint_working_days(s)
|
|
325
|
+
if working_days <= 0:
|
|
326
|
+
continue
|
|
327
|
+
per_day_samples.append(s.completed_points / working_days)
|
|
328
|
+
|
|
329
|
+
if len(per_day_samples) < MIN_CLOSED_SPRINTS_FOR_SUGGESTION:
|
|
330
|
+
return None
|
|
331
|
+
avg = statistics.fmean(per_day_samples)
|
|
332
|
+
if avg <= 0:
|
|
333
|
+
# Zero-velocity team produces an infinite suggested_duration — refuse.
|
|
334
|
+
return None
|
|
335
|
+
# Quantize to three decimal places to match the model's DecimalField scale.
|
|
336
|
+
return Decimal(f"{avg:.3f}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def compute_velocity_suggestions(sprint_id: str | uuid.UUID) -> int:
|
|
340
|
+
"""Generate VelocitySuggestion rows for tasks in a just-closed sprint.
|
|
341
|
+
|
|
342
|
+
Called synchronously from ``projects.tasks.close_sprint`` after the
|
|
343
|
+
SPRINT_CLOSED ``ScheduleRequest`` has been inserted, inside the same
|
|
344
|
+
atomic block so the suggestions commit (or roll back) together with the
|
|
345
|
+
sprint close itself.
|
|
346
|
+
|
|
347
|
+
Behavior:
|
|
348
|
+
|
|
349
|
+
- Requires the sprint to be COMPLETED. A non-completed sprint is treated
|
|
350
|
+
as a no-op (defensive — the drain only calls this after the state
|
|
351
|
+
transition succeeds).
|
|
352
|
+
- Skips when team_velocity_per_day is undefined (fewer than three prior
|
|
353
|
+
completed sprints, or zero/negative average). The PM will simply not
|
|
354
|
+
see a prompt until enough history accumulates.
|
|
355
|
+
- For each non-deleted task in the closing sprint with ``story_points``
|
|
356
|
+
set, computes ``suggested_duration = round(story_points / velocity)``,
|
|
357
|
+
clamped to at least 1 working day.
|
|
358
|
+
- Skips tasks whose current ``most_likely_duration`` already equals the
|
|
359
|
+
suggestion (no point prompting the PM to accept a value already in
|
|
360
|
+
place).
|
|
361
|
+
- Idempotent: a duplicate run upserts the suggestion row via the unique
|
|
362
|
+
constraint on (task, sprint); the PM's accept/dismiss decision on a
|
|
363
|
+
prior row is preserved (we only refresh truly pending rows).
|
|
364
|
+
|
|
365
|
+
Returns the number of suggestion rows touched (created or refreshed).
|
|
366
|
+
"""
|
|
367
|
+
from trueppm_api.apps.projects.models import (
|
|
368
|
+
EstimationMode,
|
|
369
|
+
Sprint,
|
|
370
|
+
SprintState,
|
|
371
|
+
Task,
|
|
372
|
+
)
|
|
373
|
+
from trueppm_api.apps.scheduling.models import VelocitySuggestion
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
sprint = Sprint.objects.select_related("project").get(pk=sprint_id)
|
|
377
|
+
except Sprint.DoesNotExist:
|
|
378
|
+
logger.warning("compute_velocity_suggestions: sprint %s not found", sprint_id)
|
|
379
|
+
return 0
|
|
380
|
+
|
|
381
|
+
if sprint.state != SprintState.COMPLETED:
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
velocity = compute_team_velocity_per_day(
|
|
385
|
+
sprint.project_id,
|
|
386
|
+
exclude_sprint_id=sprint.pk,
|
|
387
|
+
)
|
|
388
|
+
if velocity is None:
|
|
389
|
+
logger.info(
|
|
390
|
+
"compute_velocity_suggestions: project %s lacks enough closed sprints "
|
|
391
|
+
"for a stable rolling average — skipping",
|
|
392
|
+
sprint.project_id,
|
|
393
|
+
)
|
|
394
|
+
return 0
|
|
395
|
+
|
|
396
|
+
flag_for_review = sprint.project.estimation_mode == EstimationMode.SUGGEST_APPROVE
|
|
397
|
+
|
|
398
|
+
tasks = list(
|
|
399
|
+
Task.objects.filter(
|
|
400
|
+
sprint_id=sprint.pk,
|
|
401
|
+
is_deleted=False,
|
|
402
|
+
story_points__isnull=False,
|
|
403
|
+
story_points__gt=0,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Pre-load existing suggestions for the (sprint, task ∈ tasks) set in a
|
|
408
|
+
# single query rather than issuing one .filter().first() per task — for a
|
|
409
|
+
# sprint with many tasks the drain is the hot path and N+1 would slow
|
|
410
|
+
# sprint close noticeably.
|
|
411
|
+
existing_by_task = {
|
|
412
|
+
sugg.task_id: sugg
|
|
413
|
+
for sugg in VelocitySuggestion.objects.filter(
|
|
414
|
+
sprint=sprint,
|
|
415
|
+
task_id__in=[t.pk for t in tasks],
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
touched = 0
|
|
420
|
+
for task in tasks:
|
|
421
|
+
# story_points is narrowed by the filter above (isnull=False, gt=0);
|
|
422
|
+
# the explicit cast satisfies mypy without an assert in a hot loop.
|
|
423
|
+
story_points = task.story_points or 0
|
|
424
|
+
suggested = max(1, round(float(story_points) / float(velocity)))
|
|
425
|
+
if task.most_likely_duration == suggested:
|
|
426
|
+
# Already in place — no point creating a suggestion the PM would
|
|
427
|
+
# have to dismiss.
|
|
428
|
+
continue
|
|
429
|
+
# Preserve a prior accept/dismiss decision: only the pending row is
|
|
430
|
+
# refreshed. update_or_create with a where-pending filter expressed as
|
|
431
|
+
# a get-then-update keeps the audit trail correct.
|
|
432
|
+
existing = existing_by_task.get(task.pk)
|
|
433
|
+
if existing is None:
|
|
434
|
+
VelocitySuggestion.objects.create(
|
|
435
|
+
task=task,
|
|
436
|
+
sprint=sprint,
|
|
437
|
+
suggested_duration=suggested,
|
|
438
|
+
team_velocity_per_day=velocity,
|
|
439
|
+
flag_for_review=flag_for_review,
|
|
440
|
+
)
|
|
441
|
+
touched += 1
|
|
442
|
+
continue
|
|
443
|
+
if existing.is_pending:
|
|
444
|
+
VelocitySuggestion.objects.filter(pk=existing.pk).update(
|
|
445
|
+
suggested_duration=suggested,
|
|
446
|
+
team_velocity_per_day=velocity,
|
|
447
|
+
flag_for_review=flag_for_review,
|
|
448
|
+
)
|
|
449
|
+
touched += 1
|
|
450
|
+
if touched:
|
|
451
|
+
logger.info(
|
|
452
|
+
"compute_velocity_suggestions: wrote %d suggestion(s) for sprint %s",
|
|
453
|
+
touched,
|
|
454
|
+
sprint_id,
|
|
455
|
+
)
|
|
456
|
+
return touched
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def build_sched_tasks(db_tasks: list[Any], *, suggest_approve: bool) -> list[Any]:
|
|
460
|
+
"""Convert Django ``Task`` rows into scheduler ``Task`` dataclasses.
|
|
461
|
+
|
|
462
|
+
Single source of truth for the API → engine task mapping, shared by the
|
|
463
|
+
deterministic CPM pass (``scheduling.tasks``) and the Monte Carlo endpoint
|
|
464
|
+
(``scheduling.views``). Keeping one converter is what stops the CPM and MC
|
|
465
|
+
inputs from drifting — the exact failure mode of #1185, where Monte Carlo
|
|
466
|
+
silently dropped ``planned_start``. Every progress/actual field (ADR-0132) is
|
|
467
|
+
mapped here, so it reaches both engines or neither.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
db_tasks: Django ``Task`` rows, already filtered to the committed set.
|
|
471
|
+
suggest_approve: When True (``project.estimation_mode`` is
|
|
472
|
+
``SUGGEST_APPROVE``) a task's three-point estimate is withheld unless
|
|
473
|
+
its ``estimate_status`` is ACCEPTED. The scheduler's all-or-none rule
|
|
474
|
+
means a single ``None`` falls back to the deterministic duration.
|
|
475
|
+
(No-op for the deterministic CPM pass, which ignores PERT, but applied
|
|
476
|
+
uniformly so the two paths share one code path.)
|
|
477
|
+
"""
|
|
478
|
+
from datetime import timedelta
|
|
479
|
+
|
|
480
|
+
from trueppm_scheduler.models import DeliveryMode as SchedDeliveryMode
|
|
481
|
+
from trueppm_scheduler.models import Task as SchedTask
|
|
482
|
+
|
|
483
|
+
from trueppm_api.apps.projects.models import DeliveryMode, EstimateStatus
|
|
484
|
+
|
|
485
|
+
def _pert(value: int | None, estimate_status: str | None) -> timedelta | None:
|
|
486
|
+
if value is None:
|
|
487
|
+
return None
|
|
488
|
+
if suggest_approve and estimate_status != EstimateStatus.ACCEPTED:
|
|
489
|
+
return None
|
|
490
|
+
return timedelta(days=value)
|
|
491
|
+
|
|
492
|
+
return [
|
|
493
|
+
SchedTask(
|
|
494
|
+
id=str(t.id),
|
|
495
|
+
name=t.name,
|
|
496
|
+
# Milestones are zero-duration gates regardless of any stored duration
|
|
497
|
+
# (MS Project allows non-zero milestone durations); the engine operates
|
|
498
|
+
# on duration only, so normalise at the boundary.
|
|
499
|
+
duration=timedelta(days=0) if t.is_milestone else timedelta(days=t.duration),
|
|
500
|
+
# planned_start is the engine's SNET floor (ADR-0014). An explicit value
|
|
501
|
+
# always wins; otherwise a sprint-assigned, schedulable task inherits its
|
|
502
|
+
# sprint's start_date as a *synthetic* floor (ADR-0168) so agile work
|
|
503
|
+
# positions in its sprint window instead of the project origin. Engine
|
|
504
|
+
# input only — never written back, so the stored row stays null; applying
|
|
505
|
+
# it in this one converter keeps CPM and Monte Carlo in sync (#1185).
|
|
506
|
+
# Milestones are excluded — a sprint review/demo gate sits at the sprint
|
|
507
|
+
# end, not its start (ADR-0106).
|
|
508
|
+
planned_start=(
|
|
509
|
+
t.planned_start
|
|
510
|
+
if t.planned_start is not None
|
|
511
|
+
else (t.sprint.start_date if (t.sprint_id and not t.is_milestone) else None)
|
|
512
|
+
),
|
|
513
|
+
percent_complete=t.percent_complete,
|
|
514
|
+
actual_start=t.actual_start,
|
|
515
|
+
actual_finish=t.actual_finish,
|
|
516
|
+
optimistic_duration=_pert(t.optimistic_duration, t.estimate_status),
|
|
517
|
+
most_likely_duration=_pert(t.most_likely_duration, t.estimate_status),
|
|
518
|
+
pessimistic_duration=_pert(t.pessimistic_duration, t.estimate_status),
|
|
519
|
+
# Agile-aware Monte Carlo (#411): only SCRUM tasks sample from team
|
|
520
|
+
# velocity, so map just that mode (the scheduler enum has no KANBAN /
|
|
521
|
+
# MILESTONE — those stay None == WATERFALL, deterministic/PERT, exactly
|
|
522
|
+
# as before). story_points is the velocity path's burn-down target. Both
|
|
523
|
+
# are inert for the deterministic CPM pass, which reads neither — the
|
|
524
|
+
# shared converter (ADR-0132) simply carries them for the MC path.
|
|
525
|
+
delivery_mode=(
|
|
526
|
+
SchedDeliveryMode.SCRUM if t.delivery_mode == DeliveryMode.SCRUM else None
|
|
527
|
+
),
|
|
528
|
+
story_points=(float(t.story_points) if t.story_points is not None else None),
|
|
529
|
+
)
|
|
530
|
+
for t in db_tasks
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def build_children_map(db_tasks: list[Any]) -> dict[str, list[str]]:
|
|
535
|
+
"""Map each summary task's id to its direct children's ids via the WBS hierarchy.
|
|
536
|
+
|
|
537
|
+
A task is a *summary* when another task's ``wbs_path`` is a direct child of its
|
|
538
|
+
own (e.g. ``1.2`` is a direct child of ``1``). Tasks are indexed by ``wbs_path``
|
|
539
|
+
in a single pass so each task resolves its parent with one dict lookup — O(N)
|
|
540
|
+
total. The former inline construction scanned ``db_tasks`` for every task to find
|
|
541
|
+
its parent by string equality (O(N^2)), the one superlinear step left in recalc;
|
|
542
|
+
at 5k+ tasks it began to dominate the "Building schedule model" phase (#1011).
|
|
543
|
+
|
|
544
|
+
``setdefault`` makes the first task in ``db_tasks`` order win a duplicate
|
|
545
|
+
``wbs_path``, exactly mirroring the prior loop's first-match-and-break semantics,
|
|
546
|
+
and children are appended in ``db_tasks`` order — so the result is byte-for-byte
|
|
547
|
+
identical to the O(N^2) version it replaces.
|
|
548
|
+
|
|
549
|
+
Lives here rather than in ``scheduling.tasks`` (#3527) because summary shaping is
|
|
550
|
+
part of the shared API→engine graph construction, not a detail of the Celery
|
|
551
|
+
recalc: see :func:`build_sched_graph`.
|
|
552
|
+
"""
|
|
553
|
+
id_by_wbs_path: dict[str, str] = {}
|
|
554
|
+
for t in db_tasks:
|
|
555
|
+
if t.wbs_path:
|
|
556
|
+
id_by_wbs_path.setdefault(str(t.wbs_path), str(t.id))
|
|
557
|
+
|
|
558
|
+
children_map: dict[str, list[str]] = {}
|
|
559
|
+
for t in db_tasks:
|
|
560
|
+
if not t.wbs_path:
|
|
561
|
+
continue
|
|
562
|
+
parts = str(t.wbs_path).rsplit(".", 1)
|
|
563
|
+
if len(parts) < 2:
|
|
564
|
+
continue
|
|
565
|
+
parent_id = id_by_wbs_path.get(parts[0])
|
|
566
|
+
if parent_id is not None:
|
|
567
|
+
children_map.setdefault(parent_id, []).append(str(t.id))
|
|
568
|
+
return children_map
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@dataclass(frozen=True)
|
|
572
|
+
class SchedGraph:
|
|
573
|
+
"""The leaf-only scheduler graph for a project, with the summary shape that made it.
|
|
574
|
+
|
|
575
|
+
``tasks``/``dependencies`` are what goes into ``SchedProject``; ``summary_ids``
|
|
576
|
+
and ``children_map`` are what the deterministic pass needs afterwards to roll the
|
|
577
|
+
summaries back up (ADR-0105), and ``leaf_db_tasks`` is the Django-row subset that
|
|
578
|
+
corresponds to ``tasks`` — for any per-task reporting that must describe the set
|
|
579
|
+
actually simulated rather than the set loaded.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
tasks: list[Any]
|
|
583
|
+
dependencies: list[Any]
|
|
584
|
+
summary_ids: set[str]
|
|
585
|
+
children_map: dict[str, list[str]]
|
|
586
|
+
leaf_db_tasks: list[Any]
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def build_sched_graph(
|
|
590
|
+
db_tasks: list[Any], sched_tasks: list[Any], sched_deps: list[Any]
|
|
591
|
+
) -> SchedGraph:
|
|
592
|
+
"""Strip summary rows out of a scheduler graph and fan their edges down to leaves.
|
|
593
|
+
|
|
594
|
+
Summary (phase) rows are grouping nodes, not schedulable work: ADR-0105 excludes
|
|
595
|
+
them from the CPM pass and derives their dates by rolling up their leaves. This is
|
|
596
|
+
the shaping step that enforces that, and it is shared for the same reason
|
|
597
|
+
:func:`build_sched_tasks` is (#1185): the deterministic CPM pass and every Monte
|
|
598
|
+
Carlo path must schedule *the same network*.
|
|
599
|
+
|
|
600
|
+
Sharing only the converter was not enough (#3527). ``_run_schedule`` did this
|
|
601
|
+
shaping inline while the Monte Carlo endpoint, the what-if endpoint and the
|
|
602
|
+
derivation builder all called ``build_sched_tasks`` and fed the result straight to
|
|
603
|
+
the engine, so every phase entered those simulations as an ordinary task: a large,
|
|
604
|
+
never-started, unconstrained block of work. A summary's stored ``percent_complete``
|
|
605
|
+
is 0 (the 100% the UI shows is the read-time ``percent_complete_rollup``
|
|
606
|
+
annotation, never a stored value) and it carries no dependencies of its own, so
|
|
607
|
+
each phase floored at the data date and the longest one became the simulated
|
|
608
|
+
project finish. The forecast was flat, unrelated to the network, and *worsened* by
|
|
609
|
+
recording real progress — closing a task stretched the phase span the phantom's
|
|
610
|
+
duration was read from. Every project with phases was affected; a flat-WBS project
|
|
611
|
+
was not, which is why no unit suite caught it.
|
|
612
|
+
|
|
613
|
+
Removing summaries from the network also removes the only reader that turned
|
|
614
|
+
#3530 into a wrong forecast: ``_apply_cpm_results`` writes a summary's ``duration``
|
|
615
|
+
as a **calendar-day** span while the engine reads ``duration`` as **working days**,
|
|
616
|
+
which inflated the phantom a further ~1.4x. That field is still wrong for every
|
|
617
|
+
other consumer — #3530 owns it, and this function is not a fix for it.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
db_tasks: the Django ``Task`` rows the graph was built from, in the same order.
|
|
621
|
+
sched_tasks: scheduler tasks from :func:`build_sched_tasks`, summaries included.
|
|
622
|
+
sched_deps: scheduler dependencies, which may reference summary endpoints.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
A :class:`SchedGraph`. When the project has no summaries the task and
|
|
626
|
+
dependency lists are the inputs unchanged.
|
|
627
|
+
"""
|
|
628
|
+
from trueppm_scheduler.engine import (
|
|
629
|
+
MAX_DEPENDENCIES,
|
|
630
|
+
InvalidScheduleInput,
|
|
631
|
+
expand_summary_dependencies,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
# The O(1) raw-edge cap, enforced HERE rather than left to the engine (#3527).
|
|
635
|
+
# `_validate_project` checks it too, but only once `schedule()`/`monte_carlo()`
|
|
636
|
+
# is called — which is *after* this function has already walked the edge list.
|
|
637
|
+
# `run_monte_carlo`'s synchronous-by-design argument (#1203) rests on the cheap
|
|
638
|
+
# cap running before any per-edge work, and moving expansion onto the request
|
|
639
|
+
# path put the expensive step first. Checking the list length restores that
|
|
640
|
+
# order for every caller, including the Celery pass, where it is simply earlier.
|
|
641
|
+
if len(sched_deps) > MAX_DEPENDENCIES:
|
|
642
|
+
raise InvalidScheduleInput(
|
|
643
|
+
f"Project has {len(sched_deps)} dependencies, exceeding the maximum of "
|
|
644
|
+
f"{MAX_DEPENDENCIES}; the graph cannot be scheduled within resource limits."
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
children_map = build_children_map(db_tasks)
|
|
648
|
+
summary_ids = set(children_map.keys())
|
|
649
|
+
leaf_tasks, expanded_deps = expand_summary_dependencies(sched_tasks, sched_deps, children_map)
|
|
650
|
+
return SchedGraph(
|
|
651
|
+
tasks=leaf_tasks,
|
|
652
|
+
dependencies=expanded_deps,
|
|
653
|
+
summary_ids=summary_ids,
|
|
654
|
+
children_map=children_map,
|
|
655
|
+
leaf_db_tasks=[t for t in db_tasks if str(t.id) not in summary_ids],
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def build_sched_calendar(cal: Calendar | None) -> Any:
|
|
660
|
+
"""Convert a Django ``Calendar`` (plus its ``CalendarException`` rows) into a
|
|
661
|
+
scheduler ``Calendar`` dataclass.
|
|
662
|
+
|
|
663
|
+
Single source of truth for the API → engine calendar mapping (issue #1491).
|
|
664
|
+
Every call site that feeds a project's calendar to the CPM pass or Monte
|
|
665
|
+
Carlo previously constructed ``trueppm_scheduler.models.Calendar`` inline
|
|
666
|
+
with only ``working_days``/``hours_per_day``/``timezone`` — never
|
|
667
|
+
``exceptions`` — so a configured holiday or shutdown (``CalendarException``)
|
|
668
|
+
was silently scheduled straight through: early/late dates, float, the
|
|
669
|
+
critical path, and Monte Carlo P50/P80/P95 all ignored it. Routing every
|
|
670
|
+
caller through this one converter is what stops that field from being
|
|
671
|
+
forgotten again at a fourth call site.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
cal: The project's Django ``Calendar``, or ``None`` if the project has
|
|
675
|
+
no calendar assigned (falls back to the Mon-Fri/8h/UTC default).
|
|
676
|
+
When not ``None``, callers should have prefetched ``cal.exceptions``
|
|
677
|
+
(e.g. via ``prefetch_related("calendar__exceptions")``) to avoid an
|
|
678
|
+
N+1 query per project.
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
A ``trueppm_scheduler.models.Calendar`` with ``exceptions`` populated
|
|
682
|
+
from the calendar's ``CalendarException`` rows.
|
|
683
|
+
"""
|
|
684
|
+
from trueppm_scheduler.models import Calendar as SchedCalendar
|
|
685
|
+
from trueppm_scheduler.models import DateRange
|
|
686
|
+
|
|
687
|
+
if cal is None:
|
|
688
|
+
return SchedCalendar(working_days=31, hours_per_day=8.0, timezone="UTC")
|
|
689
|
+
|
|
690
|
+
return SchedCalendar(
|
|
691
|
+
working_days=cal.working_days,
|
|
692
|
+
hours_per_day=cal.hours_per_day,
|
|
693
|
+
timezone=cal.timezone,
|
|
694
|
+
exceptions=[
|
|
695
|
+
DateRange(start=exc.exc_start, end=exc.exc_end) for exc in cal.exceptions.all()
|
|
696
|
+
],
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
# Reason codes for a flat (deterministic) Monte Carlo forecast, in precedence order.
|
|
701
|
+
# Exposed so the API contract and tests reference one source rather than literals.
|
|
702
|
+
FORECAST_NO_COMMITTED_TASKS = "no_committed_tasks"
|
|
703
|
+
FORECAST_ALL_COMPLETE = "all_complete"
|
|
704
|
+
FORECAST_ESTIMATES_BELOW_PLAN_DURATION = "estimates_below_plan_duration"
|
|
705
|
+
FORECAST_ESTIMATES_OFF_CRITICAL_PATH = "estimates_off_critical_path"
|
|
706
|
+
FORECAST_ESTIMATES_PENDING_APPROVAL = "estimates_pending_approval"
|
|
707
|
+
FORECAST_NO_VELOCITY_HISTORY = "no_velocity_history"
|
|
708
|
+
FORECAST_NO_ESTIMATES = "no_estimates"
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _forecast_task_signal(
|
|
712
|
+
t: Any, *, suggest_approve: bool, has_velocity_signal: bool
|
|
713
|
+
) -> tuple[bool, bool, bool, bool, bool]:
|
|
714
|
+
"""Classify one committed task for :func:`forecast_diagnostic`.
|
|
715
|
+
|
|
716
|
+
Returns ``(is_incomplete, has_variance, is_pending_approval,
|
|
717
|
+
is_agile_without_velocity, is_estimate_below_plan)`` — mirroring the exact
|
|
718
|
+
sampling gate the engine applies (``build_sched_tasks._pert`` plus the velocity
|
|
719
|
+
condition) so the diagnostic explains what the forecast actually did.
|
|
720
|
+
"""
|
|
721
|
+
from trueppm_api.apps.projects.models import DeliveryMode, EstimateStatus
|
|
722
|
+
|
|
723
|
+
# Completion mirrors trueppm_scheduler.engine._is_complete (ADR-0136): a
|
|
724
|
+
# finished task is laid out at fixed duration and contributes no variance.
|
|
725
|
+
is_complete = t.actual_finish is not None or (t.percent_complete or 0) >= 100
|
|
726
|
+
|
|
727
|
+
has_triple = (
|
|
728
|
+
t.optimistic_duration is not None
|
|
729
|
+
and t.most_likely_duration is not None
|
|
730
|
+
and t.pessimistic_duration is not None
|
|
731
|
+
)
|
|
732
|
+
# Mirror build_sched_tasks._pert: SUGGEST_APPROVE withholds a triple from the
|
|
733
|
+
# engine until it is ACCEPTED — a withheld triple is "pending", not variance.
|
|
734
|
+
withheld = suggest_approve and t.estimate_status != EstimateStatus.ACCEPTED
|
|
735
|
+
# A real spread needs opt != pess; a degenerate triple samples to a constant.
|
|
736
|
+
pert_spread = has_triple and not withheld and t.optimistic_duration != t.pessimistic_duration
|
|
737
|
+
# ...and, since #3765, sampling room ABOVE the floor. The engine clamps every
|
|
738
|
+
# sampled duration up to ``Task.duration``, so a triple whose pessimistic value
|
|
739
|
+
# sits at or below the planned duration collapses to a constant column no matter
|
|
740
|
+
# how wide its spread is. Counting it as variance made the flat-forecast reason
|
|
741
|
+
# resolve to ``estimates_off_critical_path`` — telling a PM their estimates miss
|
|
742
|
+
# the critical path when the real cause is that they sit under the plan. This
|
|
743
|
+
# function's whole contract is to mirror the sampling gate; the gate moved, so
|
|
744
|
+
# it moves with it.
|
|
745
|
+
below_plan = bool(pert_spread and t.pessimistic_duration <= (t.duration or 0))
|
|
746
|
+
pert_variance = pert_spread and not below_plan
|
|
747
|
+
is_scrum_pointed = t.delivery_mode == DeliveryMode.SCRUM and t.story_points is not None
|
|
748
|
+
velocity_variance = is_scrum_pointed and has_velocity_signal
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
not is_complete,
|
|
752
|
+
bool(pert_variance or velocity_variance),
|
|
753
|
+
bool(has_triple and withheld),
|
|
754
|
+
bool(is_scrum_pointed and not has_velocity_signal),
|
|
755
|
+
below_plan,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _forecast_reason(
|
|
760
|
+
*,
|
|
761
|
+
total: int,
|
|
762
|
+
incomplete: int,
|
|
763
|
+
with_variance: int,
|
|
764
|
+
pending_approval: int,
|
|
765
|
+
agile_without_velocity: int,
|
|
766
|
+
estimates_below_plan: int,
|
|
767
|
+
) -> str:
|
|
768
|
+
"""Derive the single most-actionable flat-forecast reason code, in precedence order."""
|
|
769
|
+
if total == 0:
|
|
770
|
+
return FORECAST_NO_COMMITTED_TASKS
|
|
771
|
+
if incomplete == 0:
|
|
772
|
+
return FORECAST_ALL_COMPLETE
|
|
773
|
+
if with_variance > 0:
|
|
774
|
+
# Variance exists but never reaches the project finish: the estimated work
|
|
775
|
+
# sits off the critical path (the sensitivity tornado's own empty case).
|
|
776
|
+
# Surfacing this stops the UI telling a user with estimates to add some.
|
|
777
|
+
return FORECAST_ESTIMATES_OFF_CRITICAL_PATH
|
|
778
|
+
if pending_approval > 0:
|
|
779
|
+
return FORECAST_ESTIMATES_PENDING_APPROVAL
|
|
780
|
+
if agile_without_velocity > 0:
|
|
781
|
+
return FORECAST_NO_VELOCITY_HISTORY
|
|
782
|
+
if estimates_below_plan > 0:
|
|
783
|
+
# Last before the fallback, so it strictly narrows ``no_estimates`` rather
|
|
784
|
+
# than displacing any existing explanation. The task DOES carry a spread —
|
|
785
|
+
# it just sits at or below the planned duration the engine floors at
|
|
786
|
+
# (#3765), so it samples to a constant. "Add estimates" is the one piece of
|
|
787
|
+
# advice guaranteed not to help here, and it is exactly what the fallback
|
|
788
|
+
# would have said.
|
|
789
|
+
return FORECAST_ESTIMATES_BELOW_PLAN_DURATION
|
|
790
|
+
return FORECAST_NO_ESTIMATES
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def forecast_diagnostic(
|
|
794
|
+
db_tasks: list[Any],
|
|
795
|
+
*,
|
|
796
|
+
suggest_approve: bool,
|
|
797
|
+
has_velocity_signal: bool,
|
|
798
|
+
deterministic: bool,
|
|
799
|
+
) -> dict[str, Any]:
|
|
800
|
+
"""Explain why a Monte Carlo forecast carries — or lacks — an uncertainty band.
|
|
801
|
+
|
|
802
|
+
A flat forecast (``P50 == P80 == P95``) is *correct* whenever no committed task
|
|
803
|
+
can contribute duration variance, but the bare percentiles give the user no clue
|
|
804
|
+
*why*. The UI historically hard-coded "add PERT estimates", which is wrong when
|
|
805
|
+
the estimates already exist but are withheld (``SUGGEST_APPROVE`` pending
|
|
806
|
+
approval), when the work is agile (its uncertainty comes from velocity history,
|
|
807
|
+
not a three-point range), or when the estimated work simply sits off the critical
|
|
808
|
+
path. This derives the single most-actionable reason from the committed task set,
|
|
809
|
+
mirroring the exact sampling gate the engine applies — ``build_sched_tasks._pert``
|
|
810
|
+
plus the velocity condition in ``trueppm_scheduler.engine.monte_carlo`` — so the
|
|
811
|
+
message the UI shows matches what the forecast actually did.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
db_tasks: the committed ``Task`` rows fed to the simulation (already
|
|
815
|
+
BACKLOG-filtered by ``Task.committed``).
|
|
816
|
+
suggest_approve: whether the project withholds un-accepted estimates
|
|
817
|
+
(``estimation_mode == SUGGEST_APPROVE``) — the same flag passed to
|
|
818
|
+
:func:`build_sched_tasks`.
|
|
819
|
+
has_velocity_signal: whether the project has usable completed-sprint velocity
|
|
820
|
+
(a non-empty ``scheduler_velocity_inputs`` sample set).
|
|
821
|
+
deterministic: whether the simulation collapsed to a single date.
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
A diagnostic dict with ``deterministic``, a ``reason`` code (``None`` when the
|
|
825
|
+
forecast has a real band), and supporting counts (``tasks_total``,
|
|
826
|
+
``tasks_with_variance``, ``tasks_pending_approval``,
|
|
827
|
+
``agile_tasks_without_velocity``, ``tasks_estimates_below_plan``).
|
|
828
|
+
``reason`` is one of the ``FORECAST_*`` codes above.
|
|
829
|
+
"""
|
|
830
|
+
total = len(db_tasks)
|
|
831
|
+
incomplete = 0
|
|
832
|
+
with_variance = 0
|
|
833
|
+
pending_approval = 0
|
|
834
|
+
agile_without_velocity = 0
|
|
835
|
+
estimates_below_plan = 0
|
|
836
|
+
|
|
837
|
+
for t in db_tasks:
|
|
838
|
+
t_incomplete, t_variance, t_pending, t_agile, t_below_plan = _forecast_task_signal(
|
|
839
|
+
t, suggest_approve=suggest_approve, has_velocity_signal=has_velocity_signal
|
|
840
|
+
)
|
|
841
|
+
incomplete += t_incomplete
|
|
842
|
+
with_variance += t_variance
|
|
843
|
+
pending_approval += t_pending
|
|
844
|
+
agile_without_velocity += t_agile
|
|
845
|
+
estimates_below_plan += t_below_plan
|
|
846
|
+
|
|
847
|
+
reason: str | None = None
|
|
848
|
+
if deterministic:
|
|
849
|
+
reason = _forecast_reason(
|
|
850
|
+
total=total,
|
|
851
|
+
incomplete=incomplete,
|
|
852
|
+
with_variance=with_variance,
|
|
853
|
+
pending_approval=pending_approval,
|
|
854
|
+
agile_without_velocity=agile_without_velocity,
|
|
855
|
+
estimates_below_plan=estimates_below_plan,
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
"deterministic": deterministic,
|
|
860
|
+
"reason": reason,
|
|
861
|
+
"tasks_total": total,
|
|
862
|
+
"tasks_with_variance": with_variance,
|
|
863
|
+
"tasks_pending_approval": pending_approval,
|
|
864
|
+
"agile_tasks_without_velocity": agile_without_velocity,
|
|
865
|
+
"tasks_estimates_below_plan": estimates_below_plan,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _rollup_one_summary(
|
|
870
|
+
sid: str, leaf_results: list[Any], db_task_by_id: dict[str, Any]
|
|
871
|
+
) -> Any | None:
|
|
872
|
+
"""Derive one summary task's synthetic CPM row from its leaf results (ADR-0105).
|
|
873
|
+
|
|
874
|
+
Returns ``None`` when no leaf carries both an early start and an early finish:
|
|
875
|
+
there is nothing to roll up, and a half-populated row would render a bar with
|
|
876
|
+
no span rather than no bar at all.
|
|
877
|
+
"""
|
|
878
|
+
from datetime import timedelta
|
|
879
|
+
|
|
880
|
+
from trueppm_scheduler.models import Task as SchedTask
|
|
881
|
+
|
|
882
|
+
es_dates = [t.early_start for t in leaf_results if t.early_start is not None]
|
|
883
|
+
ef_dates = [t.early_finish for t in leaf_results if t.early_finish is not None]
|
|
884
|
+
if not es_dates or not ef_dates:
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
ls_dates = [t.late_start for t in leaf_results if t.late_start is not None]
|
|
888
|
+
lf_dates = [t.late_finish for t in leaf_results if t.late_finish is not None]
|
|
889
|
+
# ADR-0752: a summary's span start is the earliest of its leaves' span
|
|
890
|
+
# starts — the same min-aggregation as early_start, so a leaf whose work
|
|
891
|
+
# actually began before its own remaining-work window (actual_start <
|
|
892
|
+
# early_start) still pulls the rolled-up summary bar's span left. Falls
|
|
893
|
+
# back to early_start when no leaf carries one (pre-#2622 synced rows).
|
|
894
|
+
ss_dates = [t.scheduled_start for t in leaf_results if t.scheduled_start is not None]
|
|
895
|
+
floats = [t.total_float for t in leaf_results if t.total_float is not None]
|
|
896
|
+
|
|
897
|
+
summary_sched = SchedTask(
|
|
898
|
+
id=sid,
|
|
899
|
+
name=db_task_by_id[sid].name if sid in db_task_by_id else sid,
|
|
900
|
+
duration=timedelta(days=0),
|
|
901
|
+
)
|
|
902
|
+
summary_sched.early_start = min(es_dates)
|
|
903
|
+
summary_sched.early_finish = max(ef_dates)
|
|
904
|
+
summary_sched.late_start = min(ls_dates) if ls_dates else summary_sched.early_start
|
|
905
|
+
summary_sched.late_finish = max(lf_dates) if lf_dates else summary_sched.early_finish
|
|
906
|
+
summary_sched.scheduled_start = min(ss_dates) if ss_dates else summary_sched.early_start
|
|
907
|
+
summary_sched.total_float = min(floats) if floats else timedelta(days=0)
|
|
908
|
+
summary_sched.free_float = timedelta(days=0)
|
|
909
|
+
summary_sched.is_critical = any(t.is_critical for t in leaf_results)
|
|
910
|
+
return summary_sched
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def apply_summary_rollups(
|
|
914
|
+
result_map: dict[str, Any],
|
|
915
|
+
summary_ids: set[str],
|
|
916
|
+
children_map: dict[str, list[str]],
|
|
917
|
+
db_task_by_id: dict[str, Any],
|
|
918
|
+
) -> None:
|
|
919
|
+
"""Add synthetic CPM rows for summary tasks to ``result_map`` in place (ADR-0105).
|
|
920
|
+
|
|
921
|
+
Summary tasks are excluded from the leaf CPM pass (they are grouping nodes, not
|
|
922
|
+
schedulable work), so their dates are *derived* from their leaf descendants:
|
|
923
|
+
``early_start = min(leaf early_start)``, ``early_finish = max(leaf early_finish)``,
|
|
924
|
+
tightest float, critical if any leaf is critical. Shared by the single-project
|
|
925
|
+
write-back (``scheduling.tasks._run_schedule``) and the program-scoped write-back
|
|
926
|
+
(``recalculate_program_schedule``) so the two never drift (the #1185 class). The
|
|
927
|
+
merged ``children_map`` keys are globally-unique task ids, so this works
|
|
928
|
+
unchanged across the per-project and program graphs.
|
|
929
|
+
"""
|
|
930
|
+
from trueppm_scheduler.engine import _collect_leaves
|
|
931
|
+
|
|
932
|
+
for sid in summary_ids:
|
|
933
|
+
leaves = _collect_leaves(sid, children_map)
|
|
934
|
+
leaf_results = [result_map[lid] for lid in leaves if lid in result_map]
|
|
935
|
+
if not leaf_results:
|
|
936
|
+
continue
|
|
937
|
+
rolled = _rollup_one_summary(sid, leaf_results, db_task_by_id)
|
|
938
|
+
if rolled is not None:
|
|
939
|
+
result_map[sid] = rolled
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
# Capture-path dedup window (ADR-0154 §3): a recompute that produces an
|
|
943
|
+
# unchanged forecast within this window of the previous snapshot is a no-op, so
|
|
944
|
+
# a project recomputed many times during a heavy edit session does not write a
|
|
945
|
+
# row per recompute. This window also makes a duplicate recompute (broker retry,
|
|
946
|
+
# manual re-queue) idempotent.
|
|
947
|
+
FORECAST_DEDUP_WINDOW_SECONDS = 3600
|
|
948
|
+
|
|
949
|
+
# The fields compared for the dedup no-op decision — the forecast itself plus the
|
|
950
|
+
# schedule-shape context. captured_at/triggered_by are intentionally excluded.
|
|
951
|
+
_FORECAST_DEDUP_FIELDS = (
|
|
952
|
+
"cpm_finish",
|
|
953
|
+
"total_float_days",
|
|
954
|
+
"mc_p50_finish",
|
|
955
|
+
"mc_p80_finish",
|
|
956
|
+
"mc_p95_finish",
|
|
957
|
+
"mc_iterations",
|
|
958
|
+
"task_count",
|
|
959
|
+
"completed_task_count",
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def capture_forecast_snapshot(project_id: str | uuid.UUID, trigger: str) -> Any | None:
|
|
964
|
+
"""Capture a project-grain ``ProjectForecastSnapshot`` (ADR-0154, #388).
|
|
965
|
+
|
|
966
|
+
Derives every field from already-committed state — the just-recomputed
|
|
967
|
+
``Task`` rows (CPM spine) plus the project's most-recent ``MonteCarloRun``
|
|
968
|
+
(probabilistic band, best-effort, may be absent or stale). Idempotent within
|
|
969
|
+
``FORECAST_DEDUP_WINDOW_SECONDS``: if the latest snapshot is newer than the
|
|
970
|
+
window and every forecast field is unchanged, this no-ops and returns ``None``.
|
|
971
|
+
|
|
972
|
+
The aggregate population is ``Task.committed`` — the same set every
|
|
973
|
+
``SchedProject`` construction site feeds to CPM. See the comment on the
|
|
974
|
+
aggregate below for why using ``Task.objects`` here was a correctness bug
|
|
975
|
+
(#3539) rather than a scoping preference.
|
|
976
|
+
|
|
977
|
+
Returns the created row, or ``None`` when the capture was deduped.
|
|
978
|
+
"""
|
|
979
|
+
from datetime import timedelta
|
|
980
|
+
|
|
981
|
+
from django.db.models import Count, Max, Min, Q
|
|
982
|
+
from django.utils import timezone
|
|
983
|
+
|
|
984
|
+
from trueppm_api.apps.projects.models import Task, TaskStatus
|
|
985
|
+
from trueppm_api.apps.scheduling.models import MonteCarloRun, ProjectForecastSnapshot
|
|
986
|
+
|
|
987
|
+
# One aggregate query for the whole-project schedule shape. cpm_finish is the
|
|
988
|
+
# latest task finish; total_float_days is the tightest slack across the project
|
|
989
|
+
# (negative = a constraint is breached).
|
|
990
|
+
#
|
|
991
|
+
# The population is Task.committed, NOT Task.objects (#3539). A snapshot must
|
|
992
|
+
# describe the population CPM actually ran on, and every SchedProject
|
|
993
|
+
# construction site schedules Task.committed — BACKLOG rows, EPIC grouping
|
|
994
|
+
# nodes and recurring occurrences are never fed to the engine. Two things go
|
|
995
|
+
# wrong when they are admitted to the aggregate:
|
|
996
|
+
#
|
|
997
|
+
# 1. A task groomed back to BACKLOG keeps whatever early_finish/total_float
|
|
998
|
+
# it carried when it was last scheduled — _apply_cpm_results only
|
|
999
|
+
# bulk_updates the rows it scheduled, so nothing ever clears them
|
|
1000
|
+
# (clearing them is a separate decision, tracked in #3578).
|
|
1001
|
+
# That stale value then wins the Max/Min and reports a finish date, or a
|
|
1002
|
+
# slack figure, belonging to work the schedule does not contain. Measured
|
|
1003
|
+
# on dev: a 6-day false project finish, and a project with no float at
|
|
1004
|
+
# all reporting 30 days of it from seven groomed-out stories.
|
|
1005
|
+
# 2. cpm_finish feeds notify_project_end_date_shift, which emails the
|
|
1006
|
+
# PM/Owner cohort on a material move. A stale BACKLOG row entering or
|
|
1007
|
+
# leaving the Max moves cpm_finish with no change to any committed work,
|
|
1008
|
+
# so the wrong population does not merely mis-report — it emails owners
|
|
1009
|
+
# that their end date shifted when it did not, and symmetrically hides a
|
|
1010
|
+
# real shift inside that noise.
|
|
1011
|
+
#
|
|
1012
|
+
# The counts are aggregated over the same population on purpose: they are
|
|
1013
|
+
# recorded as "schedule shape at capture time — context for interpreting the
|
|
1014
|
+
# forecast", and a task_count drawn from a wider set than cpm_finish makes the
|
|
1015
|
+
# snapshot internally incoherent (a trend chart annotating a committed-set
|
|
1016
|
+
# finish with a whole-board task count).
|
|
1017
|
+
agg = Task.committed.filter(project_id=project_id).aggregate(
|
|
1018
|
+
cpm_finish=Max("early_finish"),
|
|
1019
|
+
total_float_days=Min("total_float"),
|
|
1020
|
+
task_count=Count("id"),
|
|
1021
|
+
completed=Count("id", filter=Q(status=TaskStatus.COMPLETE)),
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
latest_mc = (
|
|
1025
|
+
MonteCarloRun.objects.filter(project_id=project_id)
|
|
1026
|
+
.order_by("-taken_at")
|
|
1027
|
+
.values("p50", "p80", "p95", "n_simulations")
|
|
1028
|
+
.first()
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
fields = {
|
|
1032
|
+
"cpm_finish": agg["cpm_finish"],
|
|
1033
|
+
"total_float_days": agg["total_float_days"],
|
|
1034
|
+
"mc_p50_finish": latest_mc["p50"] if latest_mc else None,
|
|
1035
|
+
"mc_p80_finish": latest_mc["p80"] if latest_mc else None,
|
|
1036
|
+
"mc_p95_finish": latest_mc["p95"] if latest_mc else None,
|
|
1037
|
+
"mc_iterations": latest_mc["n_simulations"] if latest_mc else None,
|
|
1038
|
+
"task_count": agg["task_count"] or 0,
|
|
1039
|
+
"completed_task_count": agg["completed"] or 0,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
# Capture-path dedup: no-op if the latest snapshot is within the window AND
|
|
1043
|
+
# every tracked field matches. The latest-row read is the idempotency guard.
|
|
1044
|
+
latest = (
|
|
1045
|
+
ProjectForecastSnapshot.objects.filter(project_id=project_id)
|
|
1046
|
+
.order_by("-captured_at")
|
|
1047
|
+
.first()
|
|
1048
|
+
)
|
|
1049
|
+
if latest is not None:
|
|
1050
|
+
within_window = (timezone.now() - latest.captured_at) < timedelta(
|
|
1051
|
+
seconds=FORECAST_DEDUP_WINDOW_SECONDS
|
|
1052
|
+
)
|
|
1053
|
+
unchanged = all(getattr(latest, name) == fields[name] for name in _FORECAST_DEDUP_FIELDS)
|
|
1054
|
+
if within_window and unchanged:
|
|
1055
|
+
return None
|
|
1056
|
+
|
|
1057
|
+
return ProjectForecastSnapshot.objects.create(
|
|
1058
|
+
project_id=project_id, triggered_by=trigger, **fields
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def notify_project_end_date_shift(snapshot: Any) -> None:
|
|
1063
|
+
"""Notify the project's PM/Owner cohort when the CPM finish moves by more
|
|
1064
|
+
than the project's configurable threshold (#1911, the third #82 schedule-
|
|
1065
|
+
notification rule — companion to #1668's dependency-slip / became-critical
|
|
1066
|
+
pair, and the ``ProjectForecastSnapshot`` analog of
|
|
1067
|
+
``projects.services.notify_milestone_forecast_shift`` (#861)).
|
|
1068
|
+
|
|
1069
|
+
Compares ``snapshot`` (the just-captured row) against the immediately-prior
|
|
1070
|
+
``ProjectForecastSnapshot`` for the same project. Material shift = the
|
|
1071
|
+
absolute day delta between the two ``cpm_finish`` values is strictly
|
|
1072
|
+
greater than ``Project.end_date_shift_threshold_days``. A project with no
|
|
1073
|
+
prior snapshot (or no cpm_finish on either side) has nothing to compare
|
|
1074
|
+
against and is skipped — the first-ever finish is not a "shift".
|
|
1075
|
+
|
|
1076
|
+
Debounce (issue AC) is structural, not a separate guard here: this is
|
|
1077
|
+
called once per *newly created* snapshot, and ``capture_forecast_snapshot``
|
|
1078
|
+
already no-ops (creates no row) when the forecast is unchanged within
|
|
1079
|
+
``FORECAST_DEDUP_WINDOW_SECONDS`` — so a recompute that doesn't move the
|
|
1080
|
+
finish produces no new snapshot and therefore no repeat notification for
|
|
1081
|
+
the same shift.
|
|
1082
|
+
|
|
1083
|
+
Role targeting mirrors ``notify_milestone_forecast_shift``: PM/Owner cohort
|
|
1084
|
+
is ``role >= Role.ADMIN``, ``is_deleted=False``. The ``is_deleted=False``
|
|
1085
|
+
filter is load-bearing for privacy — member removal is a soft delete that
|
|
1086
|
+
leaves the row (with its role) intact, so without it a revoked PM would
|
|
1087
|
+
keep receiving end-date-shift digests for a project they no longer belong
|
|
1088
|
+
to (rbac-check).
|
|
1089
|
+
"""
|
|
1090
|
+
from trueppm_api.apps.access.models import ProjectMembership, Role
|
|
1091
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
1092
|
+
from trueppm_api.apps.notifications.services import create_event_notifications
|
|
1093
|
+
from trueppm_api.apps.projects.models import Project
|
|
1094
|
+
from trueppm_api.apps.scheduling.models import ProjectForecastSnapshot
|
|
1095
|
+
|
|
1096
|
+
if snapshot.cpm_finish is None:
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
prior = (
|
|
1100
|
+
ProjectForecastSnapshot.objects.filter(project_id=snapshot.project_id)
|
|
1101
|
+
.exclude(pk=snapshot.pk)
|
|
1102
|
+
.order_by("-captured_at")
|
|
1103
|
+
.first()
|
|
1104
|
+
)
|
|
1105
|
+
if prior is None or prior.cpm_finish is None:
|
|
1106
|
+
return # no prior finish to compare against — not a "shift"
|
|
1107
|
+
|
|
1108
|
+
delta_days = abs((snapshot.cpm_finish - prior.cpm_finish).days)
|
|
1109
|
+
|
|
1110
|
+
project_row = (
|
|
1111
|
+
Project.objects.filter(pk=snapshot.project_id)
|
|
1112
|
+
.values("name", "end_date_shift_threshold_days")
|
|
1113
|
+
.first()
|
|
1114
|
+
)
|
|
1115
|
+
if project_row is None or delta_days <= project_row["end_date_shift_threshold_days"]:
|
|
1116
|
+
return
|
|
1117
|
+
|
|
1118
|
+
# rbac-check: recipients are computed server-side from role, never taken from
|
|
1119
|
+
# request input or filtered client-side — a plain member can never end up in
|
|
1120
|
+
# this list regardless of what the caller passes.
|
|
1121
|
+
recipient_ids = list(
|
|
1122
|
+
ProjectMembership.objects.filter(
|
|
1123
|
+
project_id=snapshot.project_id, role__gte=Role.ADMIN, is_deleted=False
|
|
1124
|
+
).values_list("user_id", flat=True)
|
|
1125
|
+
)
|
|
1126
|
+
if not recipient_ids:
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
direction = "pushed out" if snapshot.cpm_finish > prior.cpm_finish else "pulled in"
|
|
1130
|
+
day_word = "day" if delta_days == 1 else "days"
|
|
1131
|
+
subject = f"Project end date shifted — {project_row['name']}"
|
|
1132
|
+
body = (
|
|
1133
|
+
f"The project finish {direction} by {delta_days} {day_word}: "
|
|
1134
|
+
f"{prior.cpm_finish.isoformat()} → {snapshot.cpm_finish.isoformat()}."
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
project_id_str = str(snapshot.project_id)
|
|
1138
|
+
# Deferred like every other event-notification write in this codebase
|
|
1139
|
+
# (notify_milestone_forecast_shift, notify_carryover_assignees): on_commit
|
|
1140
|
+
# is safe to call even with no active transaction (runs immediately), and
|
|
1141
|
+
# protects a future caller that invokes this from inside an open one.
|
|
1142
|
+
transaction.on_commit(
|
|
1143
|
+
lambda: create_event_notifications(
|
|
1144
|
+
event_type=NotificationEventType.PROJECT_END_DATE_SHIFTED,
|
|
1145
|
+
recipient_ids=recipient_ids,
|
|
1146
|
+
subject=subject,
|
|
1147
|
+
body=body,
|
|
1148
|
+
project_id=project_id_str,
|
|
1149
|
+
)
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def safe_capture_forecast_snapshot(project_id: str | uuid.UUID, trigger: str) -> None:
|
|
1154
|
+
"""Best-effort wrapper around :func:`capture_forecast_snapshot` (ADR-0154 §3).
|
|
1155
|
+
|
|
1156
|
+
Used by the recompute ``on_commit`` hook: a capture failure must never roll
|
|
1157
|
+
back or block the CPM write (we are strictly post-commit), and the data is
|
|
1158
|
+
fully reconstructable, so any exception is logged and discarded — the daily
|
|
1159
|
+
floor task backfills the miss.
|
|
1160
|
+
|
|
1161
|
+
Also fires the project end-date shift notification (#1911) whenever a new
|
|
1162
|
+
snapshot is actually captured. Wiring it here — rather than at each of the
|
|
1163
|
+
three call sites (recompute, program recompute, daily floor) — is the
|
|
1164
|
+
single hook point that covers every trigger uniformly. Wrapped in its own
|
|
1165
|
+
try/except so a notification bug can never surface as (or mask) a snapshot
|
|
1166
|
+
capture failure.
|
|
1167
|
+
"""
|
|
1168
|
+
try:
|
|
1169
|
+
snapshot = capture_forecast_snapshot(project_id, trigger)
|
|
1170
|
+
except Exception:
|
|
1171
|
+
logger.warning("forecast snapshot capture failed for project %s", project_id, exc_info=True)
|
|
1172
|
+
return
|
|
1173
|
+
|
|
1174
|
+
if snapshot is not None:
|
|
1175
|
+
try:
|
|
1176
|
+
notify_project_end_date_shift(snapshot)
|
|
1177
|
+
except Exception:
|
|
1178
|
+
logger.warning(
|
|
1179
|
+
"project end-date shift notification failed for project %s",
|
|
1180
|
+
project_id,
|
|
1181
|
+
exc_info=True,
|
|
1182
|
+
)
|