trueppm-api 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +27 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +346 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/minter_role.py +38 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +3263 -0
- trueppm_api/apps/access/serializers.py +1007 -0
- trueppm_api/apps/access/services.py +878 -0
- trueppm_api/apps/access/signals.py +171 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +96 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1965 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +109 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +459 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +178 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +863 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +597 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +635 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +528 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +734 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1158 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +280 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +551 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1276 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +439 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +462 -0
- trueppm_api/apps/notifications/email_backend.py +350 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1148 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +1094 -0
- trueppm_api/apps/notifications/services.py +1489 -0
- trueppm_api/apps/notifications/tasks.py +762 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +919 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +566 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +993 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +537 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +541 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +458 -0
- trueppm_api/apps/projects/backfill.py +459 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +212 -0
- trueppm_api/apps/projects/config_notice.py +1330 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +132 -0
- trueppm_api/apps/projects/decisions_views.py +97 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +423 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/key_views.py +468 -0
- trueppm_api/apps/projects/keys.py +205 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migration_helpers.py +75 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
- trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
- trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
- trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +9209 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +903 -0
- trueppm_api/apps/projects/program_schedule.py +767 -0
- trueppm_api/apps/projects/program_views.py +3445 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
- trueppm_api/apps/projects/seed/exporter.py +1717 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2764 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +128 -0
- trueppm_api/apps/projects/seed/replay.py +1695 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1169 -0
- trueppm_api/apps/projects/serializers.py +12904 -0
- trueppm_api/apps/projects/services.py +7525 -0
- trueppm_api/apps/projects/share_serializers.py +142 -0
- trueppm_api/apps/projects/share_services.py +373 -0
- trueppm_api/apps/projects/share_views.py +395 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +910 -0
- trueppm_api/apps/projects/signal_privacy_views.py +485 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +288 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1244 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1979 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +261 -0
- trueppm_api/apps/projects/template_views.py +829 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1016 -0
- trueppm_api/apps/projects/utilization.py +909 -0
- trueppm_api/apps/projects/views.py +21225 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1752 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +144 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/finish_reading.py +198 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/mc_admission.py +181 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +542 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +334 -0
- trueppm_api/apps/scheduling/serializers.py +723 -0
- trueppm_api/apps/scheduling/services.py +1251 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +2248 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2908 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +446 -0
- trueppm_api/apps/sso/services.py +1473 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +369 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +548 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +272 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +488 -0
- trueppm_api/apps/sync/serializers.py +688 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +556 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +957 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +440 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +211 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +597 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +279 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
- trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
- trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +437 -0
- trueppm_api/apps/webhooks/serializers.py +326 -0
- trueppm_api/apps/webhooks/tasks.py +680 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +533 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +371 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +254 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +967 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +616 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1581 -0
- trueppm_api/asgi.py +49 -0
- trueppm_api/celery.py +40 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +934 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/demo_read_only.py +314 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +1221 -0
- trueppm_api/core/password_reset.py +513 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +1034 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +514 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/core/worker_selfheal.py +205 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2605 -0
- trueppm_api/settings/dev.py +296 -0
- trueppm_api/settings/prod.py +265 -0
- trueppm_api/urls.py +217 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0.dist-info/METADATA +273 -0
- trueppm_api-0.4.0.dist-info/RECORD +799 -0
- trueppm_api-0.4.0.dist-info/WHEEL +4 -0
- trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
"""Replay a v2 seed event timeline with backdated history (ADR-0114).
|
|
2
|
+
|
|
3
|
+
The v1 importer materializes final state: one ORM write per row, every
|
|
4
|
+
``django-simple-history`` row dated ``now()`` by the importer. A v2 seed instead
|
|
5
|
+
authors the program's *life* — an ordered ``events`` timeline — and this module
|
|
6
|
+
replays it so the demo reads like a program that has run for months:
|
|
7
|
+
|
|
8
|
+
- Tasks are created at their base column and walked forward through real status
|
|
9
|
+
transitions; each transition writes a history row dated to the event, attributed
|
|
10
|
+
to a named persona, so a COMPLETE task's History tab shows dated moves by people.
|
|
11
|
+
- A deterministic synthesizer fills the unauthored "boring middle": any task whose
|
|
12
|
+
final state implies it passed through earlier columns gets synthetic transitions
|
|
13
|
+
(it always traverses IN_PROGRESS, never NOT_STARTED→COMPLETE, so burndowns are
|
|
14
|
+
not hollow — VoC/Alex). Authored events always win over synthesis.
|
|
15
|
+
- A day-by-day sim clock writes one ``SprintBurnSnapshot`` per active-sprint day,
|
|
16
|
+
so burndown curves and multi-sprint velocity are real history, not a single
|
|
17
|
+
fabricated number.
|
|
18
|
+
|
|
19
|
+
Replay runs inside the importer's existing ``transaction.atomic()`` and under the
|
|
20
|
+
``seed_replay`` context flag, so the live side effects a real edit would trigger —
|
|
21
|
+
today-dated burndown, board broadcasts, notifications, webhooks — are suppressed.
|
|
22
|
+
A single CPM recalc per project is enqueued by the importer after commit.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
import logging
|
|
29
|
+
import random
|
|
30
|
+
from collections.abc import Callable, Sequence
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from datetime import date, datetime, timedelta
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
from zoneinfo import ZoneInfo
|
|
35
|
+
|
|
36
|
+
from django.db.models import F, Q
|
|
37
|
+
from django.utils import timezone
|
|
38
|
+
|
|
39
|
+
from trueppm_api.apps.projects.actual_date_rules import check_actual_dates
|
|
40
|
+
from trueppm_api.apps.projects.models import (
|
|
41
|
+
AcceptanceCriterion,
|
|
42
|
+
Baseline,
|
|
43
|
+
BaselineTask,
|
|
44
|
+
CommentAcknowledgement,
|
|
45
|
+
CommentReaction,
|
|
46
|
+
EstimateStatus,
|
|
47
|
+
RetroActionItem,
|
|
48
|
+
Risk,
|
|
49
|
+
RiskComment,
|
|
50
|
+
ScopeChangeStatus,
|
|
51
|
+
Sprint,
|
|
52
|
+
SprintRetro,
|
|
53
|
+
SprintScopeChange,
|
|
54
|
+
SprintState,
|
|
55
|
+
Task,
|
|
56
|
+
TaskComment,
|
|
57
|
+
TaskNote,
|
|
58
|
+
TaskStatus,
|
|
59
|
+
)
|
|
60
|
+
from trueppm_api.apps.projects.seed.reldates import WorkingCalendar, resolve_timestamp
|
|
61
|
+
from trueppm_api.apps.projects.seed.replay_ctx import seed_replay
|
|
62
|
+
from trueppm_api.apps.projects.services import upsert_burndown_for_sprint
|
|
63
|
+
from trueppm_api.apps.timetracking.models import TimeEntry
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING:
|
|
66
|
+
from trueppm_api.apps.notifications.services import ParsedMention
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
# Seed-script beat action key for board-column moves.
|
|
71
|
+
_TASK_STATUS_ACTION = "task.status"
|
|
72
|
+
|
|
73
|
+
# Forward progression of the board. The synthesizer walks a task from its base
|
|
74
|
+
# column up to its authored final column through this sequence, so every
|
|
75
|
+
# in-flight/done task passes through IN_PROGRESS (a hollow burndown is the bug
|
|
76
|
+
# Alex called out in VoC). BACKLOG/ON_HOLD are off the progression spine.
|
|
77
|
+
_PROGRESSION = [
|
|
78
|
+
TaskStatus.NOT_STARTED,
|
|
79
|
+
TaskStatus.IN_PROGRESS,
|
|
80
|
+
TaskStatus.REVIEW,
|
|
81
|
+
TaskStatus.COMPLETE,
|
|
82
|
+
]
|
|
83
|
+
_PROGRESSION_INDEX: dict[str, int] = {str(s): i for i, s in enumerate(_PROGRESSION)}
|
|
84
|
+
|
|
85
|
+
# Upper bound on the day-by-day snapshot window (~10 years). The schema already
|
|
86
|
+
# caps a single date offset at 4 digits; this bounds the *span* between the
|
|
87
|
+
# earliest and latest beat so no crafted timeline drives an oversized loop.
|
|
88
|
+
MAX_REPLAY_SPAN_DAYS = 3660
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class ReplayContext:
|
|
93
|
+
"""Symbol tables + resolved settings the importer hands to the replay pass."""
|
|
94
|
+
|
|
95
|
+
anchor: date
|
|
96
|
+
program_code: str
|
|
97
|
+
default_actor: Any # the importing owner; fallback when an event names no actor
|
|
98
|
+
users: dict[str, Any] # account slug -> User (or None on the generic path)
|
|
99
|
+
tasks: dict[tuple[str, str], Task] # (project_slug, wbs_path) -> Task
|
|
100
|
+
sprints: dict[tuple[str, str], Sprint] # (project_slug, sprint_slug) -> Sprint
|
|
101
|
+
projects: dict[str, Any] # project_slug -> Project
|
|
102
|
+
project_calendars: dict[str, WorkingCalendar] # project_slug -> calendar facts
|
|
103
|
+
risks: dict[str, Risk] # risk slug -> Risk
|
|
104
|
+
final_status: dict[tuple[str, str], str] # desired end column per task
|
|
105
|
+
final_sprint: dict[tuple[str, str], dict[str, Any]] # desired end state per sprint
|
|
106
|
+
# Authored in-flight progress per task, restored after the timeline runs
|
|
107
|
+
# (#3486). Only carries the keys the document actually wrote, so a dated
|
|
108
|
+
# ``task.points`` beat is never overwritten by a value nobody authored.
|
|
109
|
+
final_progress: dict[tuple[str, str], dict[str, Any]] = field(default_factory=dict)
|
|
110
|
+
tz: Any = None # program timezone for synthesized timestamps (defaults to UTC)
|
|
111
|
+
# Scope-change rows opened during replay, so a later resolve can close them.
|
|
112
|
+
open_scope: dict[Any, SprintScopeChange] = field(default_factory=dict)
|
|
113
|
+
# v2.1 (#3603): acceptance criteria per task in position order, so a
|
|
114
|
+
# ``task.ac_met`` beat can tick one by index; and comments by the slug their
|
|
115
|
+
# ``task.comment`` beat declared, so a reply/reaction/ack can find them.
|
|
116
|
+
criteria: dict[tuple[str, str], list[AcceptanceCriterion]] = field(default_factory=dict)
|
|
117
|
+
comments: dict[str, TaskComment] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(order=True)
|
|
121
|
+
class _Beat:
|
|
122
|
+
"""One resolved timeline beat, sortable by (when, order)."""
|
|
123
|
+
|
|
124
|
+
when: datetime
|
|
125
|
+
order: int
|
|
126
|
+
action: str = field(compare=False)
|
|
127
|
+
target: str = field(compare=False)
|
|
128
|
+
actor: Any = field(compare=False, default=None)
|
|
129
|
+
data: dict[str, Any] = field(compare=False, default_factory=dict)
|
|
130
|
+
synthetic: bool = field(compare=False, default=False)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def replay_timeline(payload: dict[str, Any], ctx: ReplayContext) -> None:
|
|
134
|
+
"""Replay the document's events (+ synthesized fill) against ``ctx``.
|
|
135
|
+
|
|
136
|
+
Idempotent within an import: the caller wipes-and-recreates the program, so
|
|
137
|
+
the timeline is always replayed from a clean base. Determinism comes from a
|
|
138
|
+
seed derived from ``program_code`` + the task wbs path.
|
|
139
|
+
|
|
140
|
+
Also seeds every persona's notification preferences, then — beside the
|
|
141
|
+
per-beat notifications individual handlers below emit — synthesizes the two
|
|
142
|
+
event types with no single triggering beat: a ``task.stale`` sweep and the
|
|
143
|
+
sponsor persona's ``program.health_digest`` (#3489).
|
|
144
|
+
"""
|
|
145
|
+
with seed_replay():
|
|
146
|
+
_seed_notification_preferences(payload, ctx)
|
|
147
|
+
beats = _resolve_authored(payload, ctx)
|
|
148
|
+
beats.extend(_synthesize(ctx, beats))
|
|
149
|
+
beats.sort()
|
|
150
|
+
_run_sim_clock(beats, ctx)
|
|
151
|
+
_synthesize_stale_notifications(ctx)
|
|
152
|
+
_synthesize_program_digest(payload, ctx)
|
|
153
|
+
_apply_notification_realism(ctx)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- resolution --------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_authored(payload: dict[str, Any], ctx: ReplayContext) -> list[_Beat]:
|
|
160
|
+
"""Turn authored event dicts into sortable beats with concrete datetimes."""
|
|
161
|
+
out: list[_Beat] = []
|
|
162
|
+
for i, ev in enumerate(payload.get("events", [])):
|
|
163
|
+
when = resolve_timestamp(ev["at"], anchor=ctx.anchor)
|
|
164
|
+
actor = ctx.users.get(ev["actor"]) if ev.get("actor") else None
|
|
165
|
+
out.append(
|
|
166
|
+
_Beat(
|
|
167
|
+
when=when,
|
|
168
|
+
order=i,
|
|
169
|
+
action=ev["action"],
|
|
170
|
+
target=ev.get("target", ""),
|
|
171
|
+
actor=actor or ctx.default_actor,
|
|
172
|
+
data=ev,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _resolve_task(ctx: ReplayContext, target: str) -> Task | None:
|
|
179
|
+
"""Resolve a ``task:<project>:<wbs>`` target to a Task in the symbol table."""
|
|
180
|
+
_, _, ref = target.partition(":")
|
|
181
|
+
project_slug, _, wbs = ref.partition(":")
|
|
182
|
+
return ctx.tasks.get((project_slug, wbs))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resolve_sprint(ctx: ReplayContext, target: str) -> Sprint | None:
|
|
186
|
+
_, _, ref = target.partition(":")
|
|
187
|
+
project_slug, _, sprint_slug = ref.partition(":")
|
|
188
|
+
return ctx.sprints.get((project_slug, sprint_slug))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _task_window(ctx: ReplayContext, key: tuple[str, str], task: Task) -> tuple[date, date]:
|
|
192
|
+
"""The [start, end] working window a synthesized task progresses across.
|
|
193
|
+
|
|
194
|
+
Agile tasks ride their sprint window; scheduled tasks ride
|
|
195
|
+
planned_start..+duration; everything else rides the project start..anchor.
|
|
196
|
+
The end is clamped to the anchor — a demo never has work completing in the
|
|
197
|
+
future relative to "today".
|
|
198
|
+
"""
|
|
199
|
+
project_slug = key[0]
|
|
200
|
+
project = ctx.projects[project_slug]
|
|
201
|
+
sprint = task.sprint
|
|
202
|
+
if sprint is not None:
|
|
203
|
+
start, end = sprint.start_date, sprint.finish_date
|
|
204
|
+
elif task.planned_start is not None:
|
|
205
|
+
start = task.planned_start
|
|
206
|
+
end = start + timedelta(days=max(task.duration, 1))
|
|
207
|
+
else:
|
|
208
|
+
start = project.start_date
|
|
209
|
+
end = ctx.anchor
|
|
210
|
+
# Clamp both ends to the anchor: a completed task whose nominal window sits
|
|
211
|
+
# in the future (relative to import day) still gets past-dated history, never
|
|
212
|
+
# future-dated — the demo's "today" is the anchor.
|
|
213
|
+
start = min(start, ctx.anchor)
|
|
214
|
+
end = min(end, ctx.anchor)
|
|
215
|
+
if end < start:
|
|
216
|
+
end = start
|
|
217
|
+
return start, end
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _synthesize(ctx: ReplayContext, authored: list[_Beat]) -> list[_Beat]:
|
|
221
|
+
"""Fill the unauthored timeline: sprint ceremonies + task progressions."""
|
|
222
|
+
return _synthesize_sprints(ctx, authored) + _synthesize_tasks(ctx, authored)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _synthesize_sprints(ctx: ReplayContext, authored: list[_Beat]) -> list[_Beat]:
|
|
226
|
+
"""Synthesize activate/close beats for sprints the timeline left implicit.
|
|
227
|
+
|
|
228
|
+
A sample that merely declares a sprint's end state + points (the v1 shape)
|
|
229
|
+
still gets a real run: activate at start, close at finish, so the sim clock
|
|
230
|
+
writes a burndown curve and a velocity number that is actual history.
|
|
231
|
+
"""
|
|
232
|
+
authored_targets = {b.target for b in authored if b.action.startswith("sprint.")}
|
|
233
|
+
out: list[_Beat] = []
|
|
234
|
+
for key, sprint in ctx.sprints.items():
|
|
235
|
+
target = f"sprint:{key[0]}:{key[1]}"
|
|
236
|
+
if target in authored_targets:
|
|
237
|
+
continue
|
|
238
|
+
final_state = ctx.final_sprint.get(key, {}).get("state", SprintState.PLANNED)
|
|
239
|
+
if final_state not in (SprintState.ACTIVE, SprintState.COMPLETED):
|
|
240
|
+
continue
|
|
241
|
+
out.append(
|
|
242
|
+
_Beat(
|
|
243
|
+
when=_aware_dt(ctx, sprint.start_date, 9, 0),
|
|
244
|
+
order=0,
|
|
245
|
+
action="sprint.activate",
|
|
246
|
+
target=target,
|
|
247
|
+
actor=ctx.default_actor,
|
|
248
|
+
data={},
|
|
249
|
+
synthetic=True,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if final_state == SprintState.COMPLETED:
|
|
253
|
+
out.append(
|
|
254
|
+
_Beat(
|
|
255
|
+
when=_aware_dt(ctx, sprint.finish_date, 17, 0),
|
|
256
|
+
order=2_000_000, # close sorts after that day's task moves
|
|
257
|
+
action="sprint.close",
|
|
258
|
+
target=target,
|
|
259
|
+
actor=ctx.default_actor,
|
|
260
|
+
data={"goal_outcome": ctx.final_sprint.get(key, {}).get("goal_outcome")},
|
|
261
|
+
synthetic=True,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _synthesize_tasks(ctx: ReplayContext, authored: list[_Beat]) -> list[_Beat]:
|
|
268
|
+
"""Generate status transitions for tasks the authored timeline left implicit.
|
|
269
|
+
|
|
270
|
+
For each task whose final column is past NOT_STARTED and which has no authored
|
|
271
|
+
``task.status`` beat, walk it forward through every intermediate column on
|
|
272
|
+
evenly-spaced, seeded dates within its window. Deterministic: a fixed seed per
|
|
273
|
+
(program, wbs) means re-import and round-trip reproduce the same history.
|
|
274
|
+
"""
|
|
275
|
+
authored_status_targets = {b.target for b in authored if b.action == _TASK_STATUS_ACTION}
|
|
276
|
+
out: list[_Beat] = []
|
|
277
|
+
for key, task in ctx.tasks.items():
|
|
278
|
+
project_slug, wbs = key
|
|
279
|
+
target = f"task:{project_slug}:{wbs}"
|
|
280
|
+
if target in authored_status_targets:
|
|
281
|
+
continue # author owns this task's progression
|
|
282
|
+
final = ctx.final_status.get(key, TaskStatus.NOT_STARTED)
|
|
283
|
+
final_idx = _PROGRESSION_INDEX.get(final)
|
|
284
|
+
if final_idx is None or final_idx == 0:
|
|
285
|
+
continue # BACKLOG / ON_HOLD / still NOT_STARTED — nothing to walk
|
|
286
|
+
start, end = _task_window(ctx, key, task)
|
|
287
|
+
steps = _PROGRESSION[1 : final_idx + 1] # columns to move into, in order
|
|
288
|
+
rng = random.Random(f"{ctx.program_code}:{wbs}")
|
|
289
|
+
actor = task.assignee or ctx.default_actor
|
|
290
|
+
span = (end - start).days or 1
|
|
291
|
+
# Place each transition at a stable fraction of the window, jittered.
|
|
292
|
+
for n, status in enumerate(steps, start=1):
|
|
293
|
+
frac = n / (len(steps) + 1)
|
|
294
|
+
jitter = rng.uniform(-0.5, 0.5) / (len(steps) + 1)
|
|
295
|
+
day_offset = round(min(max(frac + jitter, 0.02), 0.98) * span)
|
|
296
|
+
when = _aware_dt(
|
|
297
|
+
ctx, start + timedelta(days=day_offset), rng.randint(9, 16), rng.randint(0, 59)
|
|
298
|
+
)
|
|
299
|
+
out.append(
|
|
300
|
+
_Beat(
|
|
301
|
+
when=when,
|
|
302
|
+
order=1_000_000 + n, # synthetic beats sort after same-instant authored
|
|
303
|
+
action=_TASK_STATUS_ACTION,
|
|
304
|
+
target=target,
|
|
305
|
+
actor=actor,
|
|
306
|
+
data={"to": status},
|
|
307
|
+
synthetic=True,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --- sim clock ---------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _run_sim_clock(beats: list[_Beat], ctx: ReplayContext) -> None:
|
|
317
|
+
"""Apply beats in time order; snapshot active-sprint burndown each day.
|
|
318
|
+
|
|
319
|
+
Iterating every calendar day (not only event days) means an active sprint
|
|
320
|
+
gets a snapshot on quiet days too, so the burndown curve has no gaps.
|
|
321
|
+
"""
|
|
322
|
+
if not beats:
|
|
323
|
+
# No timeline (e.g. a v2 doc with only relative dates) — still finalize
|
|
324
|
+
# sprint states and in-flight progress so the seed's intended end state
|
|
325
|
+
# holds.
|
|
326
|
+
_finalize(ctx, ctx.anchor)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
by_day: dict[date, list[_Beat]] = {}
|
|
330
|
+
for b in beats:
|
|
331
|
+
by_day.setdefault(b.when.date(), []).append(b)
|
|
332
|
+
|
|
333
|
+
first = min(by_day)
|
|
334
|
+
last = max(by_day)
|
|
335
|
+
# Bound the per-day snapshot loop. The schema caps any single offset at ~27
|
|
336
|
+
# years, but a seed could still author a multi-decade span; clamp the
|
|
337
|
+
# snapshotted window so a crafted timeline can't drive an oversized loop.
|
|
338
|
+
# Beats before the window are still applied (so task states entering the
|
|
339
|
+
# window are correct) — only their day-by-day snapshots are skipped.
|
|
340
|
+
window_start = first
|
|
341
|
+
if (last - first).days > MAX_REPLAY_SPAN_DAYS:
|
|
342
|
+
window_start = last - timedelta(days=MAX_REPLAY_SPAN_DAYS)
|
|
343
|
+
logger.warning(
|
|
344
|
+
"seed replay: span %sd exceeds %sd cap; snapshotting recent window only",
|
|
345
|
+
(last - first).days,
|
|
346
|
+
MAX_REPLAY_SPAN_DAYS,
|
|
347
|
+
)
|
|
348
|
+
for d in sorted(by_day):
|
|
349
|
+
if d >= window_start:
|
|
350
|
+
break
|
|
351
|
+
for beat in by_day[d]:
|
|
352
|
+
_apply(beat, ctx)
|
|
353
|
+
|
|
354
|
+
day = window_start
|
|
355
|
+
while day <= last:
|
|
356
|
+
for beat in by_day.get(day, []):
|
|
357
|
+
_apply(beat, ctx)
|
|
358
|
+
_snapshot_active_sprints(ctx, day)
|
|
359
|
+
day += timedelta(days=1)
|
|
360
|
+
|
|
361
|
+
_finalize(ctx, last)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _snapshot_active_sprints(ctx: ReplayContext, day: date) -> None:
|
|
365
|
+
for sprint in ctx.sprints.values():
|
|
366
|
+
if sprint.state == SprintState.ACTIVE and sprint.start_date <= day <= sprint.finish_date:
|
|
367
|
+
try:
|
|
368
|
+
upsert_burndown_for_sprint(sprint, snapshot_date=day)
|
|
369
|
+
# TODO(#4421): swallows SoftTimeLimitExceeded; the drain-loop guard
|
|
370
|
+
# is ineffective until this is fixed
|
|
371
|
+
except Exception: # burndown is a secondary observation; never fail the import
|
|
372
|
+
logger.exception(
|
|
373
|
+
"seed replay: burndown snapshot failed sprint=%s day=%s", sprint.pk, day
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _finalize(ctx: ReplayContext, day: date) -> None:
|
|
378
|
+
"""Restore the end states the replay had to walk up to, once it has.
|
|
379
|
+
|
|
380
|
+
Two passes, both backstops for the same structural fact: replay births an
|
|
381
|
+
entity at the *base* of its progression so the timeline has somewhere to walk
|
|
382
|
+
from, and the walk does not always land on every authored field.
|
|
383
|
+
"""
|
|
384
|
+
_finalize_sprints(ctx, day)
|
|
385
|
+
_finalize_tasks(ctx, day)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _finalize_sprints(ctx: ReplayContext, day: date) -> None:
|
|
389
|
+
"""Force every sprint to the seed's authored end state after replay.
|
|
390
|
+
|
|
391
|
+
Authored/synthesized activate+close beats normally drive this; this is the
|
|
392
|
+
backstop for sprints the timeline never touched, so the seed's declared
|
|
393
|
+
state still holds.
|
|
394
|
+
"""
|
|
395
|
+
for key, sprint in ctx.sprints.items():
|
|
396
|
+
desired = ctx.final_sprint.get(key, {}).get("state")
|
|
397
|
+
if desired and sprint.state != desired:
|
|
398
|
+
sprint.state = desired
|
|
399
|
+
_save(sprint, _aware(ctx, day), ctx.default_actor, ["state"])
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _finalize_tasks(ctx: ReplayContext, day: date) -> None:
|
|
403
|
+
"""Restore the authored progress of every task that ends in flight or in review.
|
|
404
|
+
|
|
405
|
+
A task whose end column is IN_PROGRESS or REVIEW is born at 0% with its full
|
|
406
|
+
points remaining so the timeline has room to walk it forward, and nothing
|
|
407
|
+
ever puts the authored numbers back on its own: ``_apply_task_status``
|
|
408
|
+
writes only the column and the actual dates. For IN_PROGRESS neither field
|
|
409
|
+
is rescued elsewhere (#3486). For REVIEW, ``Task._coerce_signoff_percent``
|
|
410
|
+
fixes ``percent_complete`` to 100 by design ("work done, awaiting
|
|
411
|
+
sign-off") but has no opinion on ``remaining_points`` — a story in review
|
|
412
|
+
with points still open is a real, authorable state (#3518). Without this
|
|
413
|
+
pass every in-flight or in-review task in every v2 seed loads holding its
|
|
414
|
+
full ``story_points``.
|
|
415
|
+
|
|
416
|
+
That is not cosmetic. ``percent_complete`` sets the *remaining* duration CPM
|
|
417
|
+
schedules (ADR-0132/0136), so a 0% in-flight task is billed at its full
|
|
418
|
+
duration and both the utilization heat map and the burndown's "remaining"
|
|
419
|
+
read high. The pass therefore runs before the importer's post-commit
|
|
420
|
+
``enqueue_recalculate``, so the recompute sees the corrected values.
|
|
421
|
+
|
|
422
|
+
Only fields the document actually authored are written, which is what keeps
|
|
423
|
+
a dated ``task.points`` beat authoritative: a task that declares no
|
|
424
|
+
``remaining_points`` of its own is left holding whatever the timeline gave
|
|
425
|
+
it. The importer never hands back ``percent_complete`` for a REVIEW task
|
|
426
|
+
(see its ``final_progress`` construction), so the coerced 100 is never
|
|
427
|
+
fought here even though this loop treats both statuses uniformly.
|
|
428
|
+
"""
|
|
429
|
+
for key, task in ctx.tasks.items():
|
|
430
|
+
if ctx.final_status.get(key) not in (TaskStatus.IN_PROGRESS, TaskStatus.REVIEW):
|
|
431
|
+
continue
|
|
432
|
+
authored = ctx.final_progress.get(key)
|
|
433
|
+
if not authored:
|
|
434
|
+
continue
|
|
435
|
+
fields = [
|
|
436
|
+
name
|
|
437
|
+
for name in ("percent_complete", "remaining_points")
|
|
438
|
+
if name in authored and getattr(task, name) != authored[name]
|
|
439
|
+
]
|
|
440
|
+
for name in fields:
|
|
441
|
+
setattr(task, name, authored[name])
|
|
442
|
+
if fields:
|
|
443
|
+
_save(task, _aware(ctx, day), ctx.default_actor, fields)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# --- event handlers ----------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _apply(beat: _Beat, ctx: ReplayContext) -> None:
|
|
450
|
+
handler = _HANDLERS.get(beat.action)
|
|
451
|
+
if handler is None: # pragma: no cover - validator restricts the action set
|
|
452
|
+
logger.warning("seed replay: no handler for action %r", beat.action)
|
|
453
|
+
return
|
|
454
|
+
handler(beat, ctx)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _apply_task_status(beat: _Beat, ctx: ReplayContext) -> None:
|
|
458
|
+
task = _resolve_task(ctx, beat.target)
|
|
459
|
+
if task is None:
|
|
460
|
+
return
|
|
461
|
+
new_status = beat.data.get("to")
|
|
462
|
+
if not new_status or new_status == task.status:
|
|
463
|
+
return
|
|
464
|
+
task.status = new_status
|
|
465
|
+
fields = ["status"]
|
|
466
|
+
# Actual dates make baseline-vs-actual slip visible (VoC/Sarah): first move
|
|
467
|
+
# into IN_PROGRESS stamps actual_start; reaching COMPLETE stamps finish.
|
|
468
|
+
if new_status == TaskStatus.IN_PROGRESS and task.actual_start is None:
|
|
469
|
+
task.actual_start = beat.when.date()
|
|
470
|
+
fields.append("actual_start")
|
|
471
|
+
if new_status == TaskStatus.COMPLETE:
|
|
472
|
+
task.actual_finish = beat.when.date()
|
|
473
|
+
task.remaining_points = 0
|
|
474
|
+
fields += ["actual_finish", "remaining_points"]
|
|
475
|
+
# Defense-in-depth (ADR-1153, #3709): beats are deterministic,
|
|
476
|
+
# developer-authored data and should never trip these rules, but this is a
|
|
477
|
+
# direct model write like the MS Project importer's, so it runs through the
|
|
478
|
+
# same shared check rather than trusting that by construction. A violation
|
|
479
|
+
# here means the beat script itself is wrong — log it and drop the
|
|
480
|
+
# offending field rather than persisting a pair the engine would reject at
|
|
481
|
+
# the project's next recompute.
|
|
482
|
+
violation = check_actual_dates(
|
|
483
|
+
actual_start=task.actual_start,
|
|
484
|
+
actual_finish=task.actual_finish,
|
|
485
|
+
status=task.status,
|
|
486
|
+
project=task.project,
|
|
487
|
+
)
|
|
488
|
+
if violation is not None:
|
|
489
|
+
logger.warning(
|
|
490
|
+
"seed replay: beat %r on task %s would violate ADR-1153 (%s); dropping %s",
|
|
491
|
+
beat.action,
|
|
492
|
+
task.pk,
|
|
493
|
+
violation.message,
|
|
494
|
+
violation.field,
|
|
495
|
+
)
|
|
496
|
+
setattr(task, violation.field, None)
|
|
497
|
+
if violation.field not in fields:
|
|
498
|
+
fields.append(violation.field)
|
|
499
|
+
_save(task, beat.when, beat.actor, fields)
|
|
500
|
+
# Task.save force-stamps status_changed_at=now(); correct it to the beat time
|
|
501
|
+
# so cycle-time and "in column since" read as history, not import time.
|
|
502
|
+
Task.objects.filter(pk=task.pk).update(status_changed_at=beat.when)
|
|
503
|
+
task.status_changed_at = beat.when
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _apply_task_assign(beat: _Beat, ctx: ReplayContext) -> None:
|
|
507
|
+
task = _resolve_task(ctx, beat.target)
|
|
508
|
+
if task is None:
|
|
509
|
+
return
|
|
510
|
+
previous_assignee_id = task.assignee_id
|
|
511
|
+
assignee = ctx.users.get(beat.data["assignee"]) if beat.data.get("assignee") else None
|
|
512
|
+
task.assignee = assignee
|
|
513
|
+
_save(task, beat.when, beat.actor, ["assignee"])
|
|
514
|
+
_notify_task_assigned(task, previous_assignee_id, assignee, beat)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _notify_task_assigned(
|
|
518
|
+
task: Task, previous_assignee_id: Any, assignee: Any, beat: _Beat
|
|
519
|
+
) -> None:
|
|
520
|
+
"""``task.assigned`` to the new assignee (#638/#639), mirroring
|
|
521
|
+
``views._emit_assignee_change_events``'s notification half.
|
|
522
|
+
|
|
523
|
+
Fires on any change that lands a real assignee — a fresh None→user
|
|
524
|
+
assignment or a user→user reassignment alike — except a clear (assignee
|
|
525
|
+
set to None) and never to the actor who made the assignment.
|
|
526
|
+
"""
|
|
527
|
+
if assignee is None or assignee.pk == previous_assignee_id:
|
|
528
|
+
return
|
|
529
|
+
if assignee.pk == getattr(beat.actor, "pk", None):
|
|
530
|
+
return
|
|
531
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
532
|
+
from trueppm_api.apps.notifications.services import create_event_notifications
|
|
533
|
+
|
|
534
|
+
subject = f"You were assigned to {task.name}"
|
|
535
|
+
body = f'You were assigned to the task "{task.name}" in TruePPM.'
|
|
536
|
+
_dispatch_and_backdate(
|
|
537
|
+
beat.when,
|
|
538
|
+
event_type=NotificationEventType.TASK_ASSIGNED.value,
|
|
539
|
+
project_id=task.project_id,
|
|
540
|
+
recipient_ids=[assignee.pk],
|
|
541
|
+
dispatch=lambda: create_event_notifications(
|
|
542
|
+
event_type=NotificationEventType.TASK_ASSIGNED.value,
|
|
543
|
+
recipient_ids=[assignee.pk],
|
|
544
|
+
subject=subject,
|
|
545
|
+
body=body,
|
|
546
|
+
project_id=task.project_id,
|
|
547
|
+
task_id=task.pk,
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _apply_task_estimate(beat: _Beat, ctx: ReplayContext) -> None:
|
|
553
|
+
task = _resolve_task(ctx, beat.target)
|
|
554
|
+
est = beat.data.get("estimate")
|
|
555
|
+
if task is None or not est:
|
|
556
|
+
return
|
|
557
|
+
task.optimistic_duration = est["optimistic"]
|
|
558
|
+
task.most_likely_duration = est["most_likely"]
|
|
559
|
+
task.pessimistic_duration = est["pessimistic"]
|
|
560
|
+
task.estimate_status = EstimateStatus.ACCEPTED
|
|
561
|
+
_save(
|
|
562
|
+
task,
|
|
563
|
+
beat.when,
|
|
564
|
+
beat.actor,
|
|
565
|
+
["optimistic_duration", "most_likely_duration", "pessimistic_duration", "estimate_status"],
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _apply_task_points(beat: _Beat, ctx: ReplayContext) -> None:
|
|
570
|
+
task = _resolve_task(ctx, beat.target)
|
|
571
|
+
if task is None:
|
|
572
|
+
return
|
|
573
|
+
fields = []
|
|
574
|
+
if "points" in beat.data:
|
|
575
|
+
task.story_points = beat.data["points"]
|
|
576
|
+
fields.append("story_points")
|
|
577
|
+
if "remaining_points" in beat.data:
|
|
578
|
+
task.remaining_points = beat.data["remaining_points"]
|
|
579
|
+
fields.append("remaining_points")
|
|
580
|
+
if fields:
|
|
581
|
+
_save(task, beat.when, beat.actor, fields)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _apply_task_ac_met(beat: _Beat, ctx: ReplayContext) -> None:
|
|
585
|
+
"""Tick one acceptance criterion (v2.1 ``criterion``), else mark the story ready.
|
|
586
|
+
|
|
587
|
+
With ``criterion`` the beat records the review trail on that row —
|
|
588
|
+
``met_by``/``met_at`` dated to the beat — and leaves Definition of Ready alone:
|
|
589
|
+
ticking a criterion during sprint review is not a readiness decision. Without
|
|
590
|
+
it the beat keeps its v2.0 meaning.
|
|
591
|
+
"""
|
|
592
|
+
task = _resolve_task(ctx, beat.target)
|
|
593
|
+
if task is None:
|
|
594
|
+
return
|
|
595
|
+
index = beat.data.get("criterion")
|
|
596
|
+
if index is not None:
|
|
597
|
+
_, _, ref = beat.target.partition(":")
|
|
598
|
+
project_slug, _, wbs = ref.partition(":")
|
|
599
|
+
rows = ctx.criteria.get((project_slug, wbs), [])
|
|
600
|
+
if index < len(rows):
|
|
601
|
+
row = rows[index]
|
|
602
|
+
row.met = True
|
|
603
|
+
row.met_by = beat.actor
|
|
604
|
+
row.met_at = beat.when
|
|
605
|
+
row.save(update_fields=["met", "met_by", "met_at"])
|
|
606
|
+
return
|
|
607
|
+
task.dor = "ready"
|
|
608
|
+
_save(task, beat.when, beat.actor, ["dor"])
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _apply_task_block(beat: _Beat, ctx: ReplayContext) -> None:
|
|
612
|
+
"""Raise the explicit blocker flag, dated to the beat (#3094).
|
|
613
|
+
|
|
614
|
+
``blocked_reason`` is the flag-of-record. ``Task.save()`` stamps
|
|
615
|
+
``blocked_since`` with ``timezone.now()`` on the empty -> non-empty
|
|
616
|
+
transition, which on a backdated timeline would render every blocker as
|
|
617
|
+
"0d blocked" — and age is the entire triage signal. So the stamp is
|
|
618
|
+
overwritten with the beat time through a queryset update, the same way
|
|
619
|
+
``_apply_scope_inject`` corrects its ``auto_now_add`` column.
|
|
620
|
+
"""
|
|
621
|
+
task = _resolve_task(ctx, beat.target)
|
|
622
|
+
reason = beat.data.get("body")
|
|
623
|
+
if task is None or not reason:
|
|
624
|
+
return
|
|
625
|
+
task.blocked_reason = reason
|
|
626
|
+
task.blocker_type = beat.data.get("blocker_type", "")
|
|
627
|
+
task.blocked_by = beat.actor
|
|
628
|
+
fields = ["blocked_reason", "blocker_type", "blocked_by"]
|
|
629
|
+
blocking_ref = beat.data.get("blocking_task")
|
|
630
|
+
if blocking_ref:
|
|
631
|
+
blocking = _resolve_task(ctx, f"task:{blocking_ref}")
|
|
632
|
+
if blocking is not None and blocking.pk != task.pk:
|
|
633
|
+
task.blocking_task = blocking
|
|
634
|
+
fields.append("blocking_task")
|
|
635
|
+
_save(task, beat.when, beat.actor, fields)
|
|
636
|
+
Task.objects.filter(pk=task.pk).update(blocked_since=beat.when)
|
|
637
|
+
task.blocked_since = beat.when
|
|
638
|
+
_notify_task_blocked(task, beat)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _notify_task_blocked(task: Task, beat: _Beat) -> None:
|
|
642
|
+
"""``task.blocked`` to the assignee + project leads (#855/#476/ADR-0124),
|
|
643
|
+
reusing the real recipient resolver and renderer so the content matches the
|
|
644
|
+
live path exactly (``views._emit_blocked_notification``).
|
|
645
|
+
|
|
646
|
+
The rendered body's age line reads "just now" for a freshly-flagged task —
|
|
647
|
+
it always does here too, since this fires in the same beat that sets
|
|
648
|
+
``blocked_since``.
|
|
649
|
+
"""
|
|
650
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
651
|
+
from trueppm_api.apps.notifications.services import create_event_notifications
|
|
652
|
+
from trueppm_api.apps.projects.blocker_services import (
|
|
653
|
+
render_blocker_notification,
|
|
654
|
+
resolve_impediment_recipients,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
recipients = resolve_impediment_recipients(task)
|
|
658
|
+
recipients.discard(getattr(beat.actor, "pk", None))
|
|
659
|
+
if not recipients:
|
|
660
|
+
return
|
|
661
|
+
subject, body = render_blocker_notification(task)
|
|
662
|
+
recipient_ids = list(recipients)
|
|
663
|
+
_dispatch_and_backdate(
|
|
664
|
+
beat.when,
|
|
665
|
+
event_type=NotificationEventType.TASK_BLOCKED.value,
|
|
666
|
+
project_id=task.project_id,
|
|
667
|
+
recipient_ids=recipient_ids,
|
|
668
|
+
dispatch=lambda: create_event_notifications(
|
|
669
|
+
event_type=NotificationEventType.TASK_BLOCKED.value,
|
|
670
|
+
recipient_ids=recipient_ids,
|
|
671
|
+
subject=subject,
|
|
672
|
+
body=body,
|
|
673
|
+
project_id=task.project_id,
|
|
674
|
+
task_id=task.pk,
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _apply_task_unblock(beat: _Beat, ctx: ReplayContext) -> None:
|
|
680
|
+
"""Clear the blocker. ``Task.save()`` owns the cascade — emptying
|
|
681
|
+
``blocked_reason`` also nulls ``blocked_since``, ``blocker_type``,
|
|
682
|
+
``blocking_task`` and ``blocked_by`` — so this only empties the flag and
|
|
683
|
+
lets the model do the rest, keeping one definition of "unblocked"."""
|
|
684
|
+
task = _resolve_task(ctx, beat.target)
|
|
685
|
+
if task is None or not (task.blocked_reason or "").strip():
|
|
686
|
+
return
|
|
687
|
+
task.blocked_reason = ""
|
|
688
|
+
_save(task, beat.when, beat.actor, ["blocked_reason"])
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _apply_risk_note(beat: _Beat, ctx: ReplayContext) -> None:
|
|
692
|
+
"""Append a RiskComment, so a risk.status flip carries its reason (#3094).
|
|
693
|
+
|
|
694
|
+
Without this a risk walks OPEN -> MITIGATING -> RESOLVED with no artifact of
|
|
695
|
+
the work: the register records *that* it was mitigated and never *how*.
|
|
696
|
+
``created_at`` is ``auto_now_add``, so it is backdated after insert.
|
|
697
|
+
"""
|
|
698
|
+
_, _, slug = beat.target.partition(":")
|
|
699
|
+
risk = ctx.risks.get(slug)
|
|
700
|
+
body = beat.data.get("body")
|
|
701
|
+
if risk is None or not body:
|
|
702
|
+
return
|
|
703
|
+
comment = RiskComment.objects.create(risk=risk, author=beat.actor, message=body)
|
|
704
|
+
RiskComment.objects.filter(pk=comment.pk).update(created_at=beat.when)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _apply_task_comment(beat: _Beat, ctx: ReplayContext) -> None:
|
|
708
|
+
task = _resolve_task(ctx, beat.target)
|
|
709
|
+
body = beat.data.get("body")
|
|
710
|
+
if task is None or not body:
|
|
711
|
+
return
|
|
712
|
+
# v2.1 threading (#3493). Validation holds a reply to a top-level comment on
|
|
713
|
+
# the same task; the task check is repeated because a parent on another task
|
|
714
|
+
# would render the reply under a thread it is not part of.
|
|
715
|
+
parent = ctx.comments.get(beat.data["reply_to"]) if beat.data.get("reply_to") else None
|
|
716
|
+
if parent is not None and (parent.task_id != task.pk or parent.parent_id is not None):
|
|
717
|
+
parent = None
|
|
718
|
+
comment = TaskComment.objects.create(task=task, author=beat.actor, body=body, parent=parent)
|
|
719
|
+
# created_at is auto_now_add (stamped now() on insert); backdate it.
|
|
720
|
+
TaskComment.objects.filter(pk=comment.pk).update(created_at=beat.when)
|
|
721
|
+
if beat.data.get("slug"):
|
|
722
|
+
ctx.comments[beat.data["slug"]] = comment
|
|
723
|
+
# A reply is still a comment on the task, and the live product's own
|
|
724
|
+
# _notify_assignee_of_comment applies uniformly regardless of parent — so
|
|
725
|
+
# this fires for a reply beat exactly as it does for a top-level one (#3489).
|
|
726
|
+
parsed = _fan_out_mentions(comment, task, beat)
|
|
727
|
+
_notify_comment_on_assignee(task, comment, beat, parsed)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _fan_out_mentions(comment: TaskComment, task: Task, beat: _Beat) -> list[ParsedMention]:
|
|
731
|
+
"""Create Mention + Notification rows for an @mention in a seeded comment.
|
|
732
|
+
|
|
733
|
+
Only the live view path parsed mentions, so a seeded ``@mei`` was plain text:
|
|
734
|
+
it *read* like a mention and produced no Mention row and no notification, and
|
|
735
|
+
a persona signing in landed on an empty notification list. That is the shape
|
|
736
|
+
of defect this seed audit keeps finding — a surface that looks populated and
|
|
737
|
+
is not.
|
|
738
|
+
|
|
739
|
+
Resolution filters user mentions to current **project members**, which is why
|
|
740
|
+
this only became possible once seeded accounts held ProjectMemberships
|
|
741
|
+
(#3092). ``now=beat.when`` keeps the notification contemporaneous with the
|
|
742
|
+
comment rather than stamping import day; a mention of a non-member resolves
|
|
743
|
+
to ``skipped_users`` and is silently dropped, exactly as the live path
|
|
744
|
+
reports it.
|
|
745
|
+
|
|
746
|
+
Both ``Mention.created_at`` and ``Notification.created_at`` are
|
|
747
|
+
``auto_now_add`` — always real wall-clock time — so the rows this call
|
|
748
|
+
writes are backdated to the beat afterward, scoped to this exact comment's
|
|
749
|
+
(freshly-created, unique) pk so a concurrent import elsewhere can never be
|
|
750
|
+
touched (#3489).
|
|
751
|
+
|
|
752
|
+
Returns the parsed mentions so the caller can de-dup the sibling
|
|
753
|
+
``comment_on_my_task`` notification against an assignee who was also
|
|
754
|
+
directly @mentioned.
|
|
755
|
+
"""
|
|
756
|
+
from trueppm_api.apps.notifications.models import Mention, Notification
|
|
757
|
+
from trueppm_api.apps.notifications.services import (
|
|
758
|
+
create_mention_notifications,
|
|
759
|
+
parse_mentions,
|
|
760
|
+
resolve_parsed_mentions,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
parsed = parse_mentions(comment.body)
|
|
764
|
+
if not parsed:
|
|
765
|
+
return []
|
|
766
|
+
resolved = resolve_parsed_mentions(parsed, task.project_id)
|
|
767
|
+
if not resolved.user_targets and not resolved.group_targets:
|
|
768
|
+
return list(parsed)
|
|
769
|
+
t0 = timezone.now()
|
|
770
|
+
create_mention_notifications(
|
|
771
|
+
task_comment=comment,
|
|
772
|
+
mentioner=beat.actor,
|
|
773
|
+
parsed_result=resolved,
|
|
774
|
+
project_id=task.project_id,
|
|
775
|
+
now=beat.when,
|
|
776
|
+
)
|
|
777
|
+
Mention.objects.filter(task_comment=comment, created_at__gte=t0).update(created_at=beat.when)
|
|
778
|
+
Notification.objects.filter(mention__task_comment=comment, created_at__gte=t0).update(
|
|
779
|
+
created_at=beat.when
|
|
780
|
+
)
|
|
781
|
+
return parsed
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _notify_comment_on_assignee(
|
|
785
|
+
task: Task, comment: TaskComment, beat: _Beat, parsed: list[ParsedMention]
|
|
786
|
+
) -> None:
|
|
787
|
+
"""``comment_on_my_task`` to the task's assignee (#639), mirroring
|
|
788
|
+
``views.TaskCommentViewSet._notify_assignee_of_comment``.
|
|
789
|
+
|
|
790
|
+
Skipped when there is no assignee, the assignee wrote the comment, or the
|
|
791
|
+
assignee was already @mentioned in it — the mention path already notified
|
|
792
|
+
them, and this de-dups so one comment never pings the same person twice.
|
|
793
|
+
"""
|
|
794
|
+
assignee = task.assignee
|
|
795
|
+
if assignee is None or assignee.pk == getattr(beat.actor, "pk", None):
|
|
796
|
+
return
|
|
797
|
+
mentioned_usernames = {p.value for p in parsed if p.kind == "user"}
|
|
798
|
+
if assignee.username in mentioned_usernames:
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
802
|
+
from trueppm_api.apps.notifications.services import create_event_notifications
|
|
803
|
+
|
|
804
|
+
actor = beat.actor
|
|
805
|
+
full_name = getattr(actor, "get_full_name", lambda: "")()
|
|
806
|
+
author_name = full_name or getattr(actor, "username", "") or "Someone"
|
|
807
|
+
subject = f"New comment on {task.name}"
|
|
808
|
+
body = f'{author_name} commented on your task "{task.name}" in TruePPM.'
|
|
809
|
+
_dispatch_and_backdate(
|
|
810
|
+
beat.when,
|
|
811
|
+
event_type=NotificationEventType.COMMENT_ON_MY_TASK.value,
|
|
812
|
+
project_id=task.project_id,
|
|
813
|
+
recipient_ids=[assignee.pk],
|
|
814
|
+
dispatch=lambda: create_event_notifications(
|
|
815
|
+
event_type=NotificationEventType.COMMENT_ON_MY_TASK.value,
|
|
816
|
+
recipient_ids=[assignee.pk],
|
|
817
|
+
subject=subject,
|
|
818
|
+
body=body,
|
|
819
|
+
project_id=task.project_id,
|
|
820
|
+
task_id=task.pk,
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _apply_sprint_activate(beat: _Beat, ctx: ReplayContext) -> None:
|
|
826
|
+
sprint = _resolve_sprint(ctx, beat.target)
|
|
827
|
+
if sprint is None:
|
|
828
|
+
return
|
|
829
|
+
sprint.state = SprintState.ACTIVE
|
|
830
|
+
sprint.activated_at = beat.when
|
|
831
|
+
# Snapshot the commitment at activation from the seed's authored end state.
|
|
832
|
+
key = _sprint_key(ctx, sprint)
|
|
833
|
+
committed = ctx.final_sprint.get(key, {}).get("committed_points")
|
|
834
|
+
if committed is not None:
|
|
835
|
+
sprint.committed_points = committed
|
|
836
|
+
_save(sprint, beat.when, beat.actor, ["state", "activated_at", "committed_points"])
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _apply_sprint_close(beat: _Beat, ctx: ReplayContext) -> None:
|
|
840
|
+
"""Route the close through the real close contract (ADR-0176, #3488).
|
|
841
|
+
|
|
842
|
+
The prior version set four columns directly and stopped, so every closed
|
|
843
|
+
sample sprint carried zero ``SprintTaskOutcome`` rows and produced no
|
|
844
|
+
``VelocitySuggestion`` — the sprint-review "what didn't finish" panel, the
|
|
845
|
+
carry-over history, and the velocity-calibration prompt all read from
|
|
846
|
+
exactly those rows, so a fresh load of every sample reviewed as empty.
|
|
847
|
+
This now calls the same snapshot/outcome/velocity functions the live
|
|
848
|
+
``close_sprint`` drain task calls, in the same order, so a replayed close
|
|
849
|
+
produces the audit trail a real one would.
|
|
850
|
+
|
|
851
|
+
Narrowed from the live path on purpose, following ``is_seed_replay_active()``'s
|
|
852
|
+
own contract (the burndown receiver in ``receivers.py`` is the established
|
|
853
|
+
precedent): the board broadcast, the ``sprint.closed`` webhook, the
|
|
854
|
+
carry-over assignee notification, and the milestone reforecast digest are
|
|
855
|
+
all "the live side effect a real edit would trigger" and are skipped
|
|
856
|
+
outright — none of them write a row the review panel, carry-over history,
|
|
857
|
+
or velocity flow reads, and firing them here would spam a workspace
|
|
858
|
+
nobody is watching during import. ``apply_pending_disposition`` is
|
|
859
|
+
likewise skipped: its "carry" branch calls ``record_sprint_scope_change``,
|
|
860
|
+
which fires the un-suppressed ``sprint_scope_changed`` notify signal — the
|
|
861
|
+
same reason ``_apply_scope_inject`` above writes its ``SprintScopeChange``
|
|
862
|
+
row directly instead of going through that path. ``was_pending`` on the
|
|
863
|
+
outcome row is unaffected: it is read by ``snapshot_sprint_task_outcomes``
|
|
864
|
+
below, before any disposition would run, exactly as the live close orders
|
|
865
|
+
it.
|
|
866
|
+
|
|
867
|
+
Every write here — the sprint's own state/closed_at/goal_outcome/
|
|
868
|
+
completed_*, and any task the carry-over moves — goes through this
|
|
869
|
+
module's ``_save()`` rather than a bare ``instance.save()``, so its history
|
|
870
|
+
row is backdated to the beat like every other write in this file. Calling
|
|
871
|
+
``services.apply_carry_over`` directly instead of ``_replay_carry_over``
|
|
872
|
+
below was the first attempt: it produced the right FK moves but dated the
|
|
873
|
+
*moved task's own* history row to import time, which
|
|
874
|
+
``test_authored_percent_restore_is_backdated_not_import_time`` catches
|
|
875
|
+
immediately — the fixture's own sprint closes with in-flight tasks still
|
|
876
|
+
on it. ``_replay_carry_over`` re-applies the identical two classification
|
|
877
|
+
tuples the real service uses (imported, not copied by value, so a future
|
|
878
|
+
change to which statuses carry stays in sync) through ``_save()`` instead.
|
|
879
|
+
"""
|
|
880
|
+
sprint = _resolve_sprint(ctx, beat.target)
|
|
881
|
+
if sprint is None:
|
|
882
|
+
return
|
|
883
|
+
|
|
884
|
+
from trueppm_api.apps.projects.services import (
|
|
885
|
+
snapshot_completed_metrics,
|
|
886
|
+
snapshot_sprint_task_outcomes,
|
|
887
|
+
)
|
|
888
|
+
from trueppm_api.apps.scheduling.services import compute_velocity_suggestions
|
|
889
|
+
|
|
890
|
+
# ADR-0176 §2 / #3488: derive completed_*/the default goal_outcome from
|
|
891
|
+
# current task state before anything else moves, exactly like the live
|
|
892
|
+
# close. A synthesized close (no authored `sprint.close` beat) has no
|
|
893
|
+
# `ctx.final_sprint` goal_outcome — the importer never populates one for a
|
|
894
|
+
# synthesized beat (see `_synthesize_sprints`) — so without this every
|
|
895
|
+
# such sprint closed with `goal_outcome=None`; this derives a real
|
|
896
|
+
# MET/PARTIAL/MISSED verdict from committed vs. completed points instead.
|
|
897
|
+
snapshot_completed_metrics(sprint)
|
|
898
|
+
if beat.data.get("goal_outcome"):
|
|
899
|
+
# An authored value is the seed author's editorial call (e.g. "hit the
|
|
900
|
+
# points but missed the actual goal") and overrides the points-ratio
|
|
901
|
+
# default — mirroring the SCHEDULER+ override the live product allows
|
|
902
|
+
# after close.
|
|
903
|
+
sprint.goal_outcome = beat.data["goal_outcome"]
|
|
904
|
+
sprint.state = SprintState.COMPLETED
|
|
905
|
+
sprint.closed_at = beat.when
|
|
906
|
+
_save(
|
|
907
|
+
sprint,
|
|
908
|
+
beat.when,
|
|
909
|
+
beat.actor,
|
|
910
|
+
["state", "closed_at", "goal_outcome", "completed_points", "completed_task_count"],
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# ADR-0176 §2: snapshot task membership-at-close BEFORE the carry-over
|
|
914
|
+
# mutates Task.sprint — same order as the live close — otherwise the
|
|
915
|
+
# "what didn't ship" set would already be gone by the time this reads it.
|
|
916
|
+
carry_over_to = _implied_carry_over_target(ctx, sprint)
|
|
917
|
+
snapshot_sprint_task_outcomes(sprint, carry_over_to=carry_over_to)
|
|
918
|
+
carried_task_ids = _replay_carry_over(sprint, carry_over_to, beat.when, beat.actor)
|
|
919
|
+
_notify_carry_over(sprint, carry_over_to, carried_task_ids, beat)
|
|
920
|
+
|
|
921
|
+
compute_velocity_suggestions(sprint.pk)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _replay_carry_over(sprint: Sprint, carry_over_to: str, when: datetime, actor: Any) -> list[str]:
|
|
925
|
+
"""Mirror ``services.apply_carry_over`` (ADR-0176) with a backdated write.
|
|
926
|
+
|
|
927
|
+
The real close's ``task.save()`` has no beat to backdate to — correct
|
|
928
|
+
there, timestamped wrong here (#3488): a replayed carry-over must date the
|
|
929
|
+
moved task's history row to the beat, not to import time, like every
|
|
930
|
+
other write in this file. The move rules are the same two classification
|
|
931
|
+
tuples the real service reads (imported, not copied by value, so this
|
|
932
|
+
stays in sync if the close contract's carry-over policy changes) — only
|
|
933
|
+
*how* the result is saved differs.
|
|
934
|
+
|
|
935
|
+
Returns the moved tasks' ids so the caller can fan out the
|
|
936
|
+
``task.moved_sprint`` notification (#3489) against exactly the set this
|
|
937
|
+
call actually carried.
|
|
938
|
+
"""
|
|
939
|
+
from trueppm_api.apps.projects.services import (
|
|
940
|
+
_CARRY_OVER_INCOMPLETE_STATUSES,
|
|
941
|
+
_DECOMMITTED_ON_CARRY_OVER_TO_BACKLOG,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
if carry_over_to == "none":
|
|
945
|
+
return []
|
|
946
|
+
|
|
947
|
+
incomplete = Task.objects.filter(
|
|
948
|
+
sprint_id=sprint.pk, status__in=_CARRY_OVER_INCOMPLETE_STATUSES, is_deleted=False
|
|
949
|
+
)
|
|
950
|
+
carried_ids: list[str] = []
|
|
951
|
+
for task in incomplete:
|
|
952
|
+
fields = ["sprint"]
|
|
953
|
+
if carry_over_to == "backlog":
|
|
954
|
+
task.sprint = None
|
|
955
|
+
if task.status in _DECOMMITTED_ON_CARRY_OVER_TO_BACKLOG:
|
|
956
|
+
task.status = TaskStatus.BACKLOG
|
|
957
|
+
fields.append("status")
|
|
958
|
+
else:
|
|
959
|
+
task.sprint_id = carry_over_to
|
|
960
|
+
_save(task, when, actor, fields)
|
|
961
|
+
carried_ids.append(str(task.pk))
|
|
962
|
+
return carried_ids
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _notify_carry_over(
|
|
966
|
+
sprint: Sprint, carry_over_to: str, carried_task_ids: list[str], beat: _Beat
|
|
967
|
+
) -> None:
|
|
968
|
+
"""``task.moved_sprint`` to each carried task's assignee (#1470/ADR-0232),
|
|
969
|
+
reusing the real grouping/copy helpers (``services._carried_tasks_by_assignee``
|
|
970
|
+
/ ``_carryover_row``) so a demo persona with several carried tasks gets the
|
|
971
|
+
same one-row-per-assignee summary the live close produces.
|
|
972
|
+
"""
|
|
973
|
+
if not carried_task_ids:
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
977
|
+
from trueppm_api.apps.notifications.services import create_event_notifications_batch
|
|
978
|
+
from trueppm_api.apps.projects.services import (
|
|
979
|
+
_carried_tasks_by_assignee,
|
|
980
|
+
_carry_over_destination_label,
|
|
981
|
+
_carryover_row,
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
actor_pk = getattr(beat.actor, "pk", None)
|
|
985
|
+
by_assignee = _carried_tasks_by_assignee(carried_task_ids, actor_pk)
|
|
986
|
+
if not by_assignee:
|
|
987
|
+
return
|
|
988
|
+
destination = _carry_over_destination_label(carry_over_to)
|
|
989
|
+
rows = [
|
|
990
|
+
_carryover_row(assignee_id, tasks, origin=sprint.name, destination=destination)
|
|
991
|
+
for assignee_id, tasks in by_assignee.items()
|
|
992
|
+
]
|
|
993
|
+
recipient_ids = list(by_assignee.keys())
|
|
994
|
+
_dispatch_and_backdate(
|
|
995
|
+
beat.when,
|
|
996
|
+
event_type=NotificationEventType.TASK_MOVED_SPRINT.value,
|
|
997
|
+
project_id=sprint.project_id,
|
|
998
|
+
recipient_ids=recipient_ids,
|
|
999
|
+
dispatch=lambda: create_event_notifications_batch(
|
|
1000
|
+
event_type=NotificationEventType.TASK_MOVED_SPRINT.value,
|
|
1001
|
+
project_id=sprint.project_id,
|
|
1002
|
+
rows=rows,
|
|
1003
|
+
),
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _apply_scope_inject(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1008
|
+
"""Record a mid-sprint scope injection as the real audit row (VoC/Alex).
|
|
1009
|
+
|
|
1010
|
+
Creates the SprintScopeChange the drawer chip + Enterprise audit read, and
|
|
1011
|
+
flags the task pending — the same row the live ``record_sprint_scope_change``
|
|
1012
|
+
path writes — without firing the notify signal (suppressed during replay).
|
|
1013
|
+
|
|
1014
|
+
The injected sprint is ``task.sprint`` whenever the task still holds one;
|
|
1015
|
+
see :func:`_injected_sprint` for the case a re-imported export creates,
|
|
1016
|
+
where it no longer does.
|
|
1017
|
+
"""
|
|
1018
|
+
task = _resolve_task(ctx, beat.target)
|
|
1019
|
+
if task is None:
|
|
1020
|
+
return
|
|
1021
|
+
sprint = _injected_sprint(task, beat, ctx)
|
|
1022
|
+
if sprint is None:
|
|
1023
|
+
return
|
|
1024
|
+
scope = SprintScopeChange.objects.create(
|
|
1025
|
+
task=task,
|
|
1026
|
+
sprint=sprint,
|
|
1027
|
+
subtask_name=task.name,
|
|
1028
|
+
added_by=beat.actor,
|
|
1029
|
+
goal_impact=bool(beat.data.get("goal_impact", False)),
|
|
1030
|
+
status=ScopeChangeStatus.PENDING,
|
|
1031
|
+
)
|
|
1032
|
+
# added_at is auto_now_add (stamped now() on insert); backdate it to the beat
|
|
1033
|
+
# so the scope-injection audit reads chronologically and — for a still-PENDING
|
|
1034
|
+
# injection — the exporter can reconstruct a deterministic scope_inject event
|
|
1035
|
+
# from this row (the round-trip fixpoint depends on a stable timestamp).
|
|
1036
|
+
SprintScopeChange.objects.filter(pk=scope.pk).update(added_at=beat.when)
|
|
1037
|
+
ctx.open_scope[task.pk] = scope
|
|
1038
|
+
task.sprint_pending = True
|
|
1039
|
+
_save(task, beat.when, beat.actor, ["sprint_pending"])
|
|
1040
|
+
_notify_scope_inject(task, sprint, beat)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _notify_scope_inject(task: Task, sprint: Sprint, beat: _Beat) -> None:
|
|
1044
|
+
"""``sprint.membership_changed`` to the project lead cohort (#1946/ADR-0412),
|
|
1045
|
+
reusing the real recipient/copy helpers (``services._sprint_lead_recipient_ids``
|
|
1046
|
+
/ ``_sprint_change_body``).
|
|
1047
|
+
|
|
1048
|
+
Fires only when the injected sprint is currently ACTIVE, mirroring the live
|
|
1049
|
+
trigger's "a live-commitment change" gate — a scope change into a
|
|
1050
|
+
PLANNED/COMPLETED/CANCELLED sprint carries no accountability signal.
|
|
1051
|
+
"""
|
|
1052
|
+
if sprint.state != SprintState.ACTIVE:
|
|
1053
|
+
return
|
|
1054
|
+
|
|
1055
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
1056
|
+
from trueppm_api.apps.notifications.services import create_event_notifications_batch
|
|
1057
|
+
from trueppm_api.apps.projects.services import _sprint_change_body, _sprint_lead_recipient_ids
|
|
1058
|
+
|
|
1059
|
+
actor_pk = getattr(beat.actor, "pk", None)
|
|
1060
|
+
recipient_ids = _sprint_lead_recipient_ids(task.project_id, actor_pk)
|
|
1061
|
+
if not recipient_ids:
|
|
1062
|
+
return
|
|
1063
|
+
actor_name = getattr(beat.actor, "username", "") or "Someone"
|
|
1064
|
+
body = _sprint_change_body(
|
|
1065
|
+
actor_name,
|
|
1066
|
+
task.name,
|
|
1067
|
+
None,
|
|
1068
|
+
{"name": sprint.name},
|
|
1069
|
+
entered_active=True,
|
|
1070
|
+
left_active=False,
|
|
1071
|
+
)
|
|
1072
|
+
rows = [(rid, "Sprint scope changed", body, str(task.pk)) for rid in recipient_ids]
|
|
1073
|
+
_dispatch_and_backdate(
|
|
1074
|
+
beat.when,
|
|
1075
|
+
event_type=NotificationEventType.SPRINT_MEMBERSHIP_CHANGED.value,
|
|
1076
|
+
project_id=task.project_id,
|
|
1077
|
+
recipient_ids=recipient_ids,
|
|
1078
|
+
dispatch=lambda: create_event_notifications_batch(
|
|
1079
|
+
event_type=NotificationEventType.SPRINT_MEMBERSHIP_CHANGED.value,
|
|
1080
|
+
project_id=task.project_id,
|
|
1081
|
+
rows=rows,
|
|
1082
|
+
),
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _injected_sprint(task: Task, beat: _Beat, ctx: ReplayContext) -> Sprint | None:
|
|
1087
|
+
"""The sprint a ``sprint.scope_inject`` beat injects ``task`` into.
|
|
1088
|
+
|
|
1089
|
+
Normally read straight off ``task.sprint``: the documented contract (see
|
|
1090
|
+
``validation._EVENT_TARGET_KIND``) is that the beat targets the task and its
|
|
1091
|
+
sprint is derived, matching ``record_sprint_scope_change(task, sprint)``,
|
|
1092
|
+
where every live call site links the task to the sprint first and records
|
|
1093
|
+
the audit row second. That derivation holds for every hand-authored seed,
|
|
1094
|
+
because a document declares a task's ``sprint`` statically before it can
|
|
1095
|
+
date an injection against it.
|
|
1096
|
+
|
|
1097
|
+
It stops holding for a *re-imported export* (#3488). The exporter writes each
|
|
1098
|
+
task's ``sprint`` from its **current** membership, and a replayed sprint close
|
|
1099
|
+
now carries an unfinished task back to the backlog — so a still-PENDING
|
|
1100
|
+
``SprintScopeChange``, which the exporter reconstructs as a
|
|
1101
|
+
``sprint.scope_inject`` beat, can name a task that by export time holds no
|
|
1102
|
+
sprint at all. Born sprintless, the beat wrote no row, the re-export dropped
|
|
1103
|
+
the event, and the #616 byte-identical round trip failed.
|
|
1104
|
+
|
|
1105
|
+
The fallback is a derivation, not a guess: an injection is by definition a
|
|
1106
|
+
link into the sprint that is *running at that moment* (ADR-0102 §4), so the
|
|
1107
|
+
beat's own instant identifies it — the one sprint in the task's project that
|
|
1108
|
+
replay has already activated, has not yet closed, and whose dates contain the
|
|
1109
|
+
beat. Ambiguity is refused rather than resolved: nothing in the schema stops a
|
|
1110
|
+
seed authoring two overlapping active sprints in one project, and picking one
|
|
1111
|
+
of them arbitrarily would file the audit row against the wrong sprint.
|
|
1112
|
+
"""
|
|
1113
|
+
if task.sprint_id is not None:
|
|
1114
|
+
return task.sprint
|
|
1115
|
+
_, _, ref = beat.target.partition(":")
|
|
1116
|
+
project_slug, _, _ = ref.partition(":")
|
|
1117
|
+
day = beat.when.date()
|
|
1118
|
+
candidates = [
|
|
1119
|
+
sprint
|
|
1120
|
+
for key, sprint in ctx.sprints.items()
|
|
1121
|
+
if key[0] == project_slug
|
|
1122
|
+
and sprint.state == SprintState.ACTIVE
|
|
1123
|
+
and sprint.start_date <= day <= sprint.finish_date
|
|
1124
|
+
]
|
|
1125
|
+
return candidates[0] if len(candidates) == 1 else None
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _apply_scope_resolve(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1129
|
+
task = _resolve_task(ctx, beat.target)
|
|
1130
|
+
if task is None:
|
|
1131
|
+
return
|
|
1132
|
+
scope = ctx.open_scope.pop(task.pk, None)
|
|
1133
|
+
accepted = (beat.data.get("to") or "ACCEPTED").upper() != "REJECTED"
|
|
1134
|
+
if scope is not None:
|
|
1135
|
+
scope.status = ScopeChangeStatus.ACCEPTED if accepted else ScopeChangeStatus.REJECTED
|
|
1136
|
+
scope.save(update_fields=["status"])
|
|
1137
|
+
task.sprint_pending = False
|
|
1138
|
+
fields = ["sprint_pending"]
|
|
1139
|
+
if not accepted:
|
|
1140
|
+
task.sprint = None
|
|
1141
|
+
fields.append("sprint")
|
|
1142
|
+
_save(task, beat.when, beat.actor, fields)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _apply_risk_status(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1146
|
+
_, _, slug = beat.target.partition(":")
|
|
1147
|
+
risk = ctx.risks.get(slug)
|
|
1148
|
+
if risk is None:
|
|
1149
|
+
return
|
|
1150
|
+
new_status = beat.data.get("to")
|
|
1151
|
+
if not new_status or new_status == risk.status:
|
|
1152
|
+
return
|
|
1153
|
+
risk.status = new_status
|
|
1154
|
+
_save(risk, beat.when, beat.actor, ["status"])
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _apply_baseline_capture(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1158
|
+
"""Capture a baseline of the project's current task dates at the beat time.
|
|
1159
|
+
|
|
1160
|
+
``is_active`` (default False, #3495) collapses the real app's two-step
|
|
1161
|
+
capture-then-activate flow (``BaselineViewSet.perform_create`` +
|
|
1162
|
+
``BaselineActivateView``) into one beat: when true, any baseline already
|
|
1163
|
+
active for the project is deactivated first — the same swap
|
|
1164
|
+
``BaselineActivateView`` performs — so an authored rebaseline can supersede
|
|
1165
|
+
the one it replaces in a single timeline entry instead of leaving the
|
|
1166
|
+
project with two inactive baselines or an ``IntegrityError`` from
|
|
1167
|
+
``unique_active_baseline_per_project``.
|
|
1168
|
+
"""
|
|
1169
|
+
_, _, project_slug = beat.target.partition(":")
|
|
1170
|
+
project = ctx.projects.get(project_slug)
|
|
1171
|
+
if project is None:
|
|
1172
|
+
return
|
|
1173
|
+
is_active = bool(beat.data.get("is_active", False))
|
|
1174
|
+
if is_active:
|
|
1175
|
+
Baseline.objects.filter(project=project, is_active=True).update(is_active=False)
|
|
1176
|
+
baseline = Baseline.objects.create(
|
|
1177
|
+
project=project,
|
|
1178
|
+
name=beat.data.get("body") or f"Baseline {beat.when.date().isoformat()}",
|
|
1179
|
+
is_active=is_active,
|
|
1180
|
+
)
|
|
1181
|
+
Baseline.objects.filter(pk=baseline.pk).update(created_at=beat.when)
|
|
1182
|
+
rows = [
|
|
1183
|
+
BaselineTask(
|
|
1184
|
+
baseline=baseline,
|
|
1185
|
+
task_id=task.pk,
|
|
1186
|
+
task_name=task.name,
|
|
1187
|
+
start=task.planned_start,
|
|
1188
|
+
finish=task.planned_start + timedelta(days=task.duration)
|
|
1189
|
+
if task.planned_start
|
|
1190
|
+
else None,
|
|
1191
|
+
duration=task.duration,
|
|
1192
|
+
story_points=task.story_points,
|
|
1193
|
+
)
|
|
1194
|
+
for (slug, _), task in ctx.tasks.items()
|
|
1195
|
+
if slug == project_slug
|
|
1196
|
+
]
|
|
1197
|
+
BaselineTask.objects.bulk_create(rows)
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _apply_retro_action(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1201
|
+
"""Create a RetroActionItem on the target sprint's retro (ADR-0114 §7).
|
|
1202
|
+
|
|
1203
|
+
The parent ``SprintRetro`` is created lazily the first time an action item
|
|
1204
|
+
attaches to the sprint (mirroring the live ``_get_or_create_retro`` path),
|
|
1205
|
+
so a seed authors retro outcomes without a separate "open retro" event. An
|
|
1206
|
+
optional ``notes`` on the beat sets the retro's own meeting-summary text
|
|
1207
|
+
(distinct from the action item's ``body``) — a seed typically carries it on
|
|
1208
|
+
the last action-item beat for that sprint, once the retro has something to
|
|
1209
|
+
summarize (#3497).
|
|
1210
|
+
"""
|
|
1211
|
+
sprint = _resolve_sprint(ctx, beat.target)
|
|
1212
|
+
body = beat.data.get("body")
|
|
1213
|
+
if sprint is None or not body:
|
|
1214
|
+
return
|
|
1215
|
+
retro, created = SprintRetro.objects.get_or_create(
|
|
1216
|
+
sprint=sprint, defaults={"created_by": beat.actor}
|
|
1217
|
+
)
|
|
1218
|
+
if created:
|
|
1219
|
+
# created_at is auto_now_add; backdate the retro to its first action so
|
|
1220
|
+
# the demo's retro is dated to the ceremony, not import time.
|
|
1221
|
+
SprintRetro.objects.filter(pk=retro.pk).update(created_at=beat.when)
|
|
1222
|
+
notes = beat.data.get("notes")
|
|
1223
|
+
if notes:
|
|
1224
|
+
retro.notes = notes
|
|
1225
|
+
_save(retro, beat.when, beat.actor, ["notes"])
|
|
1226
|
+
assignee = ctx.users.get(beat.data["assignee"]) if beat.data.get("assignee") else None
|
|
1227
|
+
item = RetroActionItem.objects.create(
|
|
1228
|
+
retro=retro,
|
|
1229
|
+
text=body,
|
|
1230
|
+
assignee=assignee,
|
|
1231
|
+
story_points=beat.data.get("points"),
|
|
1232
|
+
)
|
|
1233
|
+
RetroActionItem.objects.filter(pk=item.pk).update(created_at=beat.when)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _apply_retro_promote(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1237
|
+
"""Promote a retro action item (matched by ``body``) to a backlog task.
|
|
1238
|
+
|
|
1239
|
+
Mirrors ``promote_retro_action_item`` but writes directly (no on_commit
|
|
1240
|
+
broadcast / recalc enqueue) because replay is side-effect-suppressed — the
|
|
1241
|
+
importer enqueues a single per-project recalc after commit. The resulting
|
|
1242
|
+
Task is a project-backlog item (``status=BACKLOG``, ``sprint=None``) exactly
|
|
1243
|
+
as the live promote path produces, so the demo shows the retro→task loop
|
|
1244
|
+
closed with a real ``T-XXX`` link.
|
|
1245
|
+
"""
|
|
1246
|
+
sprint = _resolve_sprint(ctx, beat.target)
|
|
1247
|
+
body = beat.data.get("body")
|
|
1248
|
+
if sprint is None or not body:
|
|
1249
|
+
return
|
|
1250
|
+
retro = SprintRetro.objects.filter(sprint=sprint).first()
|
|
1251
|
+
if retro is None:
|
|
1252
|
+
return
|
|
1253
|
+
item = (
|
|
1254
|
+
RetroActionItem.objects.filter(
|
|
1255
|
+
retro=retro, text=body, promoted_task_id__isnull=True, is_deleted=False
|
|
1256
|
+
)
|
|
1257
|
+
.order_by("created_at")
|
|
1258
|
+
.first()
|
|
1259
|
+
)
|
|
1260
|
+
if item is None:
|
|
1261
|
+
return # nothing (left) to promote for this text
|
|
1262
|
+
short_id = sprint.short_id or str(sprint.pk)[:8]
|
|
1263
|
+
task = Task(
|
|
1264
|
+
project=sprint.project,
|
|
1265
|
+
name=body[:255],
|
|
1266
|
+
duration=1,
|
|
1267
|
+
status=TaskStatus.BACKLOG,
|
|
1268
|
+
sprint=None,
|
|
1269
|
+
assignee=item.assignee,
|
|
1270
|
+
story_points=item.story_points,
|
|
1271
|
+
notes=f'source: "retrospective" (from Sprint {short_id} retro)',
|
|
1272
|
+
)
|
|
1273
|
+
_save_new_backdated(task, beat.when, beat.actor)
|
|
1274
|
+
item.promoted_task_id = task.pk
|
|
1275
|
+
item.save(update_fields=["promoted_task_id"])
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
# --- notifications -------------------------------------------------------------
|
|
1279
|
+
#
|
|
1280
|
+
# #3489: fresh samples produced notifications only from the mention fan-out
|
|
1281
|
+
# above (`_fan_out_mentions`) — every other beat wrote through the ORM and
|
|
1282
|
+
# never touched `notifications.services`, so a persona signing in saw an
|
|
1283
|
+
# empty bell. The handlers above call the small `_notify_*` functions added
|
|
1284
|
+
# alongside them; this section holds the cross-cutting backdating helper plus
|
|
1285
|
+
# the two event types with no single triggering beat (a synthesized
|
|
1286
|
+
# `task.stale` sweep and the sponsor's `program.health_digest`) and the final
|
|
1287
|
+
# read/unread/archived/snoozed realism pass.
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _dispatch_and_backdate(
|
|
1291
|
+
when: datetime,
|
|
1292
|
+
*,
|
|
1293
|
+
event_type: str,
|
|
1294
|
+
project_id: Any,
|
|
1295
|
+
recipient_ids: Sequence[Any],
|
|
1296
|
+
dispatch: Callable[[], Any],
|
|
1297
|
+
) -> None:
|
|
1298
|
+
"""Run a ``notifications.services`` dispatch, then backdate the rows it wrote.
|
|
1299
|
+
|
|
1300
|
+
Every dispatcher in that module stamps ``Notification.created_at`` via
|
|
1301
|
+
``auto_now_add`` — always real wall-clock time, correct for the live
|
|
1302
|
+
product and wrong for a replayed beat. This captures the instant right
|
|
1303
|
+
before dispatch and rewrites every matching row to ``when`` afterward.
|
|
1304
|
+
|
|
1305
|
+
The filter is scoped to ``(recipient, project, event_type, created_at >= t0)``.
|
|
1306
|
+
``project_id`` is always a project this same import just created (or
|
|
1307
|
+
``None`` for the one account-scoped digest, whose recipient is likewise a
|
|
1308
|
+
user this import just created), so the window can never reach a row a
|
|
1309
|
+
concurrent, unrelated import or request wrote in the same instant.
|
|
1310
|
+
"""
|
|
1311
|
+
ids = [rid for rid in recipient_ids if rid is not None]
|
|
1312
|
+
if not ids:
|
|
1313
|
+
return
|
|
1314
|
+
|
|
1315
|
+
from trueppm_api.apps.notifications.models import Notification
|
|
1316
|
+
|
|
1317
|
+
t0 = timezone.now()
|
|
1318
|
+
dispatch()
|
|
1319
|
+
Notification.objects.filter(
|
|
1320
|
+
recipient_id__in=ids,
|
|
1321
|
+
project_id=project_id,
|
|
1322
|
+
event_type=event_type,
|
|
1323
|
+
created_at__gte=t0,
|
|
1324
|
+
).update(created_at=when)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _seed_notification_preferences(payload: dict[str, Any], ctx: ReplayContext) -> None:
|
|
1328
|
+
"""Backfill every persona's default ``NotificationPreference`` rows, plus one
|
|
1329
|
+
``ProjectNotificationPreference`` with a non-default quiet-hours window for
|
|
1330
|
+
the program's OWNER persona (#3489's "preference pages render seeded rows,
|
|
1331
|
+
not defaults" acceptance bar).
|
|
1332
|
+
"""
|
|
1333
|
+
import datetime as _dt
|
|
1334
|
+
|
|
1335
|
+
from trueppm_api.apps.notifications.models import ProjectNotificationPreference
|
|
1336
|
+
from trueppm_api.apps.notifications.services import get_or_create_default_preferences
|
|
1337
|
+
|
|
1338
|
+
for user in ctx.users.values():
|
|
1339
|
+
if user is not None:
|
|
1340
|
+
get_or_create_default_preferences(user)
|
|
1341
|
+
|
|
1342
|
+
owner_slug = next(
|
|
1343
|
+
(a["slug"] for a in payload.get("accounts", []) if a.get("role") == "OWNER"), None
|
|
1344
|
+
)
|
|
1345
|
+
owner = ctx.users.get(owner_slug) if owner_slug else None
|
|
1346
|
+
project = next(iter(ctx.projects.values()), None)
|
|
1347
|
+
if owner is None or project is None:
|
|
1348
|
+
return
|
|
1349
|
+
ProjectNotificationPreference.objects.update_or_create(
|
|
1350
|
+
project=project,
|
|
1351
|
+
user=owner,
|
|
1352
|
+
defaults={
|
|
1353
|
+
"quiet_hours_enabled": True,
|
|
1354
|
+
"quiet_hours_from": _dt.time(18, 0),
|
|
1355
|
+
"quiet_hours_until": _dt.time(8, 0),
|
|
1356
|
+
},
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _synthesize_stale_notifications(ctx: ReplayContext) -> None:
|
|
1361
|
+
"""Seed-local ``task.stale`` sweep (ADR-0200), scoped to this program's own tasks.
|
|
1362
|
+
|
|
1363
|
+
Deliberately NOT ``notifications.services.create_stale_task_notifications``:
|
|
1364
|
+
that function scans every project in the install against the real wall
|
|
1365
|
+
clock, which would (a) sweep already-imported, unrelated samples and (b)
|
|
1366
|
+
stamp ``created_at`` at real import time rather than a date this replay
|
|
1367
|
+
can own. This mirrors its eligibility rule (every non-``COMPLETE`` status,
|
|
1368
|
+
an assignee set, untouched past the project's ``stale_task_threshold_days``)
|
|
1369
|
+
but dates each notification to when the task actually went stale.
|
|
1370
|
+
"""
|
|
1371
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
1372
|
+
from trueppm_api.apps.notifications.services import (
|
|
1373
|
+
DEFAULT_STALE_TASK_THRESHOLD_DAYS,
|
|
1374
|
+
create_event_notifications_batch,
|
|
1375
|
+
)
|
|
1376
|
+
|
|
1377
|
+
for (project_slug, _wbs), task in ctx.tasks.items():
|
|
1378
|
+
if task.status == TaskStatus.COMPLETE or task.assignee_id is None:
|
|
1379
|
+
continue
|
|
1380
|
+
if task.status_changed_at is None:
|
|
1381
|
+
continue
|
|
1382
|
+
project = ctx.projects[project_slug]
|
|
1383
|
+
threshold = project.stale_task_threshold_days or DEFAULT_STALE_TASK_THRESHOLD_DAYS
|
|
1384
|
+
stale_since = task.status_changed_at.date() + timedelta(days=threshold)
|
|
1385
|
+
if stale_since > ctx.anchor:
|
|
1386
|
+
continue
|
|
1387
|
+
display_name = task.name if len(task.name) <= 200 else task.name[:200].rstrip() + "…"
|
|
1388
|
+
subject = f'"{display_name}" has gone stale'
|
|
1389
|
+
body = (
|
|
1390
|
+
f'Your task "{display_name}" has sat in the same status for more than '
|
|
1391
|
+
f"{threshold} days. If it is still active, move it forward; "
|
|
1392
|
+
f"otherwise update its status so the board reflects reality."
|
|
1393
|
+
)
|
|
1394
|
+
when = _aware_dt(ctx, stale_since, 8, 0)
|
|
1395
|
+
rows = [(task.assignee_id, subject, body, str(task.pk))]
|
|
1396
|
+
_dispatch_and_backdate(
|
|
1397
|
+
when,
|
|
1398
|
+
event_type=NotificationEventType.TASK_STALE.value,
|
|
1399
|
+
project_id=task.project_id,
|
|
1400
|
+
recipient_ids=[task.assignee_id],
|
|
1401
|
+
# functools.partial (not a lambda closing over the loop variables)
|
|
1402
|
+
# so each call is bound to THIS iteration's rows/project, not
|
|
1403
|
+
# whatever the loop variable holds by the time it runs.
|
|
1404
|
+
dispatch=functools.partial(
|
|
1405
|
+
create_event_notifications_batch,
|
|
1406
|
+
event_type=NotificationEventType.TASK_STALE.value,
|
|
1407
|
+
project_id=task.project_id,
|
|
1408
|
+
rows=rows,
|
|
1409
|
+
),
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _synthesize_program_digest(payload: dict[str, Any], ctx: ReplayContext) -> None:
|
|
1414
|
+
"""One ``program.health_digest`` row for the sponsor persona — the seed's
|
|
1415
|
+
VIEWER account, the read-only executive role every sample carries exactly
|
|
1416
|
+
one of — dated to the most recent Sunday evening on/before the anchor,
|
|
1417
|
+
matching the real weekly send slot (ADR-0663).
|
|
1418
|
+
|
|
1419
|
+
``PROGRAM_HEALTH_DIGEST`` defaults OFF on both channels (a weekly send
|
|
1420
|
+
nobody asked for is exactly the noise the default guards against), so the
|
|
1421
|
+
sponsor is opted in explicitly here first — otherwise the dispatch below
|
|
1422
|
+
would silently create nothing.
|
|
1423
|
+
"""
|
|
1424
|
+
accounts = payload.get("accounts", [])
|
|
1425
|
+
sponsor_slug = next((a["slug"] for a in accounts if a.get("role") == "VIEWER"), None)
|
|
1426
|
+
if sponsor_slug is None:
|
|
1427
|
+
return
|
|
1428
|
+
sponsor = ctx.users.get(sponsor_slug)
|
|
1429
|
+
if sponsor is None:
|
|
1430
|
+
return
|
|
1431
|
+
|
|
1432
|
+
from trueppm_api.apps.notifications.digests import build_program_health_digest
|
|
1433
|
+
from trueppm_api.apps.notifications.models import (
|
|
1434
|
+
NotificationChannel,
|
|
1435
|
+
NotificationEventType,
|
|
1436
|
+
NotificationPreference,
|
|
1437
|
+
)
|
|
1438
|
+
from trueppm_api.apps.notifications.services import create_event_notifications
|
|
1439
|
+
|
|
1440
|
+
# `_seed_notification_preferences` already backfilled the sponsor's default
|
|
1441
|
+
# rows earlier in `replay_timeline`; only the digest event's opt-in (below)
|
|
1442
|
+
# needs an explicit override.
|
|
1443
|
+
NotificationPreference.objects.update_or_create(
|
|
1444
|
+
user=sponsor,
|
|
1445
|
+
event_type=NotificationEventType.PROGRAM_HEALTH_DIGEST.value,
|
|
1446
|
+
channel=NotificationChannel.IN_APP.value,
|
|
1447
|
+
defaults={"enabled": True},
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
digest_day = ctx.anchor - timedelta(days=(ctx.anchor.weekday() + 1) % 7)
|
|
1451
|
+
when = _aware_dt(ctx, digest_day, 18, 0)
|
|
1452
|
+
subject, body = build_program_health_digest(sponsor, when)
|
|
1453
|
+
_dispatch_and_backdate(
|
|
1454
|
+
when,
|
|
1455
|
+
event_type=NotificationEventType.PROGRAM_HEALTH_DIGEST.value,
|
|
1456
|
+
project_id=None,
|
|
1457
|
+
recipient_ids=[sponsor.pk],
|
|
1458
|
+
dispatch=lambda: create_event_notifications(
|
|
1459
|
+
event_type=NotificationEventType.PROGRAM_HEALTH_DIGEST.value,
|
|
1460
|
+
recipient_ids=[sponsor.pk],
|
|
1461
|
+
subject=subject,
|
|
1462
|
+
body=body,
|
|
1463
|
+
project_id=None,
|
|
1464
|
+
),
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _apply_notification_realism(ctx: ReplayContext) -> None:
|
|
1469
|
+
"""Age the freshly-emitted inbox into a mix of read / unread / archived /
|
|
1470
|
+
snoozed rows (#3489) — a batch created in one instant and left untouched
|
|
1471
|
+
would all read as equally fresh, which is its own tell.
|
|
1472
|
+
|
|
1473
|
+
Rows backdated more than ~5 days before the anchor are marked read; a
|
|
1474
|
+
deterministic handful of those are archived; one still-unread row is
|
|
1475
|
+
snoozed into the demo's near future. Recent rows are left unread so the
|
|
1476
|
+
bell has something to show. Scoped to this program's own projects (plus,
|
|
1477
|
+
for the one account-scoped digest, this program's own users) so a
|
|
1478
|
+
re-import of a different sample can never be touched.
|
|
1479
|
+
"""
|
|
1480
|
+
from trueppm_api.apps.notifications.models import Notification
|
|
1481
|
+
|
|
1482
|
+
project_ids = [p.pk for p in ctx.projects.values()]
|
|
1483
|
+
user_ids = [u.pk for u in ctx.users.values() if u is not None]
|
|
1484
|
+
qs = Notification.objects.filter(
|
|
1485
|
+
Q(project_id__in=project_ids) | Q(project_id__isnull=True, recipient_id__in=user_ids)
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
read_cutoff = _aware(ctx, ctx.anchor - timedelta(days=5))
|
|
1489
|
+
qs.filter(created_at__lt=read_cutoff, is_read=False).update(
|
|
1490
|
+
is_read=True, read_at=F("created_at") + timedelta(hours=2)
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
archive_ids = list(
|
|
1494
|
+
qs.filter(is_read=True, is_archived=False)
|
|
1495
|
+
.order_by("created_at")
|
|
1496
|
+
.values_list("pk", flat=True)[:3]
|
|
1497
|
+
)
|
|
1498
|
+
if archive_ids:
|
|
1499
|
+
Notification.objects.filter(pk__in=archive_ids).update(is_archived=True)
|
|
1500
|
+
|
|
1501
|
+
snooze_candidate = (
|
|
1502
|
+
qs.filter(is_read=False, is_archived=False, snoozed_until__isnull=True)
|
|
1503
|
+
.order_by("-created_at")
|
|
1504
|
+
.first()
|
|
1505
|
+
)
|
|
1506
|
+
if snooze_candidate is not None:
|
|
1507
|
+
Notification.objects.filter(pk=snooze_candidate.pk).update(
|
|
1508
|
+
snoozed_until=_aware_dt(ctx, ctx.anchor + timedelta(days=2), 9, 0)
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
# --- helpers -----------------------------------------------------------------
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _save_new_backdated(instance: Any, when: datetime, user: Any) -> None:
|
|
1516
|
+
"""Insert a brand-new instance, dating its creation history row to ``when``.
|
|
1517
|
+
|
|
1518
|
+
Unlike ``_save`` (which uses ``update_fields`` for a narrow update), this is
|
|
1519
|
+
for a first insert: simple-history honors ``_history_date`` / ``_history_user``
|
|
1520
|
+
on the create so the row is attributed and backdated in one write.
|
|
1521
|
+
"""
|
|
1522
|
+
instance._history_date = when
|
|
1523
|
+
instance._history_user = user
|
|
1524
|
+
instance.save()
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _save(instance: Any, when: datetime, user: Any, fields: list[str]) -> None:
|
|
1528
|
+
"""Save ``instance`` writing a history row dated ``when`` by ``user``.
|
|
1529
|
+
|
|
1530
|
+
django-simple-history honors ``_history_date``/``_history_user`` set on the
|
|
1531
|
+
instance before save (ADR-0114 — the first backdated-history writer in the
|
|
1532
|
+
codebase). update_fields keeps the write narrow.
|
|
1533
|
+
"""
|
|
1534
|
+
instance._history_date = when
|
|
1535
|
+
instance._history_user = user
|
|
1536
|
+
instance.save(update_fields=fields)
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _aware(ctx: ReplayContext, day: date) -> datetime:
|
|
1540
|
+
return _aware_dt(ctx, day, 12, 0)
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _aware_dt(ctx: ReplayContext, day: date, hour: int, minute: int) -> datetime:
|
|
1544
|
+
"""Build an aware datetime in the program timezone (default UTC)."""
|
|
1545
|
+
tz = ctx.tz or ZoneInfo("UTC")
|
|
1546
|
+
return datetime(day.year, day.month, day.day, hour, minute, tzinfo=tz)
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _sprint_key(ctx: ReplayContext, sprint: Sprint) -> tuple[str, str]:
|
|
1550
|
+
for key, candidate in ctx.sprints.items():
|
|
1551
|
+
if candidate.pk == sprint.pk:
|
|
1552
|
+
return key
|
|
1553
|
+
return ("", "")
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _implied_carry_over_target(ctx: ReplayContext, sprint: Sprint) -> str:
|
|
1557
|
+
"""Infer this close's carry-over destination from the seed's own timeline (#3488).
|
|
1558
|
+
|
|
1559
|
+
The live close reads ``carry_over_to`` off the API request; a replayed
|
|
1560
|
+
close has none, so it is read off the seed's intent instead: the next
|
|
1561
|
+
sprint in the same project that the timeline actually runs (state ACTIVE
|
|
1562
|
+
or COMPLETED in ``ctx.final_sprint`` — i.e. it gets an authored or
|
|
1563
|
+
synthesized ``sprint.activate`` beat somewhere in the timeline) is where
|
|
1564
|
+
an incomplete task would land. No such sprint — this closing sprint is
|
|
1565
|
+
the project's last to actually run — falls back to ``"backlog"``, the
|
|
1566
|
+
same default ``POST .../close/`` uses.
|
|
1567
|
+
"""
|
|
1568
|
+
project_slug, _ = _sprint_key(ctx, sprint)
|
|
1569
|
+
candidates = [
|
|
1570
|
+
(candidate.start_date, candidate.pk)
|
|
1571
|
+
for key, candidate in ctx.sprints.items()
|
|
1572
|
+
if key[0] == project_slug
|
|
1573
|
+
and candidate.pk != sprint.pk
|
|
1574
|
+
and candidate.start_date > sprint.finish_date
|
|
1575
|
+
and ctx.final_sprint.get(key, {}).get("state")
|
|
1576
|
+
in (SprintState.ACTIVE, SprintState.COMPLETED)
|
|
1577
|
+
]
|
|
1578
|
+
if not candidates:
|
|
1579
|
+
return "backlog"
|
|
1580
|
+
candidates.sort(key=lambda c: c[0])
|
|
1581
|
+
return str(candidates[0][1])
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _named_actor(beat: _Beat, ctx: ReplayContext) -> Any:
|
|
1585
|
+
"""The account a beat names, without the importing-owner fallback.
|
|
1586
|
+
|
|
1587
|
+
``_Beat.actor`` falls back to the importing owner, which is right for a
|
|
1588
|
+
status move or a comment but wrong for a fact whose attribution *is* the
|
|
1589
|
+
fact: an hour logged, a reaction, an acknowledgement. On the generic import
|
|
1590
|
+
path a pre-existing real account resolves to ``None`` (#1057); crediting its
|
|
1591
|
+
hours to the importer instead would fabricate the importer's own timesheet.
|
|
1592
|
+
"""
|
|
1593
|
+
slug = beat.data.get("actor")
|
|
1594
|
+
return ctx.users.get(slug) if slug else None
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _comment_for(ctx: ReplayContext, target: str) -> TaskComment | None:
|
|
1598
|
+
_, _, slug = target.partition(":")
|
|
1599
|
+
return ctx.comments.get(slug)
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _apply_task_note(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1603
|
+
"""Append a dated ``TaskNote`` — the task's why/decision log (ADR-0143, #3492).
|
|
1604
|
+
|
|
1605
|
+
``decision`` is what the Decisions view reads. ``created_at`` is
|
|
1606
|
+
``auto_now_add``, so it is backdated after insert like a comment.
|
|
1607
|
+
"""
|
|
1608
|
+
task = _resolve_task(ctx, beat.target)
|
|
1609
|
+
body = beat.data.get("body")
|
|
1610
|
+
if task is None or not body:
|
|
1611
|
+
return
|
|
1612
|
+
note = TaskNote.objects.create(
|
|
1613
|
+
task=task,
|
|
1614
|
+
author=beat.actor,
|
|
1615
|
+
body=body,
|
|
1616
|
+
pinned=bool(beat.data.get("pinned", False)),
|
|
1617
|
+
decision=bool(beat.data.get("decision", False)),
|
|
1618
|
+
)
|
|
1619
|
+
TaskNote.objects.filter(pk=note.pk).update(created_at=beat.when)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _apply_task_react(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1623
|
+
"""Add a reaction to a slugged comment (ADR-0075 §A.4). Never notifies."""
|
|
1624
|
+
comment = _comment_for(ctx, beat.target)
|
|
1625
|
+
user = _named_actor(beat, ctx)
|
|
1626
|
+
emoji = beat.data.get("emoji")
|
|
1627
|
+
if comment is None or user is None or not emoji:
|
|
1628
|
+
return
|
|
1629
|
+
reaction, created = CommentReaction.objects.get_or_create(
|
|
1630
|
+
comment=comment, user=user, emoji=emoji
|
|
1631
|
+
)
|
|
1632
|
+
if created:
|
|
1633
|
+
CommentReaction.objects.filter(pk=reaction.pk).update(created_at=beat.when)
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def _apply_task_ack(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1637
|
+
"""Acknowledge a slugged comment — "I saw this / I'm on it" (ADR-0075 §A.3)."""
|
|
1638
|
+
comment = _comment_for(ctx, beat.target)
|
|
1639
|
+
user = _named_actor(beat, ctx)
|
|
1640
|
+
if comment is None or user is None:
|
|
1641
|
+
return
|
|
1642
|
+
ack, created = CommentAcknowledgement.objects.get_or_create(comment=comment, user=user)
|
|
1643
|
+
if created:
|
|
1644
|
+
CommentAcknowledgement.objects.filter(pk=ack.pk).update(created_at=beat.when)
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def _apply_time_log(beat: _Beat, ctx: ReplayContext) -> None:
|
|
1648
|
+
"""Write a ``TimeEntry`` dated to the beat (ADR-0185, #3490).
|
|
1649
|
+
|
|
1650
|
+
Never forward-dated: a beat after the anchor is dropped, because logged time
|
|
1651
|
+
records work that happened and the demo's "today" is the anchor. Validation
|
|
1652
|
+
already rejects a relative ``A+N`` beat; this also covers an ISO literal.
|
|
1653
|
+
"""
|
|
1654
|
+
task = _resolve_task(ctx, beat.target)
|
|
1655
|
+
user = _named_actor(beat, ctx)
|
|
1656
|
+
minutes = beat.data.get("minutes")
|
|
1657
|
+
if task is None or user is None or not minutes:
|
|
1658
|
+
return
|
|
1659
|
+
entry_date = beat.when.date()
|
|
1660
|
+
if entry_date > ctx.anchor:
|
|
1661
|
+
return
|
|
1662
|
+
entry = TimeEntry.objects.create(
|
|
1663
|
+
task=task,
|
|
1664
|
+
user=user,
|
|
1665
|
+
minutes=minutes,
|
|
1666
|
+
entry_date=entry_date,
|
|
1667
|
+
note=beat.data.get("note", ""),
|
|
1668
|
+
source=beat.data.get("source", "manual"),
|
|
1669
|
+
)
|
|
1670
|
+
TimeEntry.objects.filter(pk=entry.pk).update(created_at=beat.when)
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
_HANDLERS = {
|
|
1674
|
+
"task.note": _apply_task_note,
|
|
1675
|
+
"task.react": _apply_task_react,
|
|
1676
|
+
"task.ack": _apply_task_ack,
|
|
1677
|
+
"time.log": _apply_time_log,
|
|
1678
|
+
_TASK_STATUS_ACTION: _apply_task_status,
|
|
1679
|
+
"task.assign": _apply_task_assign,
|
|
1680
|
+
"task.estimate": _apply_task_estimate,
|
|
1681
|
+
"task.points": _apply_task_points,
|
|
1682
|
+
"task.ac_met": _apply_task_ac_met,
|
|
1683
|
+
"task.block": _apply_task_block,
|
|
1684
|
+
"task.unblock": _apply_task_unblock,
|
|
1685
|
+
"task.comment": _apply_task_comment,
|
|
1686
|
+
"sprint.activate": _apply_sprint_activate,
|
|
1687
|
+
"sprint.close": _apply_sprint_close,
|
|
1688
|
+
"sprint.scope_inject": _apply_scope_inject,
|
|
1689
|
+
"sprint.scope_resolve": _apply_scope_resolve,
|
|
1690
|
+
"risk.status": _apply_risk_status,
|
|
1691
|
+
"risk.note": _apply_risk_note,
|
|
1692
|
+
"baseline.capture": _apply_baseline_capture,
|
|
1693
|
+
"retro.action": _apply_retro_action,
|
|
1694
|
+
"retro.promote": _apply_retro_promote,
|
|
1695
|
+
}
|