trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1644 @@
|
|
|
1
|
+
"""Export a live program to the canonical JSON seed format (ADR-0109, issue #616).
|
|
2
|
+
|
|
3
|
+
``export_program`` is the inverse of the importer: it walks a program and emits
|
|
4
|
+
a seed document that re-validates and re-imports. The exporter is deterministic
|
|
5
|
+
— arrays are emitted in a stable order and UUIDs are replaced with derived
|
|
6
|
+
slugs — so the round-trip ``export(import(export(p)))`` is identical to
|
|
7
|
+
``export(import(p))`` (the #616 guarantee). It strips every derived field
|
|
8
|
+
(server_version, CPM outputs, short_id) by simply never emitting them.
|
|
9
|
+
|
|
10
|
+
By default it emits **v1** (``schema_version: "1.0"``, final-state) so the #616
|
|
11
|
+
byte-identical round-trip is preserved unchanged. Passing ``with_events=True``
|
|
12
|
+
emits **v2** (ADR-0114 §7 / #1109): dates are rewritten as ``anchor``-relative
|
|
13
|
+
offsets and an ordered ``events`` timeline is reconstructed from the history
|
|
14
|
+
tables so a shared program re-imports as the *life* it lived, not a snapshot.
|
|
15
|
+
The v2 path is a self-consistent fixpoint — ``export→import→export`` is
|
|
16
|
+
byte-identical — because every reconstructed event replays to a state that
|
|
17
|
+
reconstructs to the same event, and the importer's synthesizer is naturally
|
|
18
|
+
suppressed (every task with history already carries authored status events).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import date, datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
from zoneinfo import ZoneInfo
|
|
30
|
+
|
|
31
|
+
from trueppm_api.apps.access.models import ProgramMembership, ProjectMembership, Role
|
|
32
|
+
from trueppm_api.apps.projects.models import (
|
|
33
|
+
AcceptanceCriterion,
|
|
34
|
+
BacklogItem,
|
|
35
|
+
Baseline,
|
|
36
|
+
BaselineTask,
|
|
37
|
+
BoardColumnConfig,
|
|
38
|
+
CeremonyTemplate,
|
|
39
|
+
CommentAcknowledgement,
|
|
40
|
+
CommentReaction,
|
|
41
|
+
Dependency,
|
|
42
|
+
Label,
|
|
43
|
+
Program,
|
|
44
|
+
Project,
|
|
45
|
+
RetroActionItem,
|
|
46
|
+
Risk,
|
|
47
|
+
RiskComment,
|
|
48
|
+
ScopeChangeStatus,
|
|
49
|
+
Sprint,
|
|
50
|
+
SprintRetro,
|
|
51
|
+
SprintScopeChange,
|
|
52
|
+
Task,
|
|
53
|
+
TaskComment,
|
|
54
|
+
TaskLabel,
|
|
55
|
+
TaskNote,
|
|
56
|
+
TaskRecurrenceRule,
|
|
57
|
+
TaskRelation,
|
|
58
|
+
)
|
|
59
|
+
from trueppm_api.apps.resources.models import (
|
|
60
|
+
ProjectResource,
|
|
61
|
+
Resource,
|
|
62
|
+
ResourceSkill,
|
|
63
|
+
TaskResource,
|
|
64
|
+
TaskSkillRequirement,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_UTC = ZoneInfo("UTC")
|
|
68
|
+
|
|
69
|
+
# Sentinel distinguishing "no HTTP caller at all" (a management command, run by
|
|
70
|
+
# an operator with direct DB/shell access — already a higher trust tier than any
|
|
71
|
+
# API role) from "an HTTP caller whose user object is falsy" (an async export
|
|
72
|
+
# job's ``requested_by`` can go ``NULL`` via ``on_delete=SET_NULL`` if the
|
|
73
|
+
# requesting account is deleted between enqueue and build). Only the sentinel is
|
|
74
|
+
# treated as trusted; a literal ``None`` from a real request lineage is not
|
|
75
|
+
# (#3627) — see ``_Exporter._resolve_email_visibility``.
|
|
76
|
+
_NO_HTTP_CALLER = object()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(order=True)
|
|
80
|
+
class _RawEvent:
|
|
81
|
+
"""A reconstructed event before serialization, sortable into replay order.
|
|
82
|
+
|
|
83
|
+
Sort key is ``(when, target, action, seq)``: chronological, with a fully
|
|
84
|
+
deterministic tiebreak so re-export is byte-identical. ``data`` (actor +
|
|
85
|
+
action-specific keys) is excluded from the ordering.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
when: datetime
|
|
89
|
+
target: str
|
|
90
|
+
action: str
|
|
91
|
+
seq: int
|
|
92
|
+
data: dict[str, Any] = field(compare=False, default_factory=dict)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Signature of the ``emit`` callback the reconstruction helpers are handed.
|
|
96
|
+
_Emit = Callable[[datetime, str, str, dict[str, Any]], None]
|
|
97
|
+
|
|
98
|
+
# seedDate / seedTimestamp cap a relative offset at 4 digits (~27 years); a date
|
|
99
|
+
# further from the anchor than this falls back to an ISO literal (still a valid
|
|
100
|
+
# seedDate/seedTimestamp) so a very long-lived program still exports cleanly.
|
|
101
|
+
_MAX_REL_OFFSET_DAYS = 9999
|
|
102
|
+
|
|
103
|
+
_ROLE_NAME: dict[int, str] = {
|
|
104
|
+
Role.VIEWER: "VIEWER",
|
|
105
|
+
Role.MEMBER: "MEMBER",
|
|
106
|
+
Role.SCHEDULER: "SCHEDULER",
|
|
107
|
+
Role.ADMIN: "ADMIN",
|
|
108
|
+
Role.OWNER: "OWNER",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: Event actions that only exist from schema 2.1 (#3603).
|
|
113
|
+
_V21_EVENT_ACTIONS = frozenset({"task.note", "task.react", "task.ack", "time.log"})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
#: Weekday names in bitmask order — the authoring shape of a recurrence rule (#3498).
|
|
117
|
+
_WEEKDAY_BITS = (
|
|
118
|
+
("mon", 1),
|
|
119
|
+
("tue", 2),
|
|
120
|
+
("wed", 4),
|
|
121
|
+
("thu", 8),
|
|
122
|
+
("fri", 16),
|
|
123
|
+
("sat", 32),
|
|
124
|
+
("sun", 64),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
#: ``Proficiency`` integer -> the seed's authoring name (#3498).
|
|
128
|
+
_PROFICIENCY_NAME = {1: "beginner", 2: "intermediate", 3: "expert"}
|
|
129
|
+
|
|
130
|
+
#: Task keys that only exist from schema 2.1.
|
|
131
|
+
_V21_TASK_KEYS = ("acceptance_criteria", "is_subtask", "recurrence", "skill_requirements")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _uses_v21(doc: dict[str, Any]) -> bool:
|
|
135
|
+
"""Whether an exported document carries any construct introduced in 2.1."""
|
|
136
|
+
program = doc.get("program", {})
|
|
137
|
+
if program.get("backlog_items") or program.get("ceremonies"):
|
|
138
|
+
return True
|
|
139
|
+
if any(resource.get("skills") for resource in doc.get("resources", [])):
|
|
140
|
+
return True
|
|
141
|
+
for project in doc.get("projects", []):
|
|
142
|
+
if project.get("team"):
|
|
143
|
+
return True
|
|
144
|
+
for task in project.get("tasks", []):
|
|
145
|
+
if task.get("type") == "tech_debt" or any(task.get(k) for k in _V21_TASK_KEYS):
|
|
146
|
+
return True
|
|
147
|
+
return any(
|
|
148
|
+
event.get("action") in _V21_EVENT_ACTIONS or "slug" in event or "reply_to" in event
|
|
149
|
+
for event in doc.get("events", [])
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def dump_seed(payload: dict[str, Any]) -> str:
|
|
154
|
+
"""Canonical serialization: sorted keys, 2-space indent, trailing newline.
|
|
155
|
+
|
|
156
|
+
Centralised so the management command, the REST endpoint, and the round-trip
|
|
157
|
+
test all emit byte-identical output.
|
|
158
|
+
"""
|
|
159
|
+
return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# The seed schema caps every ``slug`` at 40 chars (so a program slug still fits
|
|
163
|
+
# ``Program.code``). Slugs are derived from free-text titles/names, so the
|
|
164
|
+
# exporter must enforce the cap itself — a risk titled longer than ~40 chars
|
|
165
|
+
# would otherwise emit a slug the importer's own schema rejects (#2006).
|
|
166
|
+
_MAX_SLUG_LEN = 40
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _slugify(text: str) -> str:
|
|
170
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
171
|
+
# Truncate to the schema cap, then re-strip so a cut mid-separator can't
|
|
172
|
+
# leave a trailing "-" that violates the kebab-case pattern.
|
|
173
|
+
return slug[:_MAX_SLUG_LEN].strip("-") or "x"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class _SlugAllocator:
|
|
177
|
+
"""Assigns unique kebab slugs deterministically (suffix on collision)."""
|
|
178
|
+
|
|
179
|
+
def __init__(self) -> None:
|
|
180
|
+
self._used: set[str] = set()
|
|
181
|
+
|
|
182
|
+
def take(self, text: str) -> str:
|
|
183
|
+
base = _slugify(text)
|
|
184
|
+
candidate = base
|
|
185
|
+
i = 2
|
|
186
|
+
while candidate in self._used:
|
|
187
|
+
suffix = f"-{i}"
|
|
188
|
+
# Keep the disambiguated slug within the 40-char cap by trimming the
|
|
189
|
+
# base to make room for the suffix (and re-stripping any dangling "-").
|
|
190
|
+
candidate = base[: _MAX_SLUG_LEN - len(suffix)].strip("-") + suffix
|
|
191
|
+
i += 1
|
|
192
|
+
self._used.add(candidate)
|
|
193
|
+
return candidate
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _put(target: dict[str, Any], key: str, value: Any) -> None:
|
|
197
|
+
"""Emit a key only when meaningful — skips None and empty strings."""
|
|
198
|
+
if value is None or value == "":
|
|
199
|
+
return
|
|
200
|
+
target[key] = value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def export_program(
|
|
204
|
+
program: Program,
|
|
205
|
+
*,
|
|
206
|
+
with_events: bool = False,
|
|
207
|
+
requesting_user: Any = _NO_HTTP_CALLER,
|
|
208
|
+
) -> dict[str, Any]:
|
|
209
|
+
"""Serialize ``program`` to a canonical seed document (a ``dict``).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
program: the program to export.
|
|
213
|
+
with_events: when ``False`` (default) emit a v1 final-state document,
|
|
214
|
+
preserving the #616 byte-identical round-trip. When ``True`` emit a
|
|
215
|
+
v2 document (ADR-0114 §7 / #1109) with ``anchor``-relative dates and
|
|
216
|
+
a reconstructed ``events`` timeline, so the export re-imports through
|
|
217
|
+
the replay engine as the program's dated life rather than a snapshot.
|
|
218
|
+
requesting_user: the account the export is being produced *for* (#3627).
|
|
219
|
+
``Resource.email`` and account ``email`` are withheld unless this
|
|
220
|
+
user holds workspace Admin+ — see
|
|
221
|
+
``_Exporter._resolve_email_visibility``. Omit entirely for a caller
|
|
222
|
+
with no HTTP request context (e.g. the management command, run by an
|
|
223
|
+
operator with shell/DB access already); that is treated as trusted
|
|
224
|
+
and email is included unredacted. A literal ``None`` (an async
|
|
225
|
+
export job whose ``requested_by`` went ``NULL``) is deliberately
|
|
226
|
+
*not* trusted and redacts.
|
|
227
|
+
"""
|
|
228
|
+
projects = list(
|
|
229
|
+
Project.objects.filter(program=program, is_deleted=False).order_by("code", "name", "pk")
|
|
230
|
+
)
|
|
231
|
+
return _Exporter(
|
|
232
|
+
program=program,
|
|
233
|
+
projects=projects,
|
|
234
|
+
with_events=with_events,
|
|
235
|
+
requesting_user=requesting_user,
|
|
236
|
+
).build()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def export_project(project: Project, *, requesting_user: Any = _NO_HTTP_CALLER) -> dict[str, Any]:
|
|
240
|
+
"""Serialize a single ``project`` to a canonical seed document (#967).
|
|
241
|
+
|
|
242
|
+
The canonical schema requires a top-level ``program`` block (ADR-0109) but
|
|
243
|
+
``Project.program`` is nullable (ADR-0070 — standalone projects are
|
|
244
|
+
first-class), so the project is emitted inside a minimal program *synthesized
|
|
245
|
+
from the project itself* (slug/name/methodology). This is uniform whether or
|
|
246
|
+
not the live project belongs to a program, and because the synthesized slug
|
|
247
|
+
is project-derived rather than the parent program's ``code``, re-importing a
|
|
248
|
+
project export creates a fresh program instead of clobbering the live parent
|
|
249
|
+
program's subtree. The #616 round-trip guarantee still holds. See the #967
|
|
250
|
+
addendum in ADR-0109.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
project: the project to export.
|
|
254
|
+
requesting_user: see :func:`export_program` — controls whether
|
|
255
|
+
``Resource.email`` / account ``email`` are withheld (#3627).
|
|
256
|
+
"""
|
|
257
|
+
return _Exporter(
|
|
258
|
+
program=None,
|
|
259
|
+
projects=[project],
|
|
260
|
+
synthetic_program=project,
|
|
261
|
+
requesting_user=requesting_user,
|
|
262
|
+
).build()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class _Exporter:
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
program: Program | None,
|
|
270
|
+
projects: list[Project],
|
|
271
|
+
synthetic_program: Project | None = None,
|
|
272
|
+
with_events: bool = False,
|
|
273
|
+
requesting_user: Any = _NO_HTTP_CALLER,
|
|
274
|
+
) -> None:
|
|
275
|
+
# ``program`` is the live parent program for a program export, or None
|
|
276
|
+
# for a single-project export (#967), in which case ``synthetic_program``
|
|
277
|
+
# (the project) sources the synthesized program-wrapper block.
|
|
278
|
+
self.program = program
|
|
279
|
+
self.synthetic_program = synthetic_program
|
|
280
|
+
self.projects = projects
|
|
281
|
+
# #3627: whether this export includes real Resource/account email.
|
|
282
|
+
# ``_resources_block``/``_account_entry`` never reach ``ResourceSerializer``
|
|
283
|
+
# (the #891 harvest control), so the exporter enforces the same invariant
|
|
284
|
+
# itself rather than inheriting a serializer it does not use. The
|
|
285
|
+
# endpoint-level gate (IsProjectAdmin/IsProgramAdmin) stays project- or
|
|
286
|
+
# program-scoped and self-grantable by design (creating a project makes
|
|
287
|
+
# you its Owner) — that is what makes the field a workspace-wide harvest
|
|
288
|
+
# once any project Admin can pull the whole catalog into an export. So
|
|
289
|
+
# email is workspace-Admin-only *content*, independent of who can reach
|
|
290
|
+
# the export action at all. See docs/adr/0034 (#3569 amendment) and #3627.
|
|
291
|
+
self._email_visible = self._resolve_email_visibility(requesting_user)
|
|
292
|
+
# v2 event-timeline export (#1109). When on, dates become anchor-relative
|
|
293
|
+
# and an ``events`` array is reconstructed from the history tables.
|
|
294
|
+
self.with_events = with_events
|
|
295
|
+
# The reference date for relative offsets (v2 only). The earliest project
|
|
296
|
+
# start is a deterministic, always-present choice (projects require a
|
|
297
|
+
# start_date), and it round-trips: re-import resolves the same absolute
|
|
298
|
+
# dates, so re-export recomputes the same anchor.
|
|
299
|
+
self.anchor: date = min(
|
|
300
|
+
(p.start_date for p in projects if p.start_date is not None), default=date.today()
|
|
301
|
+
)
|
|
302
|
+
# Account identity, captured once accounts are built, so an event actor
|
|
303
|
+
# always resolves to a real account slug (never a dangling ref).
|
|
304
|
+
self._account_user_pks: set[Any] = set()
|
|
305
|
+
self._fallback_actor_slug: str = ""
|
|
306
|
+
self.project_slug = _SlugAllocator()
|
|
307
|
+
self.account_slug = _SlugAllocator()
|
|
308
|
+
self.calendar_slug = _SlugAllocator()
|
|
309
|
+
self.resource_slug = _SlugAllocator()
|
|
310
|
+
# v2.1 comment slugs (#3493), minted only for a comment something refers to
|
|
311
|
+
# (a reply, reaction or acknowledgement). Document-wide, so two tasks'
|
|
312
|
+
# comments can never collide after the 40-char truncation.
|
|
313
|
+
self.comment_slug = _SlugAllocator()
|
|
314
|
+
|
|
315
|
+
# pk -> slug maps, built once so cross-references resolve consistently.
|
|
316
|
+
self.user_slugs: dict[Any, str] = {}
|
|
317
|
+
self.calendar_slugs: dict[Any, str] = {}
|
|
318
|
+
self.resource_slugs: dict[Any, str] = {}
|
|
319
|
+
self.project_slugs: dict[Any, str] = {}
|
|
320
|
+
# task pk -> (project_slug, wbs_path) for global task refs.
|
|
321
|
+
self.task_ref: dict[Any, tuple[str, str]] = {}
|
|
322
|
+
# sprint pk -> slug, scoped per project.
|
|
323
|
+
self.sprint_slugs: dict[Any, str] = {}
|
|
324
|
+
# Risk slugs are allocated per project inside ``_risk_blocks``; recorded
|
|
325
|
+
# here so ``risk.note`` events can target them. Projects are serialized
|
|
326
|
+
# before events, so this is populated by the time the timeline is built.
|
|
327
|
+
self.risk_slugs: dict[Any, str] = {}
|
|
328
|
+
# task pk -> [label slug, …], populated per project in ``_project_block``
|
|
329
|
+
# (ADR-0400 labels folded into the seed, #1958). Label slugs are
|
|
330
|
+
# project-scoped, matching the slug-based (no-UUID) seed contract.
|
|
331
|
+
self.task_label_slugs: dict[Any, list[str]] = {}
|
|
332
|
+
# memoized — _all_resources is consulted by three blocks.
|
|
333
|
+
self._resources_cache: list[Resource] | None = None
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _resolve_email_visibility(requesting_user: Any) -> bool:
|
|
337
|
+
"""True when ``requesting_user`` may see real ``email`` values (#3627).
|
|
338
|
+
|
|
339
|
+
The ``_NO_HTTP_CALLER`` sentinel (the parameter default) means the caller
|
|
340
|
+
has no HTTP request context at all — the management command, run by an
|
|
341
|
+
operator with direct DB/shell access, already a higher trust tier than
|
|
342
|
+
any API role — and is treated as trusted. Every other value, including a
|
|
343
|
+
literal ``None``, goes through the real check: an HTTP-driven export
|
|
344
|
+
passes ``request.user`` (always a real, authenticated user), while an
|
|
345
|
+
async export-job builder passes ``job.requested_by``, which can be
|
|
346
|
+
``None`` if the requesting account was deleted between enqueue and
|
|
347
|
+
build — and a gone account must not fall back to trusted. Email is
|
|
348
|
+
visible only at workspace Admin+ (``WorkspaceRole.ADMIN``), matching the
|
|
349
|
+
invariant the #3569 amendment records for the other three email-bearing
|
|
350
|
+
routes: the endpoint stays reachable at project/program Admin+
|
|
351
|
+
(self-grantable by creating a project), so the field itself — not the
|
|
352
|
+
endpoint — has to be the workspace-wide gate.
|
|
353
|
+
"""
|
|
354
|
+
if requesting_user is _NO_HTTP_CALLER:
|
|
355
|
+
return True
|
|
356
|
+
# Imported locally (not at module scope) to match the existing
|
|
357
|
+
# cross-app-import convention in this package (see attachment_policy.py,
|
|
358
|
+
# calendar_settings.py, etc.) and avoid a load-order dependency on the
|
|
359
|
+
# workspace app from the projects app.
|
|
360
|
+
from trueppm_api.apps.workspace.models import WorkspaceRole
|
|
361
|
+
from trueppm_api.apps.workspace.permissions import workspace_role_for_user
|
|
362
|
+
|
|
363
|
+
role = workspace_role_for_user(requesting_user)
|
|
364
|
+
return role is not None and role >= WorkspaceRole.ADMIN
|
|
365
|
+
|
|
366
|
+
# --- public ------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def build(self) -> dict[str, Any]:
|
|
369
|
+
for project in self.projects:
|
|
370
|
+
self.project_slugs[project.pk] = self.project_slug.take(project.code or project.name)
|
|
371
|
+
|
|
372
|
+
# Pre-pass: index every task and sprint across ALL projects before
|
|
373
|
+
# emitting any block, so cross-project dependency and risk refs resolve
|
|
374
|
+
# regardless of project ordering.
|
|
375
|
+
self._project_tasks: dict[Any, list[Task]] = {}
|
|
376
|
+
self._project_sprints: dict[Any, list[Sprint]] = {}
|
|
377
|
+
for project in self.projects:
|
|
378
|
+
self._index_project(project)
|
|
379
|
+
|
|
380
|
+
doc: dict[str, Any] = {"schema_version": "2.0" if self.with_events else "1.0"}
|
|
381
|
+
if self.with_events:
|
|
382
|
+
# An explicit anchor pins the offsets so re-import resolves the exact
|
|
383
|
+
# same absolute dates (round-trip determinism), rather than drifting
|
|
384
|
+
# to the next import day.
|
|
385
|
+
doc["anchor"] = self.anchor.isoformat()
|
|
386
|
+
doc["program"] = self._program_block()
|
|
387
|
+
accounts = self._accounts_block()
|
|
388
|
+
# Capture account identity before events so an event actor resolves to a
|
|
389
|
+
# real account slug and the deterministic fallback (OWNER) is known.
|
|
390
|
+
self._capture_account_identity(accounts)
|
|
391
|
+
calendars = self._calendars_block()
|
|
392
|
+
resources = self._resources_block()
|
|
393
|
+
if accounts:
|
|
394
|
+
doc["accounts"] = accounts
|
|
395
|
+
if calendars:
|
|
396
|
+
doc["calendars"] = calendars
|
|
397
|
+
if resources:
|
|
398
|
+
doc["resources"] = resources
|
|
399
|
+
doc["projects"] = [self._project_block(p) for p in self.projects]
|
|
400
|
+
if self.with_events:
|
|
401
|
+
self._put_program_v21(doc["program"])
|
|
402
|
+
events = self._events_block()
|
|
403
|
+
if events:
|
|
404
|
+
doc["events"] = events
|
|
405
|
+
# Declare 2.1 only when the document uses a 2.1 construct, so a program
|
|
406
|
+
# with none still exports as 2.0 and stays importable by a 2.0 build.
|
|
407
|
+
if _uses_v21(doc):
|
|
408
|
+
doc["schema_version"] = "2.1"
|
|
409
|
+
return doc
|
|
410
|
+
|
|
411
|
+
def _put_program_v21(self, block: dict[str, Any]) -> None:
|
|
412
|
+
"""Program backlog and ceremonies (v2.1, #3491/#3603). v2 export only.
|
|
413
|
+
|
|
414
|
+
Built after account identity is captured, so ``pulled_by``/``created_by``
|
|
415
|
+
can be restricted to users that already hold an account slug. Agent actions
|
|
416
|
+
and share links are deliberately never exported: one is audit evidence and
|
|
417
|
+
the other a credential, and every non-sample import rejects both.
|
|
418
|
+
"""
|
|
419
|
+
if self.program is None:
|
|
420
|
+
return
|
|
421
|
+
items = sorted(
|
|
422
|
+
BacklogItem.objects.filter(program=self.program, is_deleted=False),
|
|
423
|
+
key=lambda i: (i.priority_rank is None, i.priority_rank or 0, i.created_at, i.title),
|
|
424
|
+
)
|
|
425
|
+
allocator = _SlugAllocator()
|
|
426
|
+
backlog: list[dict[str, Any]] = []
|
|
427
|
+
for item in items:
|
|
428
|
+
entry: dict[str, Any] = {"slug": allocator.take(item.title), "title": item.title}
|
|
429
|
+
_put(entry, "description", item.description)
|
|
430
|
+
entry["item_type"] = item.item_type
|
|
431
|
+
entry["status"] = item.status
|
|
432
|
+
if item.tags:
|
|
433
|
+
entry["tags"] = list(item.tags)
|
|
434
|
+
_put(entry, "priority_rank", item.priority_rank)
|
|
435
|
+
_put(entry, "story_points", item.story_points)
|
|
436
|
+
ref = self.task_ref.get(item.pulled_task_id) if item.pulled_task_id else None
|
|
437
|
+
if ref is not None:
|
|
438
|
+
entry["pulled_to"] = f"{ref[0]}:{ref[1]}"
|
|
439
|
+
if item.pulled_at is not None:
|
|
440
|
+
entry["pulled_at"] = self._rel_ts(item.pulled_at)
|
|
441
|
+
if item.pulled_by_id in self._account_user_pks:
|
|
442
|
+
entry["pulled_by"] = self._user_slug(item.pulled_by)
|
|
443
|
+
elif item.status == "pulled":
|
|
444
|
+
# The task it became is gone or unexportable; a "pulled" item that
|
|
445
|
+
# names no task would fail validation, so it re-imports as archived.
|
|
446
|
+
entry["status"] = "archived"
|
|
447
|
+
if item.created_by_id in self._account_user_pks:
|
|
448
|
+
entry["created_by"] = self._user_slug(item.created_by)
|
|
449
|
+
entry["created_at"] = self._rel_ts(item.created_at)
|
|
450
|
+
backlog.append(entry)
|
|
451
|
+
if backlog:
|
|
452
|
+
block["backlog_items"] = backlog
|
|
453
|
+
|
|
454
|
+
ceremonies: list[dict[str, Any]] = []
|
|
455
|
+
for ceremony in CeremonyTemplate.objects.filter(
|
|
456
|
+
program=self.program, is_deleted=False
|
|
457
|
+
).order_by("name", "pk"):
|
|
458
|
+
entry = {"name": ceremony.name, "cadence_type": ceremony.cadence_type}
|
|
459
|
+
_put(entry, "cadence_day", ceremony.cadence_day)
|
|
460
|
+
if ceremony.cadence_time is not None:
|
|
461
|
+
entry["cadence_time"] = ceremony.cadence_time.strftime("%H:%M")
|
|
462
|
+
entry["duration_minutes"] = ceremony.duration_minutes
|
|
463
|
+
_put(entry, "owner_role", ceremony.owner_role)
|
|
464
|
+
if not ceremony.enabled:
|
|
465
|
+
entry["enabled"] = False
|
|
466
|
+
ceremonies.append(entry)
|
|
467
|
+
if ceremonies:
|
|
468
|
+
block["ceremonies"] = ceremonies
|
|
469
|
+
|
|
470
|
+
# --- top-level blocks --------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def _program_block(self) -> dict[str, Any]:
|
|
473
|
+
if self.program is None:
|
|
474
|
+
# Single-project export (#967): synthesize a minimal program wrapper
|
|
475
|
+
# from the project so the seed satisfies the schema's required
|
|
476
|
+
# ``program`` block while staying decoupled from any live parent
|
|
477
|
+
# program (re-import won't clobber it). The slug is slugified to keep
|
|
478
|
+
# it schema-valid (kebab-case) since Project.code is free-form.
|
|
479
|
+
proj = self.synthetic_program
|
|
480
|
+
assert proj is not None # always set when self.program is None
|
|
481
|
+
block: dict[str, Any] = {
|
|
482
|
+
"slug": _slugify(proj.code or proj.name),
|
|
483
|
+
"name": proj.name,
|
|
484
|
+
"methodology": proj.methodology,
|
|
485
|
+
}
|
|
486
|
+
_put(block, "description", proj.description)
|
|
487
|
+
return block
|
|
488
|
+
block = {
|
|
489
|
+
"slug": self.program.code or self.project_slug.take(self.program.name),
|
|
490
|
+
"name": self.program.name,
|
|
491
|
+
"methodology": self.program.methodology,
|
|
492
|
+
}
|
|
493
|
+
_put(block, "description", self.program.description)
|
|
494
|
+
_put(block, "color", self.program.color)
|
|
495
|
+
if self.program.lead_id is not None:
|
|
496
|
+
block["lead"] = self._user_slug(self.program.lead)
|
|
497
|
+
return block
|
|
498
|
+
|
|
499
|
+
def _accounts_block(self) -> list[dict[str, Any]]:
|
|
500
|
+
# Every user referenced anywhere in the export needs an account so it
|
|
501
|
+
# re-imports. Membership roles come from ProgramMembership. A single-
|
|
502
|
+
# project export (#967) has no parent program, so the roster is empty and
|
|
503
|
+
# no account carries a program-level role — but the project's own live
|
|
504
|
+
# members are still emitted (#3457), alongside the users it references
|
|
505
|
+
# through task assignments, resource accounts and risk ownership.
|
|
506
|
+
roles, users = self._collect_account_users()
|
|
507
|
+
accounts = [self._account_entry(uid, user, roles) for uid, user in users.items()]
|
|
508
|
+
return sorted(accounts, key=lambda a: a["slug"])
|
|
509
|
+
|
|
510
|
+
def _collect_account_users(self) -> tuple[dict[Any, int], dict[Any, Any]]:
|
|
511
|
+
"""Gather every user referenced by the export, in a stable order.
|
|
512
|
+
|
|
513
|
+
Insertion order (program roster, then task assignees, risk owners,
|
|
514
|
+
resource accounts, then live project members) fixes slug-allocation
|
|
515
|
+
order, so the roster must be walked in exactly this sequence for the
|
|
516
|
+
round-trip to stay byte-stable.
|
|
517
|
+
"""
|
|
518
|
+
users: dict[Any, Any] = {}
|
|
519
|
+
roles = self._program_roster(users)
|
|
520
|
+
# Project leads (#3098) come before task assignees so a lead who is
|
|
521
|
+
# referenced nowhere else still gets an account entry — without one the
|
|
522
|
+
# exported ``project.lead`` slug would dangle and the document would not
|
|
523
|
+
# re-import. Inserted here rather than appended because insertion order
|
|
524
|
+
# fixes slug allocation, and the round-trip is asserted byte-identical.
|
|
525
|
+
for proj in self.projects:
|
|
526
|
+
if proj.lead_id is not None:
|
|
527
|
+
users[proj.lead_id] = proj.lead
|
|
528
|
+
for task in self._all_tasks():
|
|
529
|
+
if task.assignee_id is not None:
|
|
530
|
+
users[task.assignee_id] = task.assignee
|
|
531
|
+
for risk in Risk.objects.filter(project__in=self.projects).select_related("owner"):
|
|
532
|
+
if risk.owner_id is not None:
|
|
533
|
+
users[risk.owner_id] = risk.owner
|
|
534
|
+
for res in self._all_resources():
|
|
535
|
+
if res.user_id is not None:
|
|
536
|
+
users[res.user_id] = res.user
|
|
537
|
+
self._add_project_member_users(users)
|
|
538
|
+
return roles, users
|
|
539
|
+
|
|
540
|
+
def _add_project_member_users(self, users: dict[Any, Any]) -> None:
|
|
541
|
+
"""Add every live project member not already present, walking the projects in order."""
|
|
542
|
+
# Live *project* members, last (#3457). Project and program membership are
|
|
543
|
+
# independent grants (ADR-0070 §RBAC), so a user revoked from the program
|
|
544
|
+
# can still hold a live ProjectMembership. Now that the roster no longer
|
|
545
|
+
# carries revoked members, such a user would reach _member_blocks with no
|
|
546
|
+
# slug and their project grant would be silently dropped from the
|
|
547
|
+
# document — access lost rather than access leaked, but lost all the same.
|
|
548
|
+
# Appended after every existing source rather than inserted beside the
|
|
549
|
+
# roster: this can only add users that would otherwise carry no slug at
|
|
550
|
+
# all, so no already-allocated slug moves and the byte-stable fixpoint is
|
|
551
|
+
# untouched. One query, then regrouped and walked in ``self.projects``
|
|
552
|
+
# order and by username, because insertion order fixes slug allocation
|
|
553
|
+
# and a database's natural row order does not.
|
|
554
|
+
#
|
|
555
|
+
# Ordered by ``user__username`` and NOT by ``user_id``: the slug is
|
|
556
|
+
# derived from the username and two usernames can slugify to the same
|
|
557
|
+
# base, in which case ``_SlugAllocator`` suffixes them ``-2``/``-3`` in
|
|
558
|
+
# *call* order. ``username`` round-trips verbatim through the document;
|
|
559
|
+
# the user PK does not — a re-import mints new users in ``accounts[]``
|
|
560
|
+
# order — so ordering on the id would let a colliding pair swap suffixes
|
|
561
|
+
# on re-export and break the byte-identical fixpoint (#616).
|
|
562
|
+
members_by_project: dict[Any, list[Any]] = {}
|
|
563
|
+
for pm in (
|
|
564
|
+
ProjectMembership.objects.filter(project__in=self.projects, is_deleted=False)
|
|
565
|
+
.select_related("user")
|
|
566
|
+
.order_by("user__username")
|
|
567
|
+
):
|
|
568
|
+
members_by_project.setdefault(pm.project_id, []).append(pm)
|
|
569
|
+
for proj in self.projects:
|
|
570
|
+
for pm in members_by_project.get(proj.pk, ()):
|
|
571
|
+
users.setdefault(pm.user_id, pm.user)
|
|
572
|
+
|
|
573
|
+
def _program_roster(self, users: dict[Any, Any]) -> dict[Any, int]:
|
|
574
|
+
"""Seed ``users`` with the program lead + members; return their roles.
|
|
575
|
+
|
|
576
|
+
A single-project export (#967) has no parent program, so the roster is
|
|
577
|
+
empty and no program-level roles apply.
|
|
578
|
+
|
|
579
|
+
Both ``ProgramMembership`` reads floor on ``is_deleted=False`` (#3457),
|
|
580
|
+
matching the project-side twin in ``_member_blocks``. A revoked membership
|
|
581
|
+
is a tombstone that still owns the ``(program, user)`` slot, and the
|
|
582
|
+
importer's grant deliberately clears that tombstone (``is_deleted: False``)
|
|
583
|
+
so a seed grant always confers access (#3410) — so any revoked member left
|
|
584
|
+
in the roster is handed their role back on import. The floor has to be
|
|
585
|
+
here, on the export side: a revoked member must not be in the document at
|
|
586
|
+
all.
|
|
587
|
+
|
|
588
|
+
The ``lead`` read below is **deliberately not floored**, and must not be
|
|
589
|
+
"fixed" for symmetry. ``Program.lead`` is a display FK distinct from the
|
|
590
|
+
OWNER membership, ``_program_block`` emits its slug unconditionally, and
|
|
591
|
+
``validate_seed`` requires ``$.program.lead`` to resolve to an
|
|
592
|
+
``accounts[]`` entry — so dropping a lead whose *membership* was revoked
|
|
593
|
+
would emit a document that fails the exporter's own validator. It confers
|
|
594
|
+
nothing either way: the account is emitted without a ``role`` key, and the
|
|
595
|
+
importer's grant skips a roleless account.
|
|
596
|
+
"""
|
|
597
|
+
if self.program is None:
|
|
598
|
+
return {}
|
|
599
|
+
roles = {
|
|
600
|
+
m.user_id: m.role
|
|
601
|
+
for m in ProgramMembership.objects.filter(program=self.program, is_deleted=False)
|
|
602
|
+
}
|
|
603
|
+
if self.program.lead_id is not None:
|
|
604
|
+
users[self.program.lead_id] = self.program.lead
|
|
605
|
+
for m in ProgramMembership.objects.filter(
|
|
606
|
+
program=self.program, is_deleted=False
|
|
607
|
+
).select_related("user"):
|
|
608
|
+
users[m.user_id] = m.user
|
|
609
|
+
return roles
|
|
610
|
+
|
|
611
|
+
def _account_entry(self, uid: Any, user: Any, roles: dict[Any, int]) -> dict[str, Any]:
|
|
612
|
+
block: dict[str, Any] = {"slug": self._user_slug(user), "username": user.get_username()}
|
|
613
|
+
# #3627: withheld for a non-workspace-Admin caller. Safe for the
|
|
614
|
+
# generic re-import path — accounts are matched by ``username``, never
|
|
615
|
+
# ``email`` (see importer._resolve_accounts) — so dropping the key here
|
|
616
|
+
# never breaks round-trip. ``_put`` already drops falsy values, so
|
|
617
|
+
# passing "" simply omits the key, matching the #3599 precedent of
|
|
618
|
+
# dropping rather than nulling a withheld email.
|
|
619
|
+
_put(block, "email", getattr(user, "email", "") if self._email_visible else "")
|
|
620
|
+
display = f"{user.first_name} {user.last_name}".strip()
|
|
621
|
+
_put(block, "display_name", display)
|
|
622
|
+
if uid in roles:
|
|
623
|
+
block["role"] = _ROLE_NAME[roles[uid]]
|
|
624
|
+
return block
|
|
625
|
+
|
|
626
|
+
def _calendars_block(self) -> list[dict[str, Any]]:
|
|
627
|
+
calendars: dict[Any, Any] = {}
|
|
628
|
+
for project in self.projects:
|
|
629
|
+
if project.calendar is not None:
|
|
630
|
+
calendars[project.calendar.pk] = project.calendar
|
|
631
|
+
for res in self._all_resources():
|
|
632
|
+
if res.calendar is not None:
|
|
633
|
+
calendars[res.calendar.pk] = res.calendar
|
|
634
|
+
blocks = []
|
|
635
|
+
for cal in calendars.values():
|
|
636
|
+
slug = self._calendar_slug(cal)
|
|
637
|
+
block: dict[str, Any] = {"slug": slug, "name": cal.name}
|
|
638
|
+
_put(block, "working_days", cal.working_days)
|
|
639
|
+
_put(block, "hours_per_day", cal.hours_per_day)
|
|
640
|
+
_put(block, "timezone", cal.timezone)
|
|
641
|
+
# Non-working ranges (holidays / PTO, #376). Exported as ISO literals so
|
|
642
|
+
# the export→import→export fixpoint is byte-stable; ordered for
|
|
643
|
+
# determinism. Forecast-snapshot history is deliberately NOT exported —
|
|
644
|
+
# like SprintBurnSnapshot it is derived observational history, not
|
|
645
|
+
# authored structure, so a round-trip drops it (its parameters are not
|
|
646
|
+
# recoverable from the materialized rows).
|
|
647
|
+
exceptions = [
|
|
648
|
+
{
|
|
649
|
+
"exc_start": exc.exc_start.isoformat(),
|
|
650
|
+
"exc_end": exc.exc_end.isoformat(),
|
|
651
|
+
**({"description": exc.description} if exc.description else {}),
|
|
652
|
+
}
|
|
653
|
+
for exc in cal.exceptions.all().order_by("exc_start", "exc_end")
|
|
654
|
+
]
|
|
655
|
+
if exceptions:
|
|
656
|
+
block["exceptions"] = exceptions
|
|
657
|
+
blocks.append(block)
|
|
658
|
+
return sorted(blocks, key=lambda c: c["slug"])
|
|
659
|
+
|
|
660
|
+
def _resources_block(self) -> list[dict[str, Any]]:
|
|
661
|
+
blocks = []
|
|
662
|
+
for res in self._all_resources():
|
|
663
|
+
slug = self._resource_slug(res)
|
|
664
|
+
block: dict[str, Any] = {"slug": slug, "name": res.name}
|
|
665
|
+
# #3627: withheld for a non-workspace-Admin caller. Safe for the
|
|
666
|
+
# generic re-import path — ``_resolve_resources`` never matches an
|
|
667
|
+
# existing catalog Resource by email on that path (it always creates
|
|
668
|
+
# a fresh row, #1004) — only the server-curated sample loader does,
|
|
669
|
+
# and that path is never fed a redacted export.
|
|
670
|
+
_put(block, "email", res.email if self._email_visible else "")
|
|
671
|
+
_put(block, "job_role", res.job_role)
|
|
672
|
+
_put(block, "max_units", float(res.max_units))
|
|
673
|
+
if res.calendar_id is not None:
|
|
674
|
+
block["calendar"] = self._calendar_slug(res.calendar)
|
|
675
|
+
if res.user_id is not None:
|
|
676
|
+
block["account"] = self._user_slug(res.user)
|
|
677
|
+
if self.with_events:
|
|
678
|
+
# v2.1 (#3498). Ordered on the normalized name so the export is stable.
|
|
679
|
+
skills = [
|
|
680
|
+
{
|
|
681
|
+
"name": rs.skill.name,
|
|
682
|
+
"proficiency": _PROFICIENCY_NAME[rs.proficiency],
|
|
683
|
+
**({"category": rs.skill.category} if rs.skill.category else {}),
|
|
684
|
+
}
|
|
685
|
+
for rs in ResourceSkill.objects.filter(resource=res, is_deleted=False)
|
|
686
|
+
.select_related("skill")
|
|
687
|
+
.order_by("skill__normalized_name", "pk")
|
|
688
|
+
]
|
|
689
|
+
if skills:
|
|
690
|
+
block["skills"] = skills
|
|
691
|
+
blocks.append(block)
|
|
692
|
+
return sorted(blocks, key=lambda r: r["slug"])
|
|
693
|
+
|
|
694
|
+
# --- per-project -------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
def _index_project(self, project: Project) -> None:
|
|
697
|
+
"""Populate global task/sprint slug maps for one project (pre-pass)."""
|
|
698
|
+
pslug = self.project_slugs[project.pk]
|
|
699
|
+
# A task with no wbs_path (e.g. a promoted retro-action backlog item) has
|
|
700
|
+
# no WBS identity and cannot be represented in a seed — the schema requires
|
|
701
|
+
# a wbs_path. Exclude it from both tasks[] and the ref map so refs to it are
|
|
702
|
+
# dropped rather than emitting an invalid "None" path.
|
|
703
|
+
tasks = list(
|
|
704
|
+
Task.objects.filter(project=project, is_deleted=False, wbs_path__isnull=False).order_by(
|
|
705
|
+
"wbs_path", "pk"
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
self._project_tasks[project.pk] = tasks
|
|
709
|
+
for task in tasks:
|
|
710
|
+
self.task_ref[task.pk] = (pslug, str(task.wbs_path))
|
|
711
|
+
|
|
712
|
+
sprints = list(
|
|
713
|
+
Sprint.objects.filter(project=project, is_deleted=False).order_by("start_date", "pk")
|
|
714
|
+
)
|
|
715
|
+
self._project_sprints[project.pk] = sprints
|
|
716
|
+
sprint_alloc = _SlugAllocator()
|
|
717
|
+
for sprint in sprints:
|
|
718
|
+
self.sprint_slugs[sprint.pk] = sprint_alloc.take(sprint.name)
|
|
719
|
+
|
|
720
|
+
def _project_block(self, project: Project) -> dict[str, Any]:
|
|
721
|
+
pslug = self.project_slugs[project.pk]
|
|
722
|
+
tasks = self._project_tasks[project.pk]
|
|
723
|
+
sprints = self._project_sprints[project.pk]
|
|
724
|
+
|
|
725
|
+
block: dict[str, Any] = {
|
|
726
|
+
"slug": pslug,
|
|
727
|
+
"name": project.name,
|
|
728
|
+
"methodology": project.methodology,
|
|
729
|
+
"start_date": self._date_str(project.start_date) if project.start_date else None,
|
|
730
|
+
}
|
|
731
|
+
self._put_project_scalars(block, project)
|
|
732
|
+
|
|
733
|
+
board_columns = self._board_column_blocks(project)
|
|
734
|
+
member_blocks = self._member_blocks(project)
|
|
735
|
+
|
|
736
|
+
# Project-scoped labels (ADR-0400, #1089) folded into the seed (#1958) so a
|
|
737
|
+
# re-seed round-trips board-card labels. Slugs are allocated per project
|
|
738
|
+
# (deterministic, kebab-cased) and referenced by ``labels`` on each task
|
|
739
|
+
# block; label identity in the seed is slug-based (no UUID), matching the
|
|
740
|
+
# existing seed contract (UUID-preserving round-trip is 0.5, #1959).
|
|
741
|
+
label_blocks = self._label_blocks(project)
|
|
742
|
+
|
|
743
|
+
sprint_blocks = [self._sprint_block(s) for s in sprints]
|
|
744
|
+
task_blocks = [self._task_block(t) for t in tasks]
|
|
745
|
+
dep_blocks = self._dependency_blocks(project, pslug)
|
|
746
|
+
baseline_blocks = self._baseline_blocks(project, pslug)
|
|
747
|
+
risk_blocks = self._risk_blocks(project, pslug)
|
|
748
|
+
# Insertion order IS the emitted key order, and the export contract is a
|
|
749
|
+
# byte-identical export -> import -> export fixpoint (see
|
|
750
|
+
# ``_member_blocks``), so this sequence is not cosmetic. Note it differs
|
|
751
|
+
# from the computation order above: the blocks are built sprints-first
|
|
752
|
+
# because slug allocation is order-sensitive, but emitted tasks-first.
|
|
753
|
+
for key, value in (
|
|
754
|
+
("board_columns", board_columns),
|
|
755
|
+
("members", member_blocks),
|
|
756
|
+
("labels", label_blocks),
|
|
757
|
+
("tasks", task_blocks),
|
|
758
|
+
("dependencies", dep_blocks),
|
|
759
|
+
("sprints", sprint_blocks),
|
|
760
|
+
("baselines", baseline_blocks),
|
|
761
|
+
("risks", risk_blocks),
|
|
762
|
+
("team", self._team_block(project) if self.with_events else {}),
|
|
763
|
+
):
|
|
764
|
+
if value:
|
|
765
|
+
block[key] = value
|
|
766
|
+
return block
|
|
767
|
+
|
|
768
|
+
def _team_block(self, project: Project) -> dict[str, Any]:
|
|
769
|
+
"""The default team's name and SM/PO facets (v2.1, #3498). v2 export only.
|
|
770
|
+
|
|
771
|
+
Only facet holders are listed: membership itself re-derives from the
|
|
772
|
+
project's members on import, so listing everyone would restate it. The
|
|
773
|
+
default name is omitted, so a project nobody configured exports as before.
|
|
774
|
+
"""
|
|
775
|
+
from trueppm_api.apps.teams.models import Team, TeamMembership
|
|
776
|
+
|
|
777
|
+
team = Team.objects.filter(project=project, is_default=True, is_deleted=False).first()
|
|
778
|
+
if team is None:
|
|
779
|
+
return {}
|
|
780
|
+
members: list[dict[str, Any]] = []
|
|
781
|
+
for membership in (
|
|
782
|
+
TeamMembership.objects.filter(team=team, is_deleted=False)
|
|
783
|
+
.exclude(is_scrum_master=False, is_product_owner=False)
|
|
784
|
+
.select_related("user")
|
|
785
|
+
):
|
|
786
|
+
if membership.user_id not in self._account_user_pks:
|
|
787
|
+
continue
|
|
788
|
+
entry: dict[str, Any] = {"account": self._user_slug(membership.user)}
|
|
789
|
+
if membership.is_scrum_master:
|
|
790
|
+
entry["scrum_master"] = True
|
|
791
|
+
if membership.is_product_owner:
|
|
792
|
+
entry["product_owner"] = True
|
|
793
|
+
members.append(entry)
|
|
794
|
+
if not members and team.name == "Default Team":
|
|
795
|
+
return {}
|
|
796
|
+
block: dict[str, Any] = {"members": sorted(members, key=lambda e: e["account"])}
|
|
797
|
+
if team.name != "Default Team":
|
|
798
|
+
block["name"] = team.name
|
|
799
|
+
return block
|
|
800
|
+
|
|
801
|
+
def _put_project_scalars(self, block: dict[str, Any], project: Project) -> None:
|
|
802
|
+
"""Add the project's optional scalar fields, each omitted at its default.
|
|
803
|
+
|
|
804
|
+
A field equal to its default is left out rather than emitted, so a seed
|
|
805
|
+
stays readable and a later change of default does not silently pin every
|
|
806
|
+
exported project to the old value.
|
|
807
|
+
"""
|
|
808
|
+
_put(block, "description", project.description)
|
|
809
|
+
_put(block, "code", project.code)
|
|
810
|
+
if project.lead_id is not None:
|
|
811
|
+
block["lead"] = self._user_slug(project.lead)
|
|
812
|
+
if project.calendar_id is not None:
|
|
813
|
+
block["calendar"] = self._calendar_slug(project.calendar)
|
|
814
|
+
if project.default_view and project.default_view != "SCHEDULE":
|
|
815
|
+
block["default_view"] = project.default_view
|
|
816
|
+
if project.estimation_mode and project.estimation_mode != "open":
|
|
817
|
+
block["estimation_mode"] = project.estimation_mode
|
|
818
|
+
if project.health and project.health != "AUTO":
|
|
819
|
+
block["health"] = project.health
|
|
820
|
+
|
|
821
|
+
def _member_blocks(self, project: Project) -> list[dict[str, Any]]:
|
|
822
|
+
"""Emit this project's memberships so project-scoped roles round-trip (#3092).
|
|
823
|
+
|
|
824
|
+
Emitted unconditionally rather than only when the roles differ from the
|
|
825
|
+
program roster. A "same as the program" shortcut would make the export
|
|
826
|
+
lossy the moment a role was later changed to match by coincidence, and the
|
|
827
|
+
fixpoint (export -> import -> export) is byte-identical either way because
|
|
828
|
+
a declared ``members`` list re-imports to exactly itself.
|
|
829
|
+
|
|
830
|
+
Only users already carrying an account slug are emitted. ``_user_slug``
|
|
831
|
+
*allocates* on first call, and allocation order is what keeps the export
|
|
832
|
+
byte-stable (see ``_collect_account_users``), so minting one here would
|
|
833
|
+
reorder the roster; a member without an account entry could not be
|
|
834
|
+
re-imported anyway.
|
|
835
|
+
|
|
836
|
+
This used to lean on "every project member also holds a
|
|
837
|
+
``ProgramMembership``, so is already slugged". That premise is false —
|
|
838
|
+
the two grants are independent (ADR-0070 §RBAC), and once the roster
|
|
839
|
+
stopped carrying revoked members (#3457) a program-revoked, still-live
|
|
840
|
+
project member had no slug and was dropped here in silence. So
|
|
841
|
+
``_collect_account_users`` now slugs live project members explicitly, as
|
|
842
|
+
its last source; do not restore the assumption.
|
|
843
|
+
"""
|
|
844
|
+
rows = ProjectMembership.objects.filter(project=project, is_deleted=False).select_related(
|
|
845
|
+
"user"
|
|
846
|
+
)
|
|
847
|
+
blocks = [
|
|
848
|
+
{"account": self.user_slugs[m.user_id], "role": _ROLE_NAME[m.role]}
|
|
849
|
+
for m in rows
|
|
850
|
+
if m.user_id in self.user_slugs
|
|
851
|
+
]
|
|
852
|
+
return sorted(blocks, key=lambda b: b["account"])
|
|
853
|
+
|
|
854
|
+
def _board_column_blocks(self, project: Project) -> list[dict[str, Any]]:
|
|
855
|
+
"""Emit the project's board configuration, if it has one (#3093).
|
|
856
|
+
|
|
857
|
+
Absent when the project never configured a board — the API serves
|
|
858
|
+
hardcoded defaults in that case, and emitting them would turn "this
|
|
859
|
+
project uses the defaults" into "this project pinned today's defaults",
|
|
860
|
+
which is a different and worse claim on re-import.
|
|
861
|
+
|
|
862
|
+
Optional per-column metadata is emitted only when set, so a plain
|
|
863
|
+
rename round-trips as a rename rather than as a config that also pins
|
|
864
|
+
every null.
|
|
865
|
+
"""
|
|
866
|
+
config = BoardColumnConfig.objects.filter(project=project).first()
|
|
867
|
+
if config is None or not config.columns:
|
|
868
|
+
return []
|
|
869
|
+
blocks: list[dict[str, Any]] = []
|
|
870
|
+
for column in config.columns:
|
|
871
|
+
entry: dict[str, Any] = {
|
|
872
|
+
"status": column["status"],
|
|
873
|
+
"label": column.get("label", ""),
|
|
874
|
+
}
|
|
875
|
+
if column.get("visible") is not None:
|
|
876
|
+
entry["visible"] = column["visible"]
|
|
877
|
+
for key in ("color", "wip_limit", "age_threshold_days"):
|
|
878
|
+
if column.get(key) is not None:
|
|
879
|
+
entry[key] = column[key]
|
|
880
|
+
lanes = [
|
|
881
|
+
{
|
|
882
|
+
"key": lane["key"],
|
|
883
|
+
"label": lane["label"],
|
|
884
|
+
**({"wip_limit": lane["wip_limit"]} if lane.get("wip_limit") else {}),
|
|
885
|
+
}
|
|
886
|
+
for lane in (column.get("lanes") or [])
|
|
887
|
+
]
|
|
888
|
+
if lanes:
|
|
889
|
+
entry["lanes"] = lanes
|
|
890
|
+
blocks.append(entry)
|
|
891
|
+
return blocks
|
|
892
|
+
|
|
893
|
+
def _label_blocks(self, project: Project) -> list[dict[str, Any]]:
|
|
894
|
+
"""Emit the project's labels and index each task's label slugs (#1958).
|
|
895
|
+
|
|
896
|
+
Labels are ordered by ``(position, name)`` — their stable display order —
|
|
897
|
+
so the slug allocation is deterministic and the round-trip is byte-stable.
|
|
898
|
+
The per-task slug list is stashed in ``self.task_label_slugs`` for
|
|
899
|
+
``_task_block`` to read; a label with no attached tasks still round-trips
|
|
900
|
+
(it is a curated catalog entry, so the block is emitted regardless).
|
|
901
|
+
"""
|
|
902
|
+
labels = list(Label.objects.filter(project=project).order_by("position", "name", "pk"))
|
|
903
|
+
if not labels:
|
|
904
|
+
return []
|
|
905
|
+
allocator = _SlugAllocator()
|
|
906
|
+
slug_by_label: dict[Any, str] = {}
|
|
907
|
+
blocks: list[dict[str, Any]] = []
|
|
908
|
+
for label in labels:
|
|
909
|
+
slug = allocator.take(label.name)
|
|
910
|
+
slug_by_label[label.pk] = slug
|
|
911
|
+
block: dict[str, Any] = {"slug": slug, "name": label.name, "color": label.color}
|
|
912
|
+
if label.position:
|
|
913
|
+
block["position"] = label.position
|
|
914
|
+
blocks.append(block)
|
|
915
|
+
# Index each task's labels in (position, name) order — the pill-row order —
|
|
916
|
+
# so a task's ``labels`` slug list is deterministic too.
|
|
917
|
+
for tl in (
|
|
918
|
+
TaskLabel.objects.filter(label__project=project)
|
|
919
|
+
.select_related("label")
|
|
920
|
+
.order_by("label__position", "label__name", "label__pk")
|
|
921
|
+
):
|
|
922
|
+
label_slug = slug_by_label.get(tl.label_id)
|
|
923
|
+
if label_slug is not None:
|
|
924
|
+
self.task_label_slugs.setdefault(tl.task_id, []).append(label_slug)
|
|
925
|
+
return blocks
|
|
926
|
+
|
|
927
|
+
def _task_block(self, task: Task) -> dict[str, Any]:
|
|
928
|
+
block: dict[str, Any] = {"wbs_path": str(task.wbs_path), "name": task.name}
|
|
929
|
+
self._put_task_core(block, task)
|
|
930
|
+
self._put_task_agile(block, task)
|
|
931
|
+
self._put_task_blocker(block, task)
|
|
932
|
+
self._put_task_attachments(block, task)
|
|
933
|
+
self._put_task_estimate(block, task)
|
|
934
|
+
self._put_task_assignments(block, task)
|
|
935
|
+
self._put_task_labels_and_links(block, task)
|
|
936
|
+
if self.with_events:
|
|
937
|
+
self._put_task_criteria(block, task)
|
|
938
|
+
self._put_task_depth(block, task)
|
|
939
|
+
return block
|
|
940
|
+
|
|
941
|
+
def _put_task_depth(self, block: dict[str, Any], task: Task) -> None:
|
|
942
|
+
"""Subtask flag, recurrence rule and skill requirements (v2.1, #3498). v2 only.
|
|
943
|
+
|
|
944
|
+
The rule is exported as its authoring shape (weekday names, not the bitmask)
|
|
945
|
+
and ``end_type`` is implied by which end key is present, so a re-import
|
|
946
|
+
reconstructs exactly the row it came from.
|
|
947
|
+
"""
|
|
948
|
+
if task.is_subtask:
|
|
949
|
+
block["is_subtask"] = True
|
|
950
|
+
rule = TaskRecurrenceRule.objects.filter(task=task, is_deleted=False).first()
|
|
951
|
+
if rule is not None:
|
|
952
|
+
recurrence: dict[str, Any] = {"frequency": rule.frequency}
|
|
953
|
+
if rule.interval != 1:
|
|
954
|
+
recurrence["interval"] = rule.interval
|
|
955
|
+
days = [name for name, bit in _WEEKDAY_BITS if rule.weekdays & bit]
|
|
956
|
+
if days:
|
|
957
|
+
recurrence["weekdays"] = days
|
|
958
|
+
_put(recurrence, "day_of_month", rule.day_of_month)
|
|
959
|
+
recurrence["time_of_day"] = rule.time_of_day.strftime("%H:%M")
|
|
960
|
+
if rule.end_date is not None:
|
|
961
|
+
recurrence["end_date"] = self._date_str(rule.end_date)
|
|
962
|
+
_put(recurrence, "end_count", rule.end_count)
|
|
963
|
+
block["recurrence"] = recurrence
|
|
964
|
+
requirements = [
|
|
965
|
+
{"skill": req.skill.name, "min_proficiency": _PROFICIENCY_NAME[req.min_proficiency]}
|
|
966
|
+
for req in TaskSkillRequirement.objects.filter(task=task, is_deleted=False)
|
|
967
|
+
.select_related("skill")
|
|
968
|
+
.order_by("skill__normalized_name", "pk")
|
|
969
|
+
]
|
|
970
|
+
if requirements:
|
|
971
|
+
block["skill_requirements"] = requirements
|
|
972
|
+
|
|
973
|
+
def _put_task_criteria(self, block: dict[str, Any], task: Task) -> None:
|
|
974
|
+
"""Acceptance criteria in position order (v2.1, #3492). v2 export only.
|
|
975
|
+
|
|
976
|
+
Emitted as final state — ``met`` with its ``met_by``/``met_at`` trail — and
|
|
977
|
+
never as ``task.ac_met`` beats, the same reason the exporter already skips
|
|
978
|
+
that action: the end value rides the row, so a beat would apply it twice.
|
|
979
|
+
``met_by`` is emitted only for a user who is already an account; minting a
|
|
980
|
+
slug here would reorder allocation and break the byte-stable round-trip.
|
|
981
|
+
"""
|
|
982
|
+
rows = AcceptanceCriterion.objects.filter(task=task, is_deleted=False).order_by(
|
|
983
|
+
"position", "pk"
|
|
984
|
+
)
|
|
985
|
+
criteria: list[dict[str, Any]] = []
|
|
986
|
+
for row in rows:
|
|
987
|
+
entry: dict[str, Any] = {"text": row.text}
|
|
988
|
+
for key in ("given", "when", "then"):
|
|
989
|
+
_put(entry, key, getattr(row, key))
|
|
990
|
+
if row.met:
|
|
991
|
+
entry["met"] = True
|
|
992
|
+
if row.met_by_id is not None and row.met_by_id in self._account_user_pks:
|
|
993
|
+
entry["met_by"] = self._user_slug(row.met_by)
|
|
994
|
+
if row.met_at is not None:
|
|
995
|
+
entry["met_at"] = self._rel_ts(row.met_at)
|
|
996
|
+
criteria.append(entry)
|
|
997
|
+
if criteria:
|
|
998
|
+
block["acceptance_criteria"] = criteria
|
|
999
|
+
|
|
1000
|
+
def _put_task_core(self, block: dict[str, Any], task: Task) -> None:
|
|
1001
|
+
"""Scalar schedule/status fields, each emitted only when non-default."""
|
|
1002
|
+
if task.type and task.type != "task":
|
|
1003
|
+
block["type"] = task.type
|
|
1004
|
+
if task.status and task.status != "NOT_STARTED":
|
|
1005
|
+
block["status"] = task.status
|
|
1006
|
+
if task.is_milestone:
|
|
1007
|
+
block["is_milestone"] = True
|
|
1008
|
+
if task.duration:
|
|
1009
|
+
block["duration"] = task.duration
|
|
1010
|
+
if task.planned_start is not None:
|
|
1011
|
+
block["planned_start"] = self._date_str(task.planned_start)
|
|
1012
|
+
if task.percent_complete:
|
|
1013
|
+
block["percent_complete"] = task.percent_complete
|
|
1014
|
+
_put(block, "notes", task.notes)
|
|
1015
|
+
_put(block, "story_points", task.story_points)
|
|
1016
|
+
_put(block, "remaining_points", task.remaining_points)
|
|
1017
|
+
|
|
1018
|
+
def _put_task_agile(self, block: dict[str, Any], task: Task) -> None:
|
|
1019
|
+
"""Assignee, sprint linkage, and agile/governance classification fields."""
|
|
1020
|
+
if task.assignee_id is not None:
|
|
1021
|
+
block["assignee"] = self._user_slug(task.assignee)
|
|
1022
|
+
if task.sprint_id is not None:
|
|
1023
|
+
block["sprint"] = self.sprint_slugs[task.sprint_id]
|
|
1024
|
+
if task.parent_epic_id is not None and task.parent_epic_id in self.task_ref:
|
|
1025
|
+
block["parent_epic"] = self.task_ref[task.parent_epic_id][1]
|
|
1026
|
+
_put(block, "sprint_rank", task.sprint_rank)
|
|
1027
|
+
if task.dor and task.dor != "idea":
|
|
1028
|
+
block["dor"] = task.dor
|
|
1029
|
+
_put(block, "board_lane", task.board_lane)
|
|
1030
|
+
if task.governance_class and task.governance_class != "flow":
|
|
1031
|
+
block["governance_class"] = task.governance_class
|
|
1032
|
+
if task.delivery_mode and task.delivery_mode != "waterfall":
|
|
1033
|
+
block["delivery_mode"] = task.delivery_mode
|
|
1034
|
+
_put(block, "color", task.color)
|
|
1035
|
+
|
|
1036
|
+
def _put_task_blocker(self, block: dict[str, Any], task: Task) -> None:
|
|
1037
|
+
"""Emit the blocker cluster when the flag is raised (#3094).
|
|
1038
|
+
|
|
1039
|
+
``blocked_reason`` is the flag-of-record, so its emptiness decides. The
|
|
1040
|
+
derived companions are emitted only when set, and ``blocked_since`` rides
|
|
1041
|
+
along as ``since`` — an unstamped blocker renders no age, and age is the
|
|
1042
|
+
triage signal the flag exists to carry.
|
|
1043
|
+
"""
|
|
1044
|
+
reason = (task.blocked_reason or "").strip()
|
|
1045
|
+
if not reason:
|
|
1046
|
+
return
|
|
1047
|
+
blocked: dict[str, Any] = {"reason": task.blocked_reason}
|
|
1048
|
+
if task.blocked_since is not None:
|
|
1049
|
+
blocked["since"] = self._date_str(task.blocked_since.date())
|
|
1050
|
+
_put(blocked, "type", task.blocker_type)
|
|
1051
|
+
if task.blocking_task_id is not None and task.blocking_task_id in self.task_ref:
|
|
1052
|
+
blocked["blocking_task"] = self.task_ref[task.blocking_task_id][1]
|
|
1053
|
+
if task.blocked_by_id is not None:
|
|
1054
|
+
blocked["by"] = self._user_slug(task.blocked_by)
|
|
1055
|
+
block["blocked"] = blocked
|
|
1056
|
+
|
|
1057
|
+
def _put_task_attachments(self, block: dict[str, Any], task: Task) -> None:
|
|
1058
|
+
"""Emit external-link attachments, ordered for a stable round-trip.
|
|
1059
|
+
|
|
1060
|
+
File attachments are skipped rather than half-emitted: a seed is a text
|
|
1061
|
+
document and ``TaskAttachment`` is file XOR ``external_url``, so a file
|
|
1062
|
+
row has nothing expressible. Soft-deleted rows are skipped too — they
|
|
1063
|
+
exist only to keep ``[[attachment:uuid]]`` comment references resolvable.
|
|
1064
|
+
"""
|
|
1065
|
+
rows = [
|
|
1066
|
+
row
|
|
1067
|
+
for row in task.attachments.filter(is_deleted=False).order_by("created_at", "pk")
|
|
1068
|
+
if row.external_url
|
|
1069
|
+
]
|
|
1070
|
+
if not rows:
|
|
1071
|
+
return
|
|
1072
|
+
entries: list[dict[str, Any]] = []
|
|
1073
|
+
for row in rows:
|
|
1074
|
+
entry: dict[str, Any] = {"external_url": row.external_url}
|
|
1075
|
+
_put(entry, "external_title", row.external_title)
|
|
1076
|
+
if row.is_pinned:
|
|
1077
|
+
entry["is_pinned"] = True
|
|
1078
|
+
if row.uploaded_by_id is not None:
|
|
1079
|
+
entry["uploaded_by"] = self._user_slug(row.uploaded_by)
|
|
1080
|
+
entries.append(entry)
|
|
1081
|
+
block["attachments"] = entries
|
|
1082
|
+
|
|
1083
|
+
def _put_task_estimate(self, block: dict[str, Any], task: Task) -> None:
|
|
1084
|
+
if task.optimistic_duration is not None:
|
|
1085
|
+
block["estimate"] = {
|
|
1086
|
+
"optimistic": task.optimistic_duration,
|
|
1087
|
+
"most_likely": task.most_likely_duration,
|
|
1088
|
+
"pessimistic": task.pessimistic_duration,
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
def _put_task_assignments(self, block: dict[str, Any], task: Task) -> None:
|
|
1092
|
+
assignments = [
|
|
1093
|
+
{"resource": self._resource_slug(tr.resource), "units": float(tr.units)}
|
|
1094
|
+
for tr in TaskResource.objects.filter(task=task)
|
|
1095
|
+
.select_related("resource")
|
|
1096
|
+
.order_by("resource__name")
|
|
1097
|
+
]
|
|
1098
|
+
if assignments:
|
|
1099
|
+
block["assignments"] = assignments
|
|
1100
|
+
|
|
1101
|
+
def _put_task_labels_and_links(self, block: dict[str, Any], task: Task) -> None:
|
|
1102
|
+
label_slugs = self.task_label_slugs.get(task.pk)
|
|
1103
|
+
if label_slugs:
|
|
1104
|
+
block["labels"] = label_slugs
|
|
1105
|
+
links = self._task_relation_blocks(task)
|
|
1106
|
+
if links:
|
|
1107
|
+
block["links"] = links
|
|
1108
|
+
|
|
1109
|
+
def _task_relation_blocks(self, task: Task) -> list[dict[str, Any]]:
|
|
1110
|
+
"""Emit this task's outgoing informational relations (ADR-0455).
|
|
1111
|
+
|
|
1112
|
+
Mirrors :meth:`_dependency_blocks`: one block per live ``TaskRelation`` where
|
|
1113
|
+
the task is the *source*, ordered ``(relation_type, target wbs)`` so the
|
|
1114
|
+
round-trip is byte-stable. A relation whose target task is excluded from the
|
|
1115
|
+
export (e.g. a wbs-less promoted task) is dropped, mirroring how a dangling
|
|
1116
|
+
dependency edge is skipped. The target ref is project-qualified only when it
|
|
1117
|
+
crosses a project boundary — the same convention as a dependency predecessor.
|
|
1118
|
+
"""
|
|
1119
|
+
src = self.task_ref.get(task.pk)
|
|
1120
|
+
if src is None:
|
|
1121
|
+
return []
|
|
1122
|
+
src_pslug = src[0]
|
|
1123
|
+
blocks: list[dict[str, Any]] = []
|
|
1124
|
+
for rel in (
|
|
1125
|
+
TaskRelation.objects.filter(source=task, is_deleted=False)
|
|
1126
|
+
.select_related("target")
|
|
1127
|
+
.order_by("relation_type", "target__wbs_path", "pk")
|
|
1128
|
+
):
|
|
1129
|
+
tref = self.task_ref.get(rel.target_id)
|
|
1130
|
+
if tref is None:
|
|
1131
|
+
continue
|
|
1132
|
+
target_ref = tref[1] if tref[0] == src_pslug else f"{tref[0]}:{tref[1]}"
|
|
1133
|
+
block: dict[str, Any] = {"target": target_ref, "link_type": rel.relation_type}
|
|
1134
|
+
_put(block, "note", rel.note)
|
|
1135
|
+
blocks.append(block)
|
|
1136
|
+
return blocks
|
|
1137
|
+
|
|
1138
|
+
def _sprint_block(self, sprint: Sprint) -> dict[str, Any]:
|
|
1139
|
+
block: dict[str, Any] = {
|
|
1140
|
+
"slug": self.sprint_slugs[sprint.pk],
|
|
1141
|
+
"name": sprint.name,
|
|
1142
|
+
"state": sprint.state,
|
|
1143
|
+
"start_date": self._date_str(sprint.start_date) if sprint.start_date else None,
|
|
1144
|
+
"finish_date": self._date_str(sprint.finish_date) if sprint.finish_date else None,
|
|
1145
|
+
}
|
|
1146
|
+
_put(block, "goal", sprint.goal)
|
|
1147
|
+
_put(block, "notes", sprint.notes)
|
|
1148
|
+
_put(block, "committed_points", sprint.committed_points)
|
|
1149
|
+
_put(block, "completed_points", sprint.completed_points)
|
|
1150
|
+
_put(block, "capacity_points", sprint.capacity_points)
|
|
1151
|
+
if sprint.target_milestone_id is not None and sprint.target_milestone_id in self.task_ref:
|
|
1152
|
+
block["target_milestone"] = self.task_ref[sprint.target_milestone_id][1]
|
|
1153
|
+
return block
|
|
1154
|
+
|
|
1155
|
+
def _dependency_blocks(self, project: Project, pslug: str) -> list[dict[str, Any]]:
|
|
1156
|
+
deps = (
|
|
1157
|
+
Dependency.objects.filter(successor__project=project, is_deleted=False)
|
|
1158
|
+
.select_related("predecessor", "successor")
|
|
1159
|
+
.order_by("predecessor__wbs_path", "successor__wbs_path")
|
|
1160
|
+
)
|
|
1161
|
+
blocks = []
|
|
1162
|
+
for dep in deps:
|
|
1163
|
+
pred = self.task_ref.get(dep.predecessor_id)
|
|
1164
|
+
succ = self.task_ref.get(dep.successor_id)
|
|
1165
|
+
if pred is None or succ is None:
|
|
1166
|
+
continue
|
|
1167
|
+
pred_ref = pred[1] if pred[0] == pslug else f"{pred[0]}:{pred[1]}"
|
|
1168
|
+
block: dict[str, Any] = {
|
|
1169
|
+
"predecessor": pred_ref,
|
|
1170
|
+
"successor": succ[1],
|
|
1171
|
+
"dep_type": dep.dep_type,
|
|
1172
|
+
}
|
|
1173
|
+
if dep.lag:
|
|
1174
|
+
block["lag"] = dep.lag
|
|
1175
|
+
blocks.append(block)
|
|
1176
|
+
return blocks
|
|
1177
|
+
|
|
1178
|
+
def _baseline_blocks(self, project: Project, pslug: str) -> list[dict[str, Any]]:
|
|
1179
|
+
blocks = []
|
|
1180
|
+
for baseline in Baseline.objects.filter(project=project).order_by("created_at", "pk"):
|
|
1181
|
+
task_rows = []
|
|
1182
|
+
for bt in BaselineTask.objects.filter(baseline=baseline).order_by("task_name", "pk"):
|
|
1183
|
+
ref = self.task_ref.get(bt.task_id)
|
|
1184
|
+
if ref is None:
|
|
1185
|
+
continue
|
|
1186
|
+
row: dict[str, Any] = {"task": ref[1]}
|
|
1187
|
+
if bt.start is not None:
|
|
1188
|
+
row["start"] = self._date_str(bt.start)
|
|
1189
|
+
if bt.finish is not None:
|
|
1190
|
+
row["finish"] = self._date_str(bt.finish)
|
|
1191
|
+
_put(row, "duration", bt.duration)
|
|
1192
|
+
_put(row, "story_points", bt.story_points)
|
|
1193
|
+
task_rows.append(row)
|
|
1194
|
+
block: dict[str, Any] = {"name": baseline.name, "tasks": task_rows}
|
|
1195
|
+
# Without this the round-trip silently flattens every baseline's
|
|
1196
|
+
# capture date onto import day (#3093), which is the interval
|
|
1197
|
+
# planned-vs-actual is measured over.
|
|
1198
|
+
block["captured_at"] = self._date_str(baseline.created_at.date())
|
|
1199
|
+
if baseline.is_active:
|
|
1200
|
+
block["is_active"] = True
|
|
1201
|
+
blocks.append(block)
|
|
1202
|
+
return blocks
|
|
1203
|
+
|
|
1204
|
+
def _risk_blocks(self, project: Project, pslug: str) -> list[dict[str, Any]]:
|
|
1205
|
+
blocks = []
|
|
1206
|
+
# select_related("owner") + prefetch_related("tasks") keep the per-risk
|
|
1207
|
+
# owner-slug and task-linkage lookups from firing a query inside the loop
|
|
1208
|
+
# (the M2M through rows are batched into a single prefetch query, #2351).
|
|
1209
|
+
risks = (
|
|
1210
|
+
Risk.objects.filter(project=project, is_deleted=False)
|
|
1211
|
+
.select_related("owner")
|
|
1212
|
+
.order_by("title", "pk")
|
|
1213
|
+
.prefetch_related("tasks")
|
|
1214
|
+
)
|
|
1215
|
+
slug_alloc = _SlugAllocator()
|
|
1216
|
+
for risk in risks:
|
|
1217
|
+
risk_slug = slug_alloc.take(risk.title)
|
|
1218
|
+
self.risk_slugs[risk.pk] = risk_slug
|
|
1219
|
+
block: dict[str, Any] = {
|
|
1220
|
+
"slug": risk_slug,
|
|
1221
|
+
"title": risk.title,
|
|
1222
|
+
"status": risk.status,
|
|
1223
|
+
"probability": risk.probability,
|
|
1224
|
+
"impact": risk.impact,
|
|
1225
|
+
}
|
|
1226
|
+
_put(block, "description", risk.description)
|
|
1227
|
+
_put(block, "category", risk.category)
|
|
1228
|
+
_put(block, "response", risk.response)
|
|
1229
|
+
if risk.mitigation_due_date is not None:
|
|
1230
|
+
block["mitigation_due_date"] = self._date_str(risk.mitigation_due_date)
|
|
1231
|
+
_put(block, "trigger", risk.trigger)
|
|
1232
|
+
_put(block, "contingency", risk.contingency)
|
|
1233
|
+
_put(block, "notes", risk.notes)
|
|
1234
|
+
if risk.owner_id is not None:
|
|
1235
|
+
block["owner"] = self._user_slug(risk.owner)
|
|
1236
|
+
refs = []
|
|
1237
|
+
for linked in risk.tasks.all():
|
|
1238
|
+
ref = self.task_ref.get(linked.pk)
|
|
1239
|
+
if ref is None:
|
|
1240
|
+
continue
|
|
1241
|
+
refs.append(ref[1] if ref[0] == pslug else f"{ref[0]}:{ref[1]}")
|
|
1242
|
+
if refs:
|
|
1243
|
+
block["tasks"] = sorted(refs)
|
|
1244
|
+
blocks.append(block)
|
|
1245
|
+
return blocks
|
|
1246
|
+
|
|
1247
|
+
# --- v2 date + event reconstruction ------------------------------------
|
|
1248
|
+
|
|
1249
|
+
def _date_str(self, d: date) -> str:
|
|
1250
|
+
"""Emit a date: ISO literal (v1) or an ``anchor``-relative offset (v2)."""
|
|
1251
|
+
return self._rel_date(d) if self.with_events else d.isoformat()
|
|
1252
|
+
|
|
1253
|
+
def _rel_date(self, d: date) -> str:
|
|
1254
|
+
"""A ``seedDate`` anchor offset that lands exactly (``!`` = no snap).
|
|
1255
|
+
|
|
1256
|
+
The ``!`` suffix opts out of weekend-snapping so the resolved date is
|
|
1257
|
+
exactly ``anchor + offset`` — essential for round-trip determinism, since
|
|
1258
|
+
a snapped date would drift on re-import.
|
|
1259
|
+
"""
|
|
1260
|
+
offset = (d - self.anchor).days
|
|
1261
|
+
if abs(offset) > _MAX_REL_OFFSET_DAYS:
|
|
1262
|
+
return d.isoformat()
|
|
1263
|
+
return f"A{offset:+d}!"
|
|
1264
|
+
|
|
1265
|
+
def _rel_ts(self, dt: datetime) -> str:
|
|
1266
|
+
"""A ``seedTimestamp`` anchor offset (event time; never weekend-snapped).
|
|
1267
|
+
|
|
1268
|
+
Replay builds authored event datetimes in UTC (``resolve_timestamp``
|
|
1269
|
+
defaults to UTC), so we normalize to UTC and emit UTC components; a
|
|
1270
|
+
re-import rebuilds the identical instant, closing the round-trip.
|
|
1271
|
+
"""
|
|
1272
|
+
u = dt.astimezone(_UTC)
|
|
1273
|
+
offset = (u.date() - self.anchor).days
|
|
1274
|
+
if abs(offset) > _MAX_REL_OFFSET_DAYS:
|
|
1275
|
+
return u.replace(second=0, microsecond=0, tzinfo=None).isoformat()
|
|
1276
|
+
return f"A{offset:+d}T{u.hour:02d}:{u.minute:02d}"
|
|
1277
|
+
|
|
1278
|
+
def _capture_account_identity(self, accounts: list[dict[str, Any]]) -> None:
|
|
1279
|
+
"""Record which users are accounts + the deterministic fallback actor.
|
|
1280
|
+
|
|
1281
|
+
Every user emitted into ``accounts[]`` has a slug in ``self.user_slugs``
|
|
1282
|
+
by the time this runs. The fallback actor — used when a history row is
|
|
1283
|
+
attributed to a user who is not (or is no longer) an account — is the
|
|
1284
|
+
lexically-first OWNER account, else the first account. Resolving every
|
|
1285
|
+
event actor to a real account keeps the emitted ``actor`` from dangling
|
|
1286
|
+
and makes it round-trip (replay re-attributes it, re-export reads it).
|
|
1287
|
+
"""
|
|
1288
|
+
self._account_user_pks = set(self.user_slugs.keys())
|
|
1289
|
+
if not accounts:
|
|
1290
|
+
return
|
|
1291
|
+
owners = sorted(a["slug"] for a in accounts if a.get("role") == "OWNER")
|
|
1292
|
+
self._fallback_actor_slug = owners[0] if owners else accounts[0]["slug"]
|
|
1293
|
+
|
|
1294
|
+
def _actor_slug(self, user: Any) -> str:
|
|
1295
|
+
"""Resolve a history/audit user to an account slug (fallback if not one)."""
|
|
1296
|
+
if user is not None and user.pk in self._account_user_pks:
|
|
1297
|
+
return self._user_slug(user)
|
|
1298
|
+
return self._fallback_actor_slug
|
|
1299
|
+
|
|
1300
|
+
def _events_block(self) -> list[dict[str, Any]]:
|
|
1301
|
+
"""Reconstruct the ordered ``events`` timeline from the history tables.
|
|
1302
|
+
|
|
1303
|
+
Emitted event kinds (each a clean export→import→export fixpoint):
|
|
1304
|
+
``task.status`` (Task history transitions), ``task.points`` (mid-sprint
|
|
1305
|
+
remaining-point changes — the importer births a progressing task's
|
|
1306
|
+
``remaining_points`` at ``story_points``, so a burned-down value only
|
|
1307
|
+
survives as an event), ``task.comment`` (``TaskComment`` rows),
|
|
1308
|
+
``sprint.activate`` / ``sprint.close`` (sprint lifecycle timestamps),
|
|
1309
|
+
``sprint.scope_inject`` (still-PENDING scope rows), and ``retro.action``
|
|
1310
|
+
(retro action items).
|
|
1311
|
+
|
|
1312
|
+
Deliberately *not* reconstructed (documented fidelity gaps, not
|
|
1313
|
+
determinism breaks): ``task.assign`` / ``task.estimate`` / ``task.ac_met``
|
|
1314
|
+
(their end value is already carried by the task's final-state fields set
|
|
1315
|
+
at creation, so re-emitting them as events would double-apply and break
|
|
1316
|
+
the fixpoint); ``risk.status`` (the importer births risks at their final
|
|
1317
|
+
status, so a status event would be a no-op that does not survive a
|
|
1318
|
+
round-trip); ``sprint.scope_resolve`` and ``retro.promote`` (their target
|
|
1319
|
+
row's final state — task dropped from the sprint, or a wbs-less promoted
|
|
1320
|
+
task — cannot replay from the exported snapshot). The task's/sprint's
|
|
1321
|
+
*final* state is always preserved via its fields; only the intermediate
|
|
1322
|
+
audit rows for those specific transitions are not round-tripped.
|
|
1323
|
+
"""
|
|
1324
|
+
collected: list[_RawEvent] = []
|
|
1325
|
+
seq = 0
|
|
1326
|
+
|
|
1327
|
+
def emit(when: datetime, action: str, target: str, data: dict[str, Any]) -> None:
|
|
1328
|
+
nonlocal seq
|
|
1329
|
+
collected.append(_RawEvent(when=when, target=target, action=action, seq=seq, data=data))
|
|
1330
|
+
seq += 1
|
|
1331
|
+
|
|
1332
|
+
for project in self.projects:
|
|
1333
|
+
pslug = self.project_slugs[project.pk]
|
|
1334
|
+
for task in self._project_tasks[project.pk]:
|
|
1335
|
+
self._emit_task_history(task, pslug, emit)
|
|
1336
|
+
self._emit_task_comments(task, pslug, emit)
|
|
1337
|
+
for sprint in self._project_sprints[project.pk]:
|
|
1338
|
+
self._emit_sprint_lifecycle(sprint, pslug, emit)
|
|
1339
|
+
self._emit_retro_actions(sprint, pslug, emit)
|
|
1340
|
+
self._emit_scope_injections(project, emit)
|
|
1341
|
+
self._emit_risk_notes(project, emit)
|
|
1342
|
+
|
|
1343
|
+
# Chronological order; ties broken deterministically so re-export is
|
|
1344
|
+
# byte-identical. The array index is the replay tiebreak for same-instant
|
|
1345
|
+
# events (matching the importer's `_resolve_authored` order semantics).
|
|
1346
|
+
collected.sort(key=lambda e: (e.when, e.target, e.action, e.seq))
|
|
1347
|
+
out: list[dict[str, Any]] = []
|
|
1348
|
+
for e in collected:
|
|
1349
|
+
event: dict[str, Any] = {"at": self._rel_ts(e.when), "action": e.action}
|
|
1350
|
+
event.update(e.data)
|
|
1351
|
+
event["target"] = e.target
|
|
1352
|
+
out.append(event)
|
|
1353
|
+
return out
|
|
1354
|
+
|
|
1355
|
+
def _emit_task_history(self, task: Task, pslug: str, emit: _Emit) -> None:
|
|
1356
|
+
"""Emit ``task.status`` + ``task.points`` events from Task history.
|
|
1357
|
+
|
|
1358
|
+
The earliest (creation) row establishes the base status/remaining; each
|
|
1359
|
+
later row is inspected for two independent changes:
|
|
1360
|
+
|
|
1361
|
+
- **status** changed → ``task.status`` (from/to). Replay re-creates the
|
|
1362
|
+
task at NOT_STARTED and walks it forward, reproducing the same rows.
|
|
1363
|
+
- **remaining_points** changed *without* a status change → ``task.points``.
|
|
1364
|
+
A remaining change that coincides with a status change is the COMPLETE
|
|
1365
|
+
handler zeroing remaining_points (``_apply_task_status``), which replay
|
|
1366
|
+
re-applies on its own, so emitting a ``task.points`` for it would be a
|
|
1367
|
+
redundant, non-round-tripping write — hence the ``not status_changed``
|
|
1368
|
+
guard.
|
|
1369
|
+
"""
|
|
1370
|
+
target = f"task:{pslug}:{task.wbs_path}"
|
|
1371
|
+
prev_status: str | None = None
|
|
1372
|
+
prev_remaining: int | None = None
|
|
1373
|
+
rows = task.history.select_related("history_user").order_by("history_date", "history_id")
|
|
1374
|
+
for row in rows:
|
|
1375
|
+
if prev_status is None:
|
|
1376
|
+
prev_status = row.status
|
|
1377
|
+
prev_remaining = row.remaining_points
|
|
1378
|
+
continue
|
|
1379
|
+
status_changed = row.status != prev_status
|
|
1380
|
+
if status_changed:
|
|
1381
|
+
emit(
|
|
1382
|
+
row.history_date,
|
|
1383
|
+
"task.status",
|
|
1384
|
+
target,
|
|
1385
|
+
{
|
|
1386
|
+
"actor": self._actor_slug(row.history_user),
|
|
1387
|
+
"from": prev_status,
|
|
1388
|
+
"to": row.status,
|
|
1389
|
+
},
|
|
1390
|
+
)
|
|
1391
|
+
prev_status = row.status
|
|
1392
|
+
if (
|
|
1393
|
+
not status_changed
|
|
1394
|
+
and row.remaining_points is not None
|
|
1395
|
+
and row.remaining_points != prev_remaining
|
|
1396
|
+
):
|
|
1397
|
+
emit(
|
|
1398
|
+
row.history_date,
|
|
1399
|
+
"task.points",
|
|
1400
|
+
target,
|
|
1401
|
+
{
|
|
1402
|
+
"actor": self._actor_slug(row.history_user),
|
|
1403
|
+
"remaining_points": row.remaining_points,
|
|
1404
|
+
},
|
|
1405
|
+
)
|
|
1406
|
+
prev_remaining = row.remaining_points
|
|
1407
|
+
|
|
1408
|
+
def _emit_task_comments(self, task: Task, pslug: str, emit: _Emit) -> None:
|
|
1409
|
+
"""Emit comments — and under v2.1, their threads, reactions, acks, and notes.
|
|
1410
|
+
|
|
1411
|
+
A comment gets a ``slug`` only when something refers to it (a reply, a
|
|
1412
|
+
reaction or an acknowledgement), so a program with flat comments exports
|
|
1413
|
+
exactly as it did under 2.0. Reactions and acknowledgements are emitted
|
|
1414
|
+
only for users who already hold an account: attributing them to the
|
|
1415
|
+
fallback actor would invent a reaction nobody gave.
|
|
1416
|
+
"""
|
|
1417
|
+
target = f"task:{pslug}:{task.wbs_path}"
|
|
1418
|
+
comments = list(
|
|
1419
|
+
TaskComment.objects.filter(task=task)
|
|
1420
|
+
.select_related("author")
|
|
1421
|
+
.order_by("created_at", "pk")
|
|
1422
|
+
)
|
|
1423
|
+
if not self.with_events or not comments:
|
|
1424
|
+
for c in comments:
|
|
1425
|
+
emit(
|
|
1426
|
+
c.created_at,
|
|
1427
|
+
"task.comment",
|
|
1428
|
+
target,
|
|
1429
|
+
{"actor": self._actor_slug(c.author), "body": c.body},
|
|
1430
|
+
)
|
|
1431
|
+
self._emit_task_notes(task, target, emit)
|
|
1432
|
+
return
|
|
1433
|
+
|
|
1434
|
+
reactions = list(
|
|
1435
|
+
CommentReaction.objects.filter(comment__in=comments, user_id__in=self._account_user_pks)
|
|
1436
|
+
.select_related("user")
|
|
1437
|
+
.order_by("created_at", "pk")
|
|
1438
|
+
)
|
|
1439
|
+
acks = list(
|
|
1440
|
+
CommentAcknowledgement.objects.filter(
|
|
1441
|
+
comment__in=comments, user_id__in=self._account_user_pks
|
|
1442
|
+
)
|
|
1443
|
+
.select_related("user")
|
|
1444
|
+
.order_by("created_at", "pk")
|
|
1445
|
+
)
|
|
1446
|
+
referenced = {c.parent_id for c in comments if c.parent_id is not None}
|
|
1447
|
+
referenced |= {r.comment_id for r in reactions} | {a.comment_id for a in acks}
|
|
1448
|
+
slugs: dict[Any, str] = {}
|
|
1449
|
+
for n, c in enumerate(comments, start=1):
|
|
1450
|
+
if c.pk in referenced:
|
|
1451
|
+
slugs[c.pk] = self.comment_slug.take(f"{pslug}-{task.wbs_path}-comment-{n}")
|
|
1452
|
+
|
|
1453
|
+
for c in comments:
|
|
1454
|
+
data: dict[str, Any] = {"actor": self._actor_slug(c.author), "body": c.body}
|
|
1455
|
+
if c.pk in slugs:
|
|
1456
|
+
data["slug"] = slugs[c.pk]
|
|
1457
|
+
if c.parent_id in slugs:
|
|
1458
|
+
data["reply_to"] = slugs[c.parent_id]
|
|
1459
|
+
emit(c.created_at, "task.comment", target, data)
|
|
1460
|
+
for r in reactions:
|
|
1461
|
+
emit(
|
|
1462
|
+
r.created_at,
|
|
1463
|
+
"task.react",
|
|
1464
|
+
f"comment:{slugs[r.comment_id]}",
|
|
1465
|
+
{"actor": self._user_slug(r.user), "emoji": r.emoji},
|
|
1466
|
+
)
|
|
1467
|
+
for a in acks:
|
|
1468
|
+
emit(
|
|
1469
|
+
a.created_at,
|
|
1470
|
+
"task.ack",
|
|
1471
|
+
f"comment:{slugs[a.comment_id]}",
|
|
1472
|
+
{"actor": self._user_slug(a.user)},
|
|
1473
|
+
)
|
|
1474
|
+
self._emit_task_notes(task, target, emit)
|
|
1475
|
+
|
|
1476
|
+
def _emit_task_notes(self, task: Task, target: str, emit: _Emit) -> None:
|
|
1477
|
+
"""Emit ``task.note`` beats from live ``TaskNote`` rows (v2.1, #3492). v2 only.
|
|
1478
|
+
|
|
1479
|
+
A note is append-only like a comment, so replaying it reproduces the row it
|
|
1480
|
+
came from. Per-person logged time is deliberately *not* reconstructed the
|
|
1481
|
+
same way: exporting ``time.log`` would hand every program Admin who can run
|
|
1482
|
+
an export each contributor's hours, which ADR-0104 keeps off management
|
|
1483
|
+
surfaces.
|
|
1484
|
+
"""
|
|
1485
|
+
if not self.with_events:
|
|
1486
|
+
return
|
|
1487
|
+
for note in (
|
|
1488
|
+
TaskNote.objects.filter(task=task, is_deleted=False)
|
|
1489
|
+
.select_related("author")
|
|
1490
|
+
.order_by("created_at", "pk")
|
|
1491
|
+
):
|
|
1492
|
+
data: dict[str, Any] = {"actor": self._actor_slug(note.author), "body": note.body}
|
|
1493
|
+
if note.pinned:
|
|
1494
|
+
data["pinned"] = True
|
|
1495
|
+
if note.decision:
|
|
1496
|
+
data["decision"] = True
|
|
1497
|
+
emit(note.created_at, "task.note", target, data)
|
|
1498
|
+
|
|
1499
|
+
def _emit_risk_notes(self, project: Project, emit: _Emit) -> None:
|
|
1500
|
+
"""Emit ``risk.note`` events from RiskComment rows (#3094).
|
|
1501
|
+
|
|
1502
|
+
Safe to reconstruct, unlike ``risk.status``: a comment is an append-only
|
|
1503
|
+
artifact that does not depend on the risk's current status, so replaying
|
|
1504
|
+
it reproduces exactly the rows it came from — the same reason
|
|
1505
|
+
``task.comment`` round-trips.
|
|
1506
|
+
"""
|
|
1507
|
+
for comment in (
|
|
1508
|
+
RiskComment.objects.filter(risk__project=project, risk__is_deleted=False)
|
|
1509
|
+
.select_related("author", "risk")
|
|
1510
|
+
.order_by("created_at", "pk")
|
|
1511
|
+
):
|
|
1512
|
+
slug = self.risk_slugs.get(comment.risk_id)
|
|
1513
|
+
if slug is None:
|
|
1514
|
+
continue
|
|
1515
|
+
emit(
|
|
1516
|
+
comment.created_at,
|
|
1517
|
+
"risk.note",
|
|
1518
|
+
f"risk:{slug}",
|
|
1519
|
+
{"actor": self._actor_slug(comment.author), "body": comment.message},
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
def _emit_sprint_lifecycle(self, sprint: Sprint, pslug: str, emit: _Emit) -> None:
|
|
1523
|
+
"""Emit activate/close from the sprint's lifecycle timestamps.
|
|
1524
|
+
|
|
1525
|
+
``activated_at`` / ``closed_at`` carry the beat time; the actor is not
|
|
1526
|
+
persisted on the sprint, so the deterministic fallback (OWNER) is used —
|
|
1527
|
+
the sprint's committed/completed points ride their fields (snapshotted at
|
|
1528
|
+
activate/close on replay), and ``goal_outcome`` rides the close event.
|
|
1529
|
+
"""
|
|
1530
|
+
target = f"sprint:{pslug}:{self.sprint_slugs[sprint.pk]}"
|
|
1531
|
+
if sprint.activated_at is not None:
|
|
1532
|
+
emit(
|
|
1533
|
+
sprint.activated_at,
|
|
1534
|
+
"sprint.activate",
|
|
1535
|
+
target,
|
|
1536
|
+
{"actor": self._fallback_actor_slug},
|
|
1537
|
+
)
|
|
1538
|
+
if sprint.closed_at is not None:
|
|
1539
|
+
data: dict[str, Any] = {"actor": self._fallback_actor_slug}
|
|
1540
|
+
if sprint.goal_outcome:
|
|
1541
|
+
data["goal_outcome"] = sprint.goal_outcome
|
|
1542
|
+
emit(sprint.closed_at, "sprint.close", target, data)
|
|
1543
|
+
|
|
1544
|
+
def _emit_scope_injections(self, project: Project, emit: _Emit) -> None:
|
|
1545
|
+
"""Emit ``sprint.scope_inject`` for still-PENDING scope-change rows.
|
|
1546
|
+
|
|
1547
|
+
Only PENDING rows round-trip cleanly: the injected task is still in its
|
|
1548
|
+
sprint (so replay's ``_apply_scope_inject`` re-creates the audit row), and
|
|
1549
|
+
``added_at`` is backdated by replay so the timestamp is stable. ACCEPTED /
|
|
1550
|
+
REJECTED rows are skipped — the resolve timestamp is not persisted and a
|
|
1551
|
+
rejected injection removed the task from its sprint.
|
|
1552
|
+
"""
|
|
1553
|
+
rows = (
|
|
1554
|
+
SprintScopeChange.objects.filter(
|
|
1555
|
+
sprint__project=project, status=ScopeChangeStatus.PENDING
|
|
1556
|
+
)
|
|
1557
|
+
.select_related("task", "added_by")
|
|
1558
|
+
.order_by("added_at", "pk")
|
|
1559
|
+
)
|
|
1560
|
+
for sc in rows:
|
|
1561
|
+
ref = self.task_ref.get(sc.task_id)
|
|
1562
|
+
if ref is None:
|
|
1563
|
+
continue # task excluded from export (e.g. no wbs_path)
|
|
1564
|
+
data: dict[str, Any] = {"actor": self._actor_slug(sc.added_by)}
|
|
1565
|
+
if sc.goal_impact:
|
|
1566
|
+
data["goal_impact"] = True
|
|
1567
|
+
emit(sc.added_at, "sprint.scope_inject", f"task:{ref[0]}:{ref[1]}", data)
|
|
1568
|
+
|
|
1569
|
+
def _emit_retro_actions(self, sprint: Sprint, pslug: str, emit: _Emit) -> None:
|
|
1570
|
+
"""Emit a ``retro.action`` per retro action item on the sprint's retro.
|
|
1571
|
+
|
|
1572
|
+
A non-empty ``SprintRetro.notes`` rides on the last item's beat (as
|
|
1573
|
+
``notes``) rather than its own event — there is no standalone "open
|
|
1574
|
+
retro" action, so a retro's summary text can only round-trip attached
|
|
1575
|
+
to an action item that already exists (#3497). A retro with notes but
|
|
1576
|
+
no action items cannot be reconstructed as v2 events; that gap predates
|
|
1577
|
+
this change (the retro itself couldn't be created via replay either).
|
|
1578
|
+
"""
|
|
1579
|
+
retro = SprintRetro.objects.filter(sprint=sprint).first()
|
|
1580
|
+
if retro is None:
|
|
1581
|
+
return
|
|
1582
|
+
target = f"sprint:{pslug}:{self.sprint_slugs[sprint.pk]}"
|
|
1583
|
+
actor = self._actor_slug(retro.created_by)
|
|
1584
|
+
items = list(
|
|
1585
|
+
RetroActionItem.objects.filter(retro=retro)
|
|
1586
|
+
.select_related("assignee")
|
|
1587
|
+
.order_by("created_at", "pk")
|
|
1588
|
+
)
|
|
1589
|
+
for i, item in enumerate(items):
|
|
1590
|
+
data: dict[str, Any] = {"actor": actor, "body": item.text}
|
|
1591
|
+
if item.assignee_id is not None and item.assignee_id in self._account_user_pks:
|
|
1592
|
+
data["assignee"] = self._user_slug(item.assignee)
|
|
1593
|
+
if item.story_points is not None:
|
|
1594
|
+
data["points"] = item.story_points
|
|
1595
|
+
if i == len(items) - 1 and retro.notes:
|
|
1596
|
+
data["notes"] = retro.notes
|
|
1597
|
+
emit(item.created_at, "retro.action", target, data)
|
|
1598
|
+
|
|
1599
|
+
# --- slug helpers ------------------------------------------------------
|
|
1600
|
+
|
|
1601
|
+
def _user_slug(self, user: Any) -> str:
|
|
1602
|
+
if user.pk not in self.user_slugs:
|
|
1603
|
+
self.user_slugs[user.pk] = self.account_slug.take(user.get_username())
|
|
1604
|
+
return self.user_slugs[user.pk]
|
|
1605
|
+
|
|
1606
|
+
def _calendar_slug(self, calendar: Any) -> str:
|
|
1607
|
+
if calendar.pk not in self.calendar_slugs:
|
|
1608
|
+
self.calendar_slugs[calendar.pk] = self.calendar_slug.take(calendar.name)
|
|
1609
|
+
return self.calendar_slugs[calendar.pk]
|
|
1610
|
+
|
|
1611
|
+
def _resource_slug(self, resource: Resource) -> str:
|
|
1612
|
+
if resource.pk not in self.resource_slugs:
|
|
1613
|
+
self.resource_slugs[resource.pk] = self.resource_slug.take(resource.name)
|
|
1614
|
+
return self.resource_slugs[resource.pk]
|
|
1615
|
+
|
|
1616
|
+
# --- collection helpers ------------------------------------------------
|
|
1617
|
+
|
|
1618
|
+
def _all_tasks(self) -> list[Task]:
|
|
1619
|
+
return list(
|
|
1620
|
+
Task.objects.filter(project__in=self.projects, is_deleted=False).select_related(
|
|
1621
|
+
"assignee"
|
|
1622
|
+
)
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
def _all_resources(self) -> list[Resource]:
|
|
1626
|
+
# Resources referenced by either a task assignment or the project roster.
|
|
1627
|
+
if self._resources_cache is not None:
|
|
1628
|
+
return self._resources_cache
|
|
1629
|
+
ids = set(
|
|
1630
|
+
TaskResource.objects.filter(task__project__in=self.projects).values_list(
|
|
1631
|
+
"resource_id", flat=True
|
|
1632
|
+
)
|
|
1633
|
+
)
|
|
1634
|
+
ids |= set(
|
|
1635
|
+
ProjectResource.objects.filter(project__in=self.projects, is_deleted=False).values_list(
|
|
1636
|
+
"resource_id", flat=True
|
|
1637
|
+
)
|
|
1638
|
+
)
|
|
1639
|
+
self._resources_cache = list(
|
|
1640
|
+
Resource.objects.filter(pk__in=ids)
|
|
1641
|
+
.select_related("calendar", "user")
|
|
1642
|
+
.order_by("name", "pk")
|
|
1643
|
+
)
|
|
1644
|
+
return self._resources_cache
|