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,1149 @@
|
|
|
1
|
+
"""Validate a TruePPM JSON seed document (ADR-0109, issue #614).
|
|
2
|
+
|
|
3
|
+
``validate_seed`` is a pure, side-effect-free function: it checks a parsed
|
|
4
|
+
payload against the bundled JSON Schema (draft 2020-12) and then runs a
|
|
5
|
+
referential-integrity pass that JSON Schema cannot express — duplicate
|
|
6
|
+
file-local slugs and dangling cross-references (an assignee that names no
|
|
7
|
+
account, a dependency edge to a task that does not exist, and so on).
|
|
8
|
+
|
|
9
|
+
Both the structural and referential phases collect *all* failures before
|
|
10
|
+
raising, so a seed author fixing a hand-written file sees every problem at
|
|
11
|
+
once rather than one-per-run. Every error is anchored to a JSON path so the
|
|
12
|
+
offending location is obvious.
|
|
13
|
+
|
|
14
|
+
Two entry points share one implementation:
|
|
15
|
+
|
|
16
|
+
``inspect_seed``
|
|
17
|
+
Pure and total — never raises for a bad document. Returns a
|
|
18
|
+
:class:`SeedReport` carrying every diagnostic plus what the file *claims*
|
|
19
|
+
to be (version, program slug, entity counts). This is what the dry-run
|
|
20
|
+
endpoint and ``manage.py import_seed --check`` call (#2418).
|
|
21
|
+
``validate_seed``
|
|
22
|
+
The original raise-on-invalid contract the importer depends on, now a thin
|
|
23
|
+
wrapper over ``inspect_seed``. Its behavior is unchanged in the only way
|
|
24
|
+
that matters: a document that failed before still fails, with at least the
|
|
25
|
+
same diagnostics.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from functools import lru_cache
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from jsonschema import Draft202012Validator, FormatChecker
|
|
37
|
+
|
|
38
|
+
# Schema majors this validator understands. The major component is the
|
|
39
|
+
# compatibility boundary: a payload whose major is unknown is rejected outright.
|
|
40
|
+
# v2 (ADR-0114) is an additive superset of v1 — relative dates + an events
|
|
41
|
+
# timeline — so both load through their own bundled schema. See ADR-0109/0114.
|
|
42
|
+
SUPPORTED_MAJORS = ("1", "2")
|
|
43
|
+
|
|
44
|
+
# Aggregate ceiling on materializable entities across the whole document.
|
|
45
|
+
# Per-array maxItems in the schema bound each collection; this bounds the
|
|
46
|
+
# *total* — a single import must not be able to create an unbounded number of
|
|
47
|
+
# rows in one transaction. Generous: the largest bundled sample is a few hundred
|
|
48
|
+
# entities.
|
|
49
|
+
MAX_SEED_NODES = 100_000
|
|
50
|
+
|
|
51
|
+
# Longest a single diagnostic may be. jsonschema renders the *whole offending
|
|
52
|
+
# instance* into ``ValidationError.message`` for container-level keywords, so a
|
|
53
|
+
# ``maxItems`` violation on ``projects`` echoes every project back at the caller
|
|
54
|
+
# — a 201-project document produces a 10 KB line, and a document padded to
|
|
55
|
+
# SEED_MAX_UPLOAD_MB produces megabytes, in a 400 whose body the caller chose
|
|
56
|
+
# the size of (#2615). The path prefix and the keyword both survive truncation,
|
|
57
|
+
# which is the part a caller can act on.
|
|
58
|
+
MAX_SEED_ERROR_CHARS = 300
|
|
59
|
+
|
|
60
|
+
_SCHEMAS_DIR = Path(__file__).resolve().parent.parent / "schemas"
|
|
61
|
+
_SCHEMA_PATH_BY_MAJOR = {
|
|
62
|
+
"1": _SCHEMAS_DIR / "seed_v1.json",
|
|
63
|
+
"2": _SCHEMAS_DIR / "seed_v2.json",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SeedValidationError(ValueError):
|
|
68
|
+
"""Raised when a seed document fails structural or referential validation.
|
|
69
|
+
|
|
70
|
+
``errors`` holds every individual problem (JSON-path anchored) so callers —
|
|
71
|
+
the import endpoint (#615) in particular — can surface a complete report
|
|
72
|
+
rather than a single message.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, errors: list[str]) -> None:
|
|
76
|
+
self.errors = errors
|
|
77
|
+
count = len(errors)
|
|
78
|
+
noun = "error" if count == 1 else "errors"
|
|
79
|
+
super().__init__(f"Seed validation failed with {count} {noun}:\n" + "\n".join(errors))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@lru_cache(maxsize=len(_SCHEMA_PATH_BY_MAJOR))
|
|
83
|
+
def _validator(major: str) -> Draft202012Validator:
|
|
84
|
+
"""Build (once per major) the schema validator with format checking on.
|
|
85
|
+
|
|
86
|
+
``format`` is an annotation in JSON Schema by default; we opt into checking
|
|
87
|
+
it so ``"2026-13-40"`` in a ``planned_start`` is caught here rather than
|
|
88
|
+
blowing up later at ``date.fromisoformat`` time during import.
|
|
89
|
+
"""
|
|
90
|
+
schema = json.loads(_SCHEMA_PATH_BY_MAJOR[major].read_text(encoding="utf-8"))
|
|
91
|
+
return Draft202012Validator(schema, format_checker=FormatChecker())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _json_path(absolute_path: Any) -> str:
|
|
95
|
+
"""Render a jsonschema error path as ``$.projects[0].tasks[2].wbs_path``."""
|
|
96
|
+
out = "$"
|
|
97
|
+
for part in absolute_path:
|
|
98
|
+
out += f"[{part}]" if isinstance(part, int) else f".{part}"
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class SeedReport:
|
|
104
|
+
"""The full result of inspecting a seed document, without importing it.
|
|
105
|
+
|
|
106
|
+
``errors`` is the same JSON-path-anchored list :class:`SeedValidationError`
|
|
107
|
+
carries. The remaining fields echo back what the *document claims to be* so
|
|
108
|
+
an operator running a dry run can confirm they grabbed the right file
|
|
109
|
+
before pointing a wipe-then-recreate import at a live program slug
|
|
110
|
+
(ADR-0109). They are read defensively: a malformed document still produces
|
|
111
|
+
a report, with ``None``/``0`` where a claim could not be read.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
valid: bool
|
|
115
|
+
errors: list[str]
|
|
116
|
+
schema_version: str | None
|
|
117
|
+
program_slug: str | None
|
|
118
|
+
program_name: str | None
|
|
119
|
+
project_count: int
|
|
120
|
+
task_count: int
|
|
121
|
+
resource_count: int
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def inspect_seed(payload: Any, *, allow_sample_sections: bool = False) -> SeedReport:
|
|
125
|
+
"""Inspect a parsed seed document and report every problem found.
|
|
126
|
+
|
|
127
|
+
Total by construction — a document this rejects still yields a report
|
|
128
|
+
rather than an exception, because the dry-run caller needs the diagnostics
|
|
129
|
+
precisely when the document is bad (#2418).
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
payload: the already-parsed JSON, not a raw string. Any type is
|
|
133
|
+
accepted; a non-object is itself reported as a diagnostic.
|
|
134
|
+
allow_sample_sections: accept the sections that create audit evidence or
|
|
135
|
+
credentials (see :func:`_sample_only_errors`). Only the bundled-sample
|
|
136
|
+
path may pass ``True``; the default rejects them, so a new caller is
|
|
137
|
+
fail-closed.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A :class:`SeedReport` whose ``valid`` is ``True`` only when ``errors``
|
|
141
|
+
is empty.
|
|
142
|
+
"""
|
|
143
|
+
if not isinstance(payload, dict):
|
|
144
|
+
return SeedReport(
|
|
145
|
+
valid=False,
|
|
146
|
+
errors=["$: seed document must be a JSON object"],
|
|
147
|
+
schema_version=None,
|
|
148
|
+
program_slug=None,
|
|
149
|
+
program_name=None,
|
|
150
|
+
project_count=0,
|
|
151
|
+
task_count=0,
|
|
152
|
+
resource_count=0,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
errors = _document_errors(payload)
|
|
156
|
+
if not allow_sample_sections:
|
|
157
|
+
errors = _sample_only_errors(payload) + errors
|
|
158
|
+
return SeedReport(
|
|
159
|
+
valid=not errors,
|
|
160
|
+
errors=errors,
|
|
161
|
+
schema_version=payload.get("schema_version")
|
|
162
|
+
if isinstance(payload.get("schema_version"), str)
|
|
163
|
+
else None,
|
|
164
|
+
**_claimed_shape(payload),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _claimed_shape(payload: dict[str, Any]) -> dict[str, Any]:
|
|
169
|
+
"""Read the program identity + entity counts a document claims to carry.
|
|
170
|
+
|
|
171
|
+
Every access is defensive: this runs on documents that have already failed
|
|
172
|
+
validation, where ``program`` may be absent or the wrong type entirely.
|
|
173
|
+
"""
|
|
174
|
+
program = payload.get("program")
|
|
175
|
+
program = program if isinstance(program, dict) else {}
|
|
176
|
+
projects = payload.get("projects")
|
|
177
|
+
projects = projects if isinstance(projects, list) else []
|
|
178
|
+
resources = payload.get("resources")
|
|
179
|
+
resources = resources if isinstance(resources, list) else []
|
|
180
|
+
|
|
181
|
+
task_count = 0
|
|
182
|
+
for project in projects:
|
|
183
|
+
if isinstance(project, dict) and isinstance(project.get("tasks"), list):
|
|
184
|
+
task_count += len(project["tasks"])
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"program_slug": program.get("slug") if isinstance(program.get("slug"), str) else None,
|
|
188
|
+
"program_name": program.get("name") if isinstance(program.get("name"), str) else None,
|
|
189
|
+
"project_count": len(projects),
|
|
190
|
+
"task_count": task_count,
|
|
191
|
+
"resource_count": len(resources),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _document_errors(payload: dict[str, Any]) -> list[str]:
|
|
196
|
+
"""Collect every diagnostic for an object-shaped document.
|
|
197
|
+
|
|
198
|
+
Version handling is the one place the passes are *not* purely additive, and
|
|
199
|
+
the two failure modes are deliberately asymmetric (#2418):
|
|
200
|
+
|
|
201
|
+
- **Missing** ``schema_version`` — the version is reported, and the
|
|
202
|
+
structural pass still runs against the newest supported schema. v2 is an
|
|
203
|
+
additive superset of v1 (ADR-0114), so a v1-shaped document validated
|
|
204
|
+
against v2 still passes structurally; running it is what lets a
|
|
205
|
+
version-less file report its *other* twenty problems in the same pass
|
|
206
|
+
instead of hiding them behind one line. The version is injected into a
|
|
207
|
+
shallow copy for that pass only — both bundled schemas list
|
|
208
|
+
``schema_version`` as required, so without the injection the schema would
|
|
209
|
+
re-report the same problem in less specific language.
|
|
210
|
+
- **Unsupported** major — reported, and the structural pass is *skipped*.
|
|
211
|
+
Here there is no defensible schema to substitute: checking a 3.x document
|
|
212
|
+
against the v2 schema would bury the one diagnostic that matters under a
|
|
213
|
+
wall of misleading ones.
|
|
214
|
+
"""
|
|
215
|
+
version = payload.get("schema_version")
|
|
216
|
+
if version is None:
|
|
217
|
+
latest = SUPPORTED_MAJORS[-1]
|
|
218
|
+
return [
|
|
219
|
+
"$.schema_version: required and missing",
|
|
220
|
+
*_schema_errors({**payload, "schema_version": f"{latest}.0"}, latest),
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
major = version.split(".")[0] if isinstance(version, str) else None
|
|
224
|
+
if major not in _SCHEMA_PATH_BY_MAJOR:
|
|
225
|
+
supported = ", ".join(f"{m}.x" for m in SUPPORTED_MAJORS)
|
|
226
|
+
return [
|
|
227
|
+
f"$.schema_version: unsupported version {version!r}; this build supports {supported}"
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
return _schema_errors(payload, major)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _bounded_message(message: str) -> str:
|
|
234
|
+
"""Clamp one jsonschema diagnostic to roughly ``MAX_SEED_ERROR_CHARS``.
|
|
235
|
+
|
|
236
|
+
Truncated in the **middle**, not at the end. jsonschema renders these as
|
|
237
|
+
``<instance> is too long`` / ``<instance> has too many items`` — the
|
|
238
|
+
offending value first and the *verdict* last — so clipping the tail keeps
|
|
239
|
+
the megabytes and throws away the only part that says what is wrong. Middle
|
|
240
|
+
truncation keeps both ends: enough of the value to recognize it, and the
|
|
241
|
+
phrase naming the violated keyword.
|
|
242
|
+
"""
|
|
243
|
+
if len(message) <= MAX_SEED_ERROR_CHARS:
|
|
244
|
+
return message
|
|
245
|
+
head = MAX_SEED_ERROR_CHARS * 2 // 3
|
|
246
|
+
tail = MAX_SEED_ERROR_CHARS - head
|
|
247
|
+
return f"{message[:head]} …(truncated)… {message[-tail:]}"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _schema_errors(payload: dict[str, Any], major: str) -> list[str]:
|
|
251
|
+
"""Run the structural pass, then the semantic passes it is a precondition for."""
|
|
252
|
+
errors: list[str] = [
|
|
253
|
+
f"{_json_path(e.absolute_path)}: {_bounded_message(e.message)}"
|
|
254
|
+
for e in sorted(_validator(major).iter_errors(payload), key=lambda e: list(e.absolute_path))
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
# Referential integrity only runs when the document is structurally sound —
|
|
258
|
+
# the cross-reference walk assumes well-typed slugs and arrays.
|
|
259
|
+
if not errors:
|
|
260
|
+
errors.extend(_referential_errors(payload))
|
|
261
|
+
errors.extend(_node_budget_errors(payload))
|
|
262
|
+
if major == "2":
|
|
263
|
+
errors.extend(_event_errors(payload))
|
|
264
|
+
errors.extend(_v21_event_errors(payload))
|
|
265
|
+
|
|
266
|
+
return errors
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def validate_seed(payload: Any, *, allow_sample_sections: bool = False) -> None:
|
|
270
|
+
"""Validate a parsed seed document. Returns ``None`` on success.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
payload: the already-parsed JSON (a ``dict``), not a raw string.
|
|
274
|
+
allow_sample_sections: see :func:`inspect_seed`. Default ``False``.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
SeedValidationError: with one message per problem found.
|
|
278
|
+
"""
|
|
279
|
+
report = inspect_seed(payload, allow_sample_sections=allow_sample_sections)
|
|
280
|
+
if not report.valid:
|
|
281
|
+
raise SeedValidationError(report.errors)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Restated, not imported: this module must stay importable without Django (the
|
|
285
|
+
# fixture build scripts in scripts/seeds call validate_seed with no settings), so it
|
|
286
|
+
# cannot reach projects.models. test_seed_v21 pins this copy to
|
|
287
|
+
# ``RESERVED_SCRUM_CEREMONY_NAMES``, and the schema's reaction ``emoji`` enum to
|
|
288
|
+
# ``ALLOWED_REACTION_EMOJI``, so neither can drift silently.
|
|
289
|
+
RESERVED_CEREMONY_NAMES = frozenset(
|
|
290
|
+
{
|
|
291
|
+
"sprint planning",
|
|
292
|
+
"sprint review",
|
|
293
|
+
"sprint retrospective",
|
|
294
|
+
"retrospective",
|
|
295
|
+
"retro",
|
|
296
|
+
"daily scrum",
|
|
297
|
+
"standup",
|
|
298
|
+
"daily standup",
|
|
299
|
+
"scrum of scrums",
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _sample_only_errors(payload: Any) -> list[str]:
|
|
305
|
+
"""Reject the sections only a bundled sample may carry (#3603).
|
|
306
|
+
|
|
307
|
+
``agent_actions`` writes rows into the instance's hash-chained audit log and
|
|
308
|
+
``share_links`` mints public bearer credentials. A user who can import a file
|
|
309
|
+
must be able to do neither: the first would forge audit history, the second
|
|
310
|
+
would publish a board. Rejected rather than dropped, because a dropped section
|
|
311
|
+
reports success for a document that did not import as written.
|
|
312
|
+
|
|
313
|
+
Reads defensively — this runs even on a document that fails the schema, so the
|
|
314
|
+
one diagnostic that explains *why* the section is refused is never hidden.
|
|
315
|
+
"""
|
|
316
|
+
if not isinstance(payload, dict):
|
|
317
|
+
return []
|
|
318
|
+
errors: list[str] = []
|
|
319
|
+
program = payload.get("program")
|
|
320
|
+
if isinstance(program, dict) and "agent_actions" in program:
|
|
321
|
+
errors.append(
|
|
322
|
+
"$.program.agent_actions: only bundled sample programs may carry agent "
|
|
323
|
+
"actions — audit evidence cannot be imported from a file"
|
|
324
|
+
)
|
|
325
|
+
projects = payload.get("projects")
|
|
326
|
+
if isinstance(projects, list):
|
|
327
|
+
for i, project in enumerate(projects):
|
|
328
|
+
if isinstance(project, dict) and "share_links" in project:
|
|
329
|
+
errors.append(
|
|
330
|
+
f"$.projects[{i}].share_links: only bundled sample programs may carry "
|
|
331
|
+
"share links — a public access credential cannot be imported from a file"
|
|
332
|
+
)
|
|
333
|
+
return errors
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _v21_reference_errors(payload: dict[str, Any], ctx: _RefContext, errors: list[str]) -> None:
|
|
337
|
+
"""Cross-references for the v2.1 program/project/task sections (#3603)."""
|
|
338
|
+
program = payload.get("program", {})
|
|
339
|
+
_v21_depth_errors(payload, ctx, errors)
|
|
340
|
+
_check_backlog_items(program.get("backlog_items", []), ctx, errors)
|
|
341
|
+
_check_ceremonies(program.get("ceremonies", []), errors)
|
|
342
|
+
_check_agent_actions(program.get("agent_actions", []), ctx, errors)
|
|
343
|
+
for i, project in enumerate(payload.get("projects", [])):
|
|
344
|
+
pbase = f"$.projects[{i}]"
|
|
345
|
+
for k, link in enumerate(project.get("share_links", [])):
|
|
346
|
+
_check_ref(
|
|
347
|
+
link.get("created_by"),
|
|
348
|
+
ctx.account_slugs,
|
|
349
|
+
f"{pbase}.share_links[{k}].created_by",
|
|
350
|
+
"account",
|
|
351
|
+
errors,
|
|
352
|
+
)
|
|
353
|
+
for j, task in enumerate(project.get("tasks", [])):
|
|
354
|
+
for k, criterion in enumerate(task.get("acceptance_criteria", [])):
|
|
355
|
+
_check_ref(
|
|
356
|
+
criterion.get("met_by"),
|
|
357
|
+
ctx.account_slugs,
|
|
358
|
+
f"{pbase}.tasks[{j}].acceptance_criteria[{k}].met_by",
|
|
359
|
+
"account",
|
|
360
|
+
errors,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
_WEEKDAY_BITS = {"mon": 1, "tue": 2, "wed": 4, "thu": 8, "fri": 16, "sat": 32, "sun": 64}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _v21_depth_errors(payload: dict[str, Any], ctx: _RefContext, errors: list[str]) -> None:
|
|
368
|
+
"""Team facets, drawer subtasks and recurrence rules (v2.1, #3498).
|
|
369
|
+
|
|
370
|
+
Mirrors the rules the API enforces on the same writes, so a seed cannot express a
|
|
371
|
+
team, a subtask tree or a recurrence the product would refuse to create.
|
|
372
|
+
"""
|
|
373
|
+
accounts_with_role = {
|
|
374
|
+
a.get("slug", "") for a in payload.get("accounts", []) if a.get("role") is not None
|
|
375
|
+
}
|
|
376
|
+
for i, project in enumerate(payload.get("projects", [])):
|
|
377
|
+
base = f"$.projects[{i}]"
|
|
378
|
+
if "members" in project:
|
|
379
|
+
members = {m.get("account", "") for m in project.get("members", [])}
|
|
380
|
+
else:
|
|
381
|
+
members = accounts_with_role
|
|
382
|
+
_check_team(project.get("team"), f"{base}.team", members, ctx, errors)
|
|
383
|
+
tasks = project.get("tasks", [])
|
|
384
|
+
by_wbs = {t.get("wbs_path"): t for t in tasks}
|
|
385
|
+
for j, task in enumerate(tasks):
|
|
386
|
+
tpath = f"{base}.tasks[{j}]"
|
|
387
|
+
if task.get("is_subtask"):
|
|
388
|
+
_check_subtask(task, by_wbs, tpath, errors)
|
|
389
|
+
if "recurrence" in task:
|
|
390
|
+
_check_recurrence(task, tpath, errors)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _check_team(
|
|
394
|
+
team: dict[str, Any] | None,
|
|
395
|
+
base: str,
|
|
396
|
+
project_members: set[str],
|
|
397
|
+
ctx: _RefContext,
|
|
398
|
+
errors: list[str],
|
|
399
|
+
) -> None:
|
|
400
|
+
"""A facet goes to a project member, and each facet has at most one holder.
|
|
401
|
+
|
|
402
|
+
``TeamMembership`` mirrors ``ProjectMembership`` onto the default team, so a facet
|
|
403
|
+
on a non-member has no row to land on. The one-holder rule is the soft singleton
|
|
404
|
+
the team service enforces on reassignment.
|
|
405
|
+
"""
|
|
406
|
+
if not team:
|
|
407
|
+
return
|
|
408
|
+
seen: set[str] = set()
|
|
409
|
+
holders = {"scrum_master": 0, "product_owner": 0}
|
|
410
|
+
for k, member in enumerate(team.get("members", [])):
|
|
411
|
+
mpath = f"{base}.members[{k}]"
|
|
412
|
+
account = member.get("account", "")
|
|
413
|
+
_check_ref(account, ctx.account_slugs, f"{mpath}.account", "account", errors)
|
|
414
|
+
if account in ctx.account_slugs and account not in project_members:
|
|
415
|
+
errors.append(f"{mpath}.account: {account!r} is not a member of this project")
|
|
416
|
+
if account in seen:
|
|
417
|
+
errors.append(f"{mpath}.account: {account!r} is listed twice")
|
|
418
|
+
seen.add(account)
|
|
419
|
+
for facet in holders:
|
|
420
|
+
if member.get(facet):
|
|
421
|
+
holders[facet] += 1
|
|
422
|
+
for facet, count in holders.items():
|
|
423
|
+
if count > 1:
|
|
424
|
+
errors.append(f"{base}.members: a team has at most one {facet}, found {count}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _check_subtask(
|
|
428
|
+
task: dict[str, Any], by_wbs: dict[Any, dict[str, Any]], tpath: str, errors: list[str]
|
|
429
|
+
) -> None:
|
|
430
|
+
"""Drawer subtasks are depth-1 leaves under a decomposable task (ADR-0060, #1750)."""
|
|
431
|
+
wbs = str(task.get("wbs_path", ""))
|
|
432
|
+
parent_path, _, _ = wbs.rpartition(".")
|
|
433
|
+
parent = by_wbs.get(parent_path) if parent_path else None
|
|
434
|
+
if parent is None:
|
|
435
|
+
where = parent_path or "?"
|
|
436
|
+
errors.append(f"{tpath}.is_subtask: a subtask needs a parent task at {where!r}")
|
|
437
|
+
return
|
|
438
|
+
if parent.get("is_milestone"):
|
|
439
|
+
errors.append(f"{tpath}.is_subtask: a milestone cannot hold subtasks")
|
|
440
|
+
if parent.get("is_subtask"):
|
|
441
|
+
errors.append(f"{tpath}.is_subtask: a subtask cannot hold subtasks")
|
|
442
|
+
prefix = f"{parent_path}."
|
|
443
|
+
for path, sibling in by_wbs.items():
|
|
444
|
+
child = str(path)
|
|
445
|
+
is_direct_child = child.startswith(prefix) and "." not in child[len(prefix) :]
|
|
446
|
+
if is_direct_child and not sibling.get("is_subtask"):
|
|
447
|
+
errors.append(
|
|
448
|
+
f"{tpath}.is_subtask: {parent_path!r} groups structural work, and a phase "
|
|
449
|
+
"cannot also hold subtasks"
|
|
450
|
+
)
|
|
451
|
+
break
|
|
452
|
+
if any(str(path).startswith(f"{wbs}.") for path in by_wbs):
|
|
453
|
+
errors.append(f"{tpath}.is_subtask: a subtask cannot have children")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _check_recurrence(task: dict[str, Any], tpath: str, errors: list[str]) -> None:
|
|
457
|
+
"""Mirror ``TaskRecurrenceRule.clean()`` plus the template constraints."""
|
|
458
|
+
rule = task.get("recurrence") or {}
|
|
459
|
+
rpath = f"{tpath}.recurrence"
|
|
460
|
+
frequency = rule.get("frequency")
|
|
461
|
+
if frequency == "WEEKLY" and not rule.get("weekdays"):
|
|
462
|
+
errors.append(f"{rpath}.weekdays: weekly recurrence requires at least one weekday")
|
|
463
|
+
if frequency == "MONTHLY" and rule.get("day_of_month") is None:
|
|
464
|
+
errors.append(f"{rpath}.day_of_month: monthly recurrence requires a day of month")
|
|
465
|
+
if "end_date" in rule and "end_count" in rule:
|
|
466
|
+
errors.append(f"{rpath}: give end_date or end_count, not both")
|
|
467
|
+
if task.get("is_milestone") or task.get("is_subtask"):
|
|
468
|
+
errors.append(f"{rpath}: a milestone or a subtask cannot be a recurrence template")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _check_backlog_items(items: list[dict[str, Any]], ctx: _RefContext, errors: list[str]) -> None:
|
|
472
|
+
"""A pulled item names exactly the one task it became (``pulled_task`` is 1:1)."""
|
|
473
|
+
_collect_slugs(items, "$.program.backlog_items", errors)
|
|
474
|
+
pulled: set[str] = set()
|
|
475
|
+
for i, item in enumerate(items):
|
|
476
|
+
base = f"$.program.backlog_items[{i}]"
|
|
477
|
+
for key in ("pulled_by", "created_by"):
|
|
478
|
+
_check_ref(item.get(key), ctx.account_slugs, f"{base}.{key}", "account", errors)
|
|
479
|
+
status = item.get("status", "proposed")
|
|
480
|
+
target = item.get("pulled_to")
|
|
481
|
+
if target is None:
|
|
482
|
+
if status == "pulled":
|
|
483
|
+
errors.append(f"{base}.pulled_to: a pulled item must name the task it became")
|
|
484
|
+
continue
|
|
485
|
+
if status != "pulled":
|
|
486
|
+
errors.append(
|
|
487
|
+
f"{base}.pulled_to: only a pulled item may name a task (status {status!r})"
|
|
488
|
+
)
|
|
489
|
+
_check_task_ref(target, "", ctx.task_index, f"{base}.pulled_to", errors)
|
|
490
|
+
if target in pulled:
|
|
491
|
+
errors.append(f"{base}.pulled_to: task {target!r} is already another item's pull")
|
|
492
|
+
pulled.add(target)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _check_ceremonies(items: list[dict[str, Any]], errors: list[str]) -> None:
|
|
496
|
+
"""Mirror ``CeremonyTemplateSerializer``: no sprint events, unique names, a clock anchor."""
|
|
497
|
+
seen: set[str] = set()
|
|
498
|
+
for i, ceremony in enumerate(items):
|
|
499
|
+
base = f"$.program.ceremonies[{i}]"
|
|
500
|
+
name = str(ceremony.get("name", "")).strip().casefold()
|
|
501
|
+
if name in RESERVED_CEREMONY_NAMES:
|
|
502
|
+
errors.append(
|
|
503
|
+
f"{base}.name: {ceremony.get('name')!r} is a team-level sprint event, "
|
|
504
|
+
"not a program ceremony (ADR-0079)"
|
|
505
|
+
)
|
|
506
|
+
if name in seen:
|
|
507
|
+
errors.append(f"{base}.name: duplicate ceremony name {ceremony.get('name')!r}")
|
|
508
|
+
seen.add(name)
|
|
509
|
+
cadence = ceremony.get("cadence_type")
|
|
510
|
+
if cadence != "on_milestone":
|
|
511
|
+
for key in ("cadence_day", "cadence_time"):
|
|
512
|
+
if not ceremony.get(key):
|
|
513
|
+
errors.append(f"{base}.{key}: required for a {cadence} cadence")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _check_agent_actions(items: list[dict[str, Any]], ctx: _RefContext, errors: list[str]) -> None:
|
|
517
|
+
"""Agent actions resolve to real accounts/projects/tasks; only refusals carry a refusal."""
|
|
518
|
+
for i, action in enumerate(items):
|
|
519
|
+
base = f"$.program.agent_actions[{i}]"
|
|
520
|
+
_check_ref(
|
|
521
|
+
action.get("principal"), ctx.account_slugs, f"{base}.principal", "account", errors
|
|
522
|
+
)
|
|
523
|
+
_check_ref(action.get("project"), ctx.project_slugs, f"{base}.project", "project", errors)
|
|
524
|
+
_check_task_ref(action.get("object"), "", ctx.task_index, f"{base}.object", errors)
|
|
525
|
+
if action.get("verdict") != "refused":
|
|
526
|
+
for key in ("refusal_reason", "refusal_constraint"):
|
|
527
|
+
if key in action:
|
|
528
|
+
errors.append(f"{base}.{key}: only a refused action carries a refusal")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
_ACTION_TIME_LOG = "time.log"
|
|
532
|
+
_ACTION_TASK_REACT = "task.react"
|
|
533
|
+
_ACTION_TASK_ACK = "task.ack"
|
|
534
|
+
_ACTION_TASK_COMMENT = "task.comment"
|
|
535
|
+
|
|
536
|
+
# Actions whose attribution is the whole fact: a time entry, a reaction or an
|
|
537
|
+
# acknowledgement by nobody in particular is not something to fall back to the
|
|
538
|
+
# importing owner for, so each must name its actor.
|
|
539
|
+
_ACTOR_REQUIRED = frozenset({_ACTION_TIME_LOG, _ACTION_TASK_REACT, _ACTION_TASK_ACK})
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _v21_event_errors(payload: dict[str, Any]) -> list[str]:
|
|
543
|
+
"""Comment threading, comment-targeted beats, criterion ticks and time beats (#3603)."""
|
|
544
|
+
errors: list[str] = []
|
|
545
|
+
events = payload.get("events", [])
|
|
546
|
+
# Top-level comment slug -> the task target it was posted on, so a reply can be
|
|
547
|
+
# held to the same task. Replies keep only their slug: nothing may answer them.
|
|
548
|
+
roots: dict[str, str] = {}
|
|
549
|
+
replies: set[str] = set()
|
|
550
|
+
for i, event in enumerate(events):
|
|
551
|
+
if event.get("action") != _ACTION_TASK_COMMENT or "slug" not in event:
|
|
552
|
+
continue
|
|
553
|
+
slug = event["slug"]
|
|
554
|
+
if slug in roots or slug in replies:
|
|
555
|
+
errors.append(f"$.events[{i}].slug: duplicate comment slug {slug!r}")
|
|
556
|
+
continue
|
|
557
|
+
if event.get("reply_to"):
|
|
558
|
+
replies.add(slug)
|
|
559
|
+
else:
|
|
560
|
+
roots[slug] = str(event.get("target", ""))
|
|
561
|
+
|
|
562
|
+
criteria_count = {
|
|
563
|
+
(project.get("slug", ""), task.get("wbs_path")): len(task.get("acceptance_criteria", []))
|
|
564
|
+
for project in payload.get("projects", [])
|
|
565
|
+
for task in project.get("tasks", [])
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for i, event in enumerate(events):
|
|
569
|
+
base = f"$.events[{i}]"
|
|
570
|
+
action = event.get("action")
|
|
571
|
+
_check_reply_to(event, action, base, roots, replies, errors)
|
|
572
|
+
if action in (_ACTION_TASK_REACT, _ACTION_TASK_ACK):
|
|
573
|
+
_, _, slug = str(event.get("target", "")).partition(":")
|
|
574
|
+
if slug and slug not in roots and slug not in replies:
|
|
575
|
+
errors.append(f"{base}.target: no comment with slug {slug!r}")
|
|
576
|
+
if action in _ACTOR_REQUIRED and not event.get("actor"):
|
|
577
|
+
errors.append(f"{base}.actor: {action} must name the account it is attributed to")
|
|
578
|
+
if action == _ACTION_TASK_REACT and "emoji" not in event:
|
|
579
|
+
errors.append(f"{base}.emoji: task.react requires an emoji")
|
|
580
|
+
if action == "task.note" and not event.get("body"):
|
|
581
|
+
errors.append(f"{base}.body: task.note requires a body")
|
|
582
|
+
if action == _ACTION_TIME_LOG:
|
|
583
|
+
_check_time_log(event, base, errors)
|
|
584
|
+
if "criterion" in event:
|
|
585
|
+
_check_criterion(event, action, base, criteria_count, errors)
|
|
586
|
+
return errors
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _check_reply_to(
|
|
590
|
+
event: dict[str, Any],
|
|
591
|
+
action: Any,
|
|
592
|
+
base: str,
|
|
593
|
+
roots: dict[str, str],
|
|
594
|
+
replies: set[str],
|
|
595
|
+
errors: list[str],
|
|
596
|
+
) -> None:
|
|
597
|
+
"""Replies are one level deep and stay on their thread's task, as the API enforces."""
|
|
598
|
+
reply_to = event.get("reply_to")
|
|
599
|
+
if reply_to is None:
|
|
600
|
+
return
|
|
601
|
+
if action != _ACTION_TASK_COMMENT:
|
|
602
|
+
errors.append(f"{base}.reply_to: only task.comment may reply to a comment")
|
|
603
|
+
elif reply_to in replies:
|
|
604
|
+
errors.append(
|
|
605
|
+
f"{base}.reply_to: {reply_to!r} is itself a reply; replies are one level deep"
|
|
606
|
+
)
|
|
607
|
+
elif reply_to not in roots:
|
|
608
|
+
errors.append(f"{base}.reply_to: no comment with slug {reply_to!r}")
|
|
609
|
+
elif roots[reply_to] != event.get("target"):
|
|
610
|
+
errors.append(
|
|
611
|
+
f"{base}.reply_to: comment {reply_to!r} is on {roots[reply_to]!r}, "
|
|
612
|
+
"and a reply must be posted on the same task"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _check_time_log(event: dict[str, Any], base: str, errors: list[str]) -> None:
|
|
617
|
+
"""Logged time needs minutes and cannot be dated after the anchor ("today")."""
|
|
618
|
+
if "minutes" not in event:
|
|
619
|
+
errors.append(f"{base}.minutes: time.log requires minutes")
|
|
620
|
+
at = str(event.get("at", ""))
|
|
621
|
+
if at.startswith("A+"):
|
|
622
|
+
digits = at[2:].split("T")[0]
|
|
623
|
+
if digits.isdigit() and int(digits) > 0:
|
|
624
|
+
errors.append(
|
|
625
|
+
f"{base}.at: time.log cannot be forward-dated ({at!r} is after the anchor)"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _check_criterion(
|
|
630
|
+
event: dict[str, Any],
|
|
631
|
+
action: Any,
|
|
632
|
+
base: str,
|
|
633
|
+
criteria_count: dict[tuple[str, Any], int],
|
|
634
|
+
errors: list[str],
|
|
635
|
+
) -> None:
|
|
636
|
+
"""A ``criterion`` index must address a criterion the target task declares."""
|
|
637
|
+
if action != "task.ac_met":
|
|
638
|
+
errors.append(f"{base}.criterion: only task.ac_met may tick a criterion")
|
|
639
|
+
return
|
|
640
|
+
_, _, ref = str(event.get("target", "")).partition(":")
|
|
641
|
+
project_slug, _, wbs = ref.partition(":")
|
|
642
|
+
count = criteria_count.get((project_slug, wbs))
|
|
643
|
+
if count is not None and event["criterion"] >= count:
|
|
644
|
+
errors.append(
|
|
645
|
+
f"{base}.criterion: task {ref!r} declares {count} acceptance criteria, "
|
|
646
|
+
f"so index {event['criterion']} does not exist"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# Which target kind each event action addresses. Every replayed action carries a
|
|
651
|
+
# target; an unknown action (should never reach here — the schema enum gates it)
|
|
652
|
+
# is treated as target-optional so validation degrades gracefully.
|
|
653
|
+
_EVENT_TARGET_KIND = {
|
|
654
|
+
"task.note": "task",
|
|
655
|
+
_ACTION_TIME_LOG: "task",
|
|
656
|
+
# A reaction/acknowledgement lands on a comment, addressed by the slug a
|
|
657
|
+
# task.comment beat declared. Resolution is checked in _v21_event_errors.
|
|
658
|
+
_ACTION_TASK_REACT: "comment",
|
|
659
|
+
_ACTION_TASK_ACK: "comment",
|
|
660
|
+
"task.status": "task",
|
|
661
|
+
"task.assign": "task",
|
|
662
|
+
"task.estimate": "task",
|
|
663
|
+
"task.points": "task",
|
|
664
|
+
_ACTION_TASK_COMMENT: "task",
|
|
665
|
+
"task.ac_met": "task",
|
|
666
|
+
"task.block": "task",
|
|
667
|
+
"task.unblock": "task",
|
|
668
|
+
"sprint.activate": "sprint",
|
|
669
|
+
"sprint.close": "sprint",
|
|
670
|
+
# Scope inject/resolve target the task being injected; its ACTIVE sprint is
|
|
671
|
+
# derived from task.sprint, matching record_sprint_scope_change(task, sprint).
|
|
672
|
+
# A re-imported export can name a task that has since left the sprint (a
|
|
673
|
+
# close carried it to the backlog), so replay falls back to the sprint
|
|
674
|
+
# running at the beat's own instant — see replay._injected_sprint (#3488).
|
|
675
|
+
"sprint.scope_inject": "task",
|
|
676
|
+
"sprint.scope_resolve": "task",
|
|
677
|
+
"baseline.capture": "project",
|
|
678
|
+
"risk.status": "risk",
|
|
679
|
+
"risk.note": "risk",
|
|
680
|
+
# retro.action creates an action item on the sprint's retro; retro.promote
|
|
681
|
+
# promotes one (matched by `body`) to a backlog task. Both are per-sprint —
|
|
682
|
+
# SprintRetro is 1:1 with Sprint — so the target is the sprint (ADR-0114 §7).
|
|
683
|
+
"retro.action": "sprint",
|
|
684
|
+
"retro.promote": "sprint",
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _event_errors(payload: dict[str, Any]) -> list[str]:
|
|
689
|
+
"""Validate v2 event actor + target references against the document (ADR-0114).
|
|
690
|
+
|
|
691
|
+
Structural shape is already enforced by the v2 JSON Schema; this pass checks
|
|
692
|
+
that every ``actor`` names an account and every ``target`` resolves to a
|
|
693
|
+
task / sprint / project / risk that the same document defines.
|
|
694
|
+
"""
|
|
695
|
+
errors: list[str] = []
|
|
696
|
+
account_slugs = {a.get("slug", "") for a in payload.get("accounts", [])}
|
|
697
|
+
project_slugs, task_index, sprint_index, risk_slugs = _event_indexes(payload)
|
|
698
|
+
|
|
699
|
+
for i, event in enumerate(payload.get("events", [])):
|
|
700
|
+
base = f"$.events[{i}]"
|
|
701
|
+
_check_ref(event.get("actor"), account_slugs, f"{base}.actor", "account", errors)
|
|
702
|
+
_check_ref(event.get("assignee"), account_slugs, f"{base}.assignee", "account", errors)
|
|
703
|
+
_check_event_target_ref(
|
|
704
|
+
event, base, project_slugs, task_index, sprint_index, risk_slugs, errors
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
return errors
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _event_indexes(
|
|
711
|
+
payload: dict[str, Any],
|
|
712
|
+
) -> tuple[set[str], dict[str, set[str]], dict[str, set[str]], set[str]]:
|
|
713
|
+
"""Index project slugs, task/sprint paths, and risk slugs for event refs."""
|
|
714
|
+
project_slugs: set[str] = set()
|
|
715
|
+
task_index: dict[str, set[str]] = {}
|
|
716
|
+
sprint_index: dict[str, set[str]] = {}
|
|
717
|
+
risk_slugs: set[str] = {r.get("slug", "") for r in payload.get("risks", [])}
|
|
718
|
+
for project in payload.get("projects", []):
|
|
719
|
+
slug = project.get("slug", "")
|
|
720
|
+
project_slugs.add(slug)
|
|
721
|
+
task_index[slug] = {t.get("wbs_path") for t in project.get("tasks", [])}
|
|
722
|
+
sprint_index[slug] = {s.get("slug") for s in project.get("sprints", [])}
|
|
723
|
+
risk_slugs |= {r.get("slug", "") for r in project.get("risks", [])}
|
|
724
|
+
return project_slugs, task_index, sprint_index, risk_slugs
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _check_event_target_ref(
|
|
728
|
+
event: dict[str, Any],
|
|
729
|
+
base: str,
|
|
730
|
+
project_slugs: set[str],
|
|
731
|
+
task_index: dict[str, set[str]],
|
|
732
|
+
sprint_index: dict[str, set[str]],
|
|
733
|
+
risk_slugs: set[str],
|
|
734
|
+
errors: list[str],
|
|
735
|
+
) -> None:
|
|
736
|
+
"""Validate one event's ``target`` (kind/prefix/resolution) and estimate order."""
|
|
737
|
+
action = event.get("action", "")
|
|
738
|
+
kind = _EVENT_TARGET_KIND.get(action)
|
|
739
|
+
target = event.get("target")
|
|
740
|
+
if kind is None:
|
|
741
|
+
return # unknown action (schema enum should have rejected it)
|
|
742
|
+
if target is None:
|
|
743
|
+
errors.append(f"{base}.target: action {action!r} requires a target")
|
|
744
|
+
return
|
|
745
|
+
prefix, _, ref = target.partition(":")
|
|
746
|
+
if prefix != kind:
|
|
747
|
+
errors.append(f"{base}.target: action {action!r} expects a {kind!r} target, got {target!r}")
|
|
748
|
+
return
|
|
749
|
+
_check_event_target(
|
|
750
|
+
kind, ref, base, project_slugs, task_index, sprint_index, risk_slugs, errors
|
|
751
|
+
)
|
|
752
|
+
# A task.estimate event re-points a triple mid-timeline; it must stay ordered,
|
|
753
|
+
# or replay persists a triple the DB CheckConstraint rejects (#2005). Only a
|
|
754
|
+
# target that passed the checks above reaches this point, matching the original
|
|
755
|
+
# sequential flow (a bad target short-circuits before the estimate check).
|
|
756
|
+
if action == "task.estimate":
|
|
757
|
+
_check_estimate_order(event, base, errors)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _check_event_target(
|
|
761
|
+
kind: str,
|
|
762
|
+
ref: str,
|
|
763
|
+
base: str,
|
|
764
|
+
project_slugs: set[str],
|
|
765
|
+
task_index: dict[str, set[str]],
|
|
766
|
+
sprint_index: dict[str, set[str]],
|
|
767
|
+
risk_slugs: set[str],
|
|
768
|
+
errors: list[str],
|
|
769
|
+
) -> None:
|
|
770
|
+
"""Resolve one event target ref of a given kind against the document."""
|
|
771
|
+
if kind == "task":
|
|
772
|
+
# Event task refs must be project-qualified (events are global/ordered,
|
|
773
|
+
# there is no enclosing project to fall back to).
|
|
774
|
+
if ":" not in ref:
|
|
775
|
+
errors.append(f'{base}.target: task ref {ref!r} must be "<project-slug>:<wbs-path>"')
|
|
776
|
+
return
|
|
777
|
+
_check_task_ref(ref, "", task_index, f"{base}.target", errors)
|
|
778
|
+
elif kind == "sprint":
|
|
779
|
+
project_slug, _, sprint_slug = ref.partition(":")
|
|
780
|
+
sprints = sprint_index.get(project_slug)
|
|
781
|
+
if sprints is None:
|
|
782
|
+
errors.append(f"{base}.target: no project {project_slug!r} for sprint ref {ref!r}")
|
|
783
|
+
elif sprint_slug not in sprints:
|
|
784
|
+
errors.append(f"{base}.target: no sprint {sprint_slug!r} in project {project_slug!r}")
|
|
785
|
+
elif kind == "project" and ref not in project_slugs:
|
|
786
|
+
errors.append(f"{base}.target: no project with slug {ref!r}")
|
|
787
|
+
elif kind == "risk":
|
|
788
|
+
if ref not in risk_slugs:
|
|
789
|
+
errors.append(f"{base}.target: no risk with slug {ref!r}")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _node_budget_errors(payload: dict[str, Any]) -> list[str]:
|
|
793
|
+
"""Reject documents whose total materializable entity count is excessive.
|
|
794
|
+
|
|
795
|
+
Every collection counted here becomes rows the importer writes inside one
|
|
796
|
+
transaction, so the budget has to include the *containers* too, not only
|
|
797
|
+
their contents (#2615). ``projects`` and ``resources`` were previously
|
|
798
|
+
omitted: a project costs ~5 statements plus a ``cascade_project_soft_delete``
|
|
799
|
+
enqueue on the replace path, and each distinct resource costs ~2 round-trips
|
|
800
|
+
through ``ensure_project_resource`` — both were invisible to the ceiling
|
|
801
|
+
while a document of nothing but empty projects scored zero.
|
|
802
|
+
"""
|
|
803
|
+
program = payload.get("program", {})
|
|
804
|
+
total = (
|
|
805
|
+
len(payload.get("projects", []))
|
|
806
|
+
+ len(payload.get("resources", []))
|
|
807
|
+
+ len(payload.get("accounts", []))
|
|
808
|
+
+ len(payload.get("calendars", []))
|
|
809
|
+
+ len(payload.get("risks", []))
|
|
810
|
+
# v2.1 program sections (#3603) each materialize one row per entry.
|
|
811
|
+
+ len(program.get("backlog_items", []))
|
|
812
|
+
+ len(program.get("ceremonies", []))
|
|
813
|
+
+ len(program.get("agent_actions", []))
|
|
814
|
+
)
|
|
815
|
+
for project in payload.get("projects", []):
|
|
816
|
+
total += (
|
|
817
|
+
len(project.get("tasks", []))
|
|
818
|
+
+ len(project.get("dependencies", []))
|
|
819
|
+
+ len(project.get("sprints", []))
|
|
820
|
+
+ len(project.get("risks", []))
|
|
821
|
+
+ len(project.get("baselines", []))
|
|
822
|
+
+ len(project.get("labels", []))
|
|
823
|
+
+ len(project.get("share_links", []))
|
|
824
|
+
+ sum(len(t.get("acceptance_criteria", [])) for t in project.get("tasks", []))
|
|
825
|
+
# TaskRelation rows materialize like dependencies, so they count
|
|
826
|
+
# toward the per-import ceiling (ADR-0455).
|
|
827
|
+
+ sum(len(t.get("links", [])) for t in project.get("tasks", []))
|
|
828
|
+
)
|
|
829
|
+
if total > MAX_SEED_NODES:
|
|
830
|
+
return [f"$: seed too large — {total} entities exceeds the {MAX_SEED_NODES} limit"]
|
|
831
|
+
return []
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@dataclass(frozen=True)
|
|
835
|
+
class _RefContext:
|
|
836
|
+
"""Global slug catalogs + the task index, resolved once per seed pass.
|
|
837
|
+
|
|
838
|
+
Bundling the document-wide sets (which every per-project and per-task check
|
|
839
|
+
reads but none mutates) keeps the helper signatures small — the alternative
|
|
840
|
+
is threading five ``set`` parameters through every extraction.
|
|
841
|
+
"""
|
|
842
|
+
|
|
843
|
+
account_slugs: set[str]
|
|
844
|
+
calendar_slugs: set[str]
|
|
845
|
+
resource_slugs: set[str]
|
|
846
|
+
project_slugs: set[str]
|
|
847
|
+
task_index: dict[str, set[str]]
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _referential_errors(payload: dict[str, Any]) -> list[str]:
|
|
851
|
+
"""Check duplicate slugs and dangling cross-references (ADR-0109 identity)."""
|
|
852
|
+
errors: list[str] = []
|
|
853
|
+
|
|
854
|
+
account_slugs = _collect_slugs(payload.get("accounts", []), "$.accounts", errors)
|
|
855
|
+
calendar_slugs = _collect_slugs(payload.get("calendars", []), "$.calendars", errors)
|
|
856
|
+
resource_slugs = _collect_slugs(payload.get("resources", []), "$.resources", errors)
|
|
857
|
+
|
|
858
|
+
projects = payload.get("projects", [])
|
|
859
|
+
project_slugs, task_index = _build_project_index(projects, errors)
|
|
860
|
+
ctx = _RefContext(account_slugs, calendar_slugs, resource_slugs, project_slugs, task_index)
|
|
861
|
+
|
|
862
|
+
# program.lead -> account
|
|
863
|
+
program = payload.get("program", {})
|
|
864
|
+
_check_ref(program.get("lead"), account_slugs, "$.program.lead", "account", errors)
|
|
865
|
+
|
|
866
|
+
# resource.calendar -> calendar, resource.account -> account
|
|
867
|
+
for i, resource in enumerate(payload.get("resources", [])):
|
|
868
|
+
base = f"$.resources[{i}]"
|
|
869
|
+
_check_ref(resource.get("calendar"), calendar_slugs, f"{base}.calendar", "calendar", errors)
|
|
870
|
+
_check_ref(resource.get("account"), account_slugs, f"{base}.account", "account", errors)
|
|
871
|
+
|
|
872
|
+
# program-scoped risks
|
|
873
|
+
_check_risks(
|
|
874
|
+
payload.get("risks", []), "$.risks", None, account_slugs, task_index, project_slugs, errors
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
# per-project references
|
|
878
|
+
for i, project in enumerate(projects):
|
|
879
|
+
_project_reference_errors(i, project, ctx, errors)
|
|
880
|
+
|
|
881
|
+
_v21_reference_errors(payload, ctx, errors)
|
|
882
|
+
return errors
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _build_project_index(
|
|
886
|
+
projects: list[dict[str, Any]], errors: list[str]
|
|
887
|
+
) -> tuple[set[str], dict[str, set[str]]]:
|
|
888
|
+
"""Index project slugs and their wbs paths so task refs resolve anywhere.
|
|
889
|
+
|
|
890
|
+
Builds the global task index (project slug -> set of wbs paths) so a task
|
|
891
|
+
ref — bare or ``<project>:<wbs>`` qualified — can be resolved from any
|
|
892
|
+
scope, and records duplicate project slugs while walking.
|
|
893
|
+
"""
|
|
894
|
+
project_slugs: set[str] = set()
|
|
895
|
+
task_index: dict[str, set[str]] = {}
|
|
896
|
+
for i, project in enumerate(projects):
|
|
897
|
+
slug = project.get("slug", "")
|
|
898
|
+
path = f"$.projects[{i}]"
|
|
899
|
+
if slug in project_slugs:
|
|
900
|
+
errors.append(f"{path}.slug: duplicate project slug {slug!r}")
|
|
901
|
+
project_slugs.add(slug)
|
|
902
|
+
task_index[slug] = {t.get("wbs_path") for t in project.get("tasks", [])}
|
|
903
|
+
return project_slugs, task_index
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _project_reference_errors(
|
|
907
|
+
i: int, project: dict[str, Any], ctx: _RefContext, errors: list[str]
|
|
908
|
+
) -> None:
|
|
909
|
+
"""Validate every cross-reference scoped to a single project."""
|
|
910
|
+
slug = project.get("slug", "")
|
|
911
|
+
base = f"$.projects[{i}]"
|
|
912
|
+
_check_ref(project.get("calendar"), ctx.calendar_slugs, f"{base}.calendar", "calendar", errors)
|
|
913
|
+
own_tasks = ctx.task_index.get(slug, set())
|
|
914
|
+
|
|
915
|
+
# members[].account -> accounts[] (#3092). A dangling ref here is silent
|
|
916
|
+
# otherwise: the importer resolves it to None and skips the grant, so the
|
|
917
|
+
# persona simply cannot see the project — the exact failure this key exists
|
|
918
|
+
# to fix, reintroduced by a typo.
|
|
919
|
+
for k, member in enumerate(project.get("members", [])):
|
|
920
|
+
_check_ref(
|
|
921
|
+
member.get("account"),
|
|
922
|
+
ctx.account_slugs,
|
|
923
|
+
f"{base}.members[{k}].account",
|
|
924
|
+
"account",
|
|
925
|
+
errors,
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
sprint_slugs = _collect_slugs(project.get("sprints", []), f"{base}.sprints", errors)
|
|
929
|
+
# Project labels (ADR-0400, #1958): collect the label catalog so each task's
|
|
930
|
+
# label slug refs can be checked against it (dangling label ref).
|
|
931
|
+
label_slugs = _collect_slugs(project.get("labels", []), f"{base}.labels", errors)
|
|
932
|
+
_check_sprint_milestones(project, base, own_tasks, errors)
|
|
933
|
+
|
|
934
|
+
seen_wbs: set[str] = set()
|
|
935
|
+
for j, task in enumerate(project.get("tasks", [])):
|
|
936
|
+
tpath = f"{base}.tasks[{j}]"
|
|
937
|
+
wbs = task.get("wbs_path", "")
|
|
938
|
+
if wbs in seen_wbs:
|
|
939
|
+
errors.append(f"{tpath}.wbs_path: duplicate path {wbs!r} in this project")
|
|
940
|
+
seen_wbs.add(wbs)
|
|
941
|
+
_task_reference_errors(
|
|
942
|
+
task, wbs, tpath, slug, sprint_slugs, label_slugs, own_tasks, ctx, errors
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
_check_dependencies(project, base, slug, ctx.task_index, errors)
|
|
946
|
+
_check_baselines(project, base, own_tasks, errors)
|
|
947
|
+
_check_risks(
|
|
948
|
+
project.get("risks", []),
|
|
949
|
+
f"{base}.risks",
|
|
950
|
+
slug,
|
|
951
|
+
ctx.account_slugs,
|
|
952
|
+
ctx.task_index,
|
|
953
|
+
ctx.project_slugs,
|
|
954
|
+
errors,
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _check_sprint_milestones(
|
|
959
|
+
project: dict[str, Any], base: str, own_tasks: set[str], errors: list[str]
|
|
960
|
+
) -> None:
|
|
961
|
+
"""A sprint's ``target_milestone`` must name a task in the same project."""
|
|
962
|
+
for j, sprint in enumerate(project.get("sprints", [])):
|
|
963
|
+
milestone = sprint.get("target_milestone")
|
|
964
|
+
if milestone is not None and milestone not in own_tasks:
|
|
965
|
+
errors.append(
|
|
966
|
+
f"{base}.sprints[{j}].target_milestone: no task {milestone!r} in this project"
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _task_reference_errors(
|
|
971
|
+
task: dict[str, Any],
|
|
972
|
+
wbs: str,
|
|
973
|
+
tpath: str,
|
|
974
|
+
project_slug: str,
|
|
975
|
+
sprint_slugs: set[str],
|
|
976
|
+
label_slugs: set[str],
|
|
977
|
+
own_tasks: set[str],
|
|
978
|
+
ctx: _RefContext,
|
|
979
|
+
errors: list[str],
|
|
980
|
+
) -> None:
|
|
981
|
+
"""Validate the cross-references a single task carries."""
|
|
982
|
+
_check_ref(task.get("assignee"), ctx.account_slugs, f"{tpath}.assignee", "account", errors)
|
|
983
|
+
_check_ref(task.get("sprint"), sprint_slugs, f"{tpath}.sprint", "sprint", errors)
|
|
984
|
+
parent = task.get("parent_epic")
|
|
985
|
+
if parent is not None and parent not in own_tasks:
|
|
986
|
+
errors.append(f"{tpath}.parent_epic: no task {parent!r} in this project")
|
|
987
|
+
for k, label_ref in enumerate(task.get("labels", [])):
|
|
988
|
+
_check_ref(label_ref, label_slugs, f"{tpath}.labels[{k}]", "label", errors)
|
|
989
|
+
_check_task_links(task, wbs, project_slug, ctx.task_index, tpath, errors)
|
|
990
|
+
_check_estimate_order(task, tpath, errors)
|
|
991
|
+
for k, assignment in enumerate(task.get("assignments", [])):
|
|
992
|
+
_check_ref(
|
|
993
|
+
assignment.get("resource"),
|
|
994
|
+
ctx.resource_slugs,
|
|
995
|
+
f"{tpath}.assignments[{k}].resource",
|
|
996
|
+
"resource",
|
|
997
|
+
errors,
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _check_task_links(
|
|
1002
|
+
task: dict[str, Any],
|
|
1003
|
+
wbs: str,
|
|
1004
|
+
project_slug: str,
|
|
1005
|
+
task_index: dict[str, set[str]],
|
|
1006
|
+
tpath: str,
|
|
1007
|
+
errors: list[str],
|
|
1008
|
+
) -> None:
|
|
1009
|
+
"""Validate informational task-to-task relations (ADR-0455).
|
|
1010
|
+
|
|
1011
|
+
The target must resolve to a real task; a bare wbs is enclosing-project, a
|
|
1012
|
+
``<slug>:<wbs>`` ref a sibling project. Because a seed document is a single
|
|
1013
|
+
program, ``_check_task_ref``'s "no project with slug" error is itself the
|
|
1014
|
+
cross-program guard — a resolvable target is always in the same program as
|
|
1015
|
+
the source (the ADR-0120 D1 envelope). Self-links are rejected here too
|
|
1016
|
+
(inert; the DB CheckConstraint is the backstop).
|
|
1017
|
+
"""
|
|
1018
|
+
for k, link in enumerate(task.get("links", [])):
|
|
1019
|
+
lpath = f"{tpath}.links[{k}].target"
|
|
1020
|
+
target = link.get("target")
|
|
1021
|
+
_check_task_ref(target, project_slug, task_index, lpath, errors)
|
|
1022
|
+
if target is not None:
|
|
1023
|
+
if ":" in target:
|
|
1024
|
+
tproj, _, twbs = target.partition(":")
|
|
1025
|
+
else:
|
|
1026
|
+
tproj, twbs = project_slug, target
|
|
1027
|
+
if tproj == project_slug and twbs == wbs:
|
|
1028
|
+
errors.append(f"{lpath}: a task cannot link to itself")
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _check_estimate_order(task: dict[str, Any], tpath: str, errors: list[str]) -> None:
|
|
1032
|
+
"""A complete three-point estimate must be ordered.
|
|
1033
|
+
|
|
1034
|
+
This is the same invariant the engine, the REST serializer, and the DB
|
|
1035
|
+
CheckConstraint enforce (#2005). Caught here so a mis-authored seed fails
|
|
1036
|
+
loudly at build/CI time (``test_sample_content`` runs ``validate_seed``)
|
|
1037
|
+
rather than as a runtime IntegrityError on import.
|
|
1038
|
+
"""
|
|
1039
|
+
est = task.get("estimate")
|
|
1040
|
+
if isinstance(est, dict):
|
|
1041
|
+
o, m, p = est.get("optimistic"), est.get("most_likely"), est.get("pessimistic")
|
|
1042
|
+
if o is not None and m is not None and p is not None and not (o <= m <= p):
|
|
1043
|
+
errors.append(
|
|
1044
|
+
f"{tpath}.estimate: three-point estimate must satisfy "
|
|
1045
|
+
f"optimistic <= most_likely <= pessimistic (got {o} <= {m} <= {p})"
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _check_dependencies(
|
|
1050
|
+
project: dict[str, Any],
|
|
1051
|
+
base: str,
|
|
1052
|
+
project_slug: str,
|
|
1053
|
+
task_index: dict[str, set[str]],
|
|
1054
|
+
errors: list[str],
|
|
1055
|
+
) -> None:
|
|
1056
|
+
"""Both endpoints of every dependency edge must resolve to a task."""
|
|
1057
|
+
for j, dep in enumerate(project.get("dependencies", [])):
|
|
1058
|
+
dpath = f"{base}.dependencies[{j}]"
|
|
1059
|
+
_check_task_ref(
|
|
1060
|
+
dep.get("predecessor"), project_slug, task_index, f"{dpath}.predecessor", errors
|
|
1061
|
+
)
|
|
1062
|
+
_check_task_ref(
|
|
1063
|
+
dep.get("successor"), project_slug, task_index, f"{dpath}.successor", errors
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _check_baselines(
|
|
1068
|
+
project: dict[str, Any], base: str, own_tasks: set[str], errors: list[str]
|
|
1069
|
+
) -> None:
|
|
1070
|
+
"""Every baselined task must name a task in the same project."""
|
|
1071
|
+
for j, bl in enumerate(project.get("baselines", [])):
|
|
1072
|
+
for k, bt in enumerate(bl.get("tasks", [])):
|
|
1073
|
+
ref = bt.get("task")
|
|
1074
|
+
if ref not in own_tasks:
|
|
1075
|
+
errors.append(
|
|
1076
|
+
f"{base}.baselines[{j}].tasks[{k}].task: no task {ref!r} in this project"
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _collect_slugs(items: list[dict[str, Any]], base: str, errors: list[str]) -> set[str]:
|
|
1081
|
+
"""Gather slugs from a collection, recording duplicates as errors."""
|
|
1082
|
+
slugs: set[str] = set()
|
|
1083
|
+
for i, item in enumerate(items):
|
|
1084
|
+
slug = item.get("slug", "")
|
|
1085
|
+
if slug in slugs:
|
|
1086
|
+
errors.append(f"{base}[{i}].slug: duplicate slug {slug!r}")
|
|
1087
|
+
slugs.add(slug)
|
|
1088
|
+
return slugs
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _check_ref(value: str | None, valid: set[str], path: str, kind: str, errors: list[str]) -> None:
|
|
1092
|
+
"""A simple slug reference must name an entry in ``valid`` when present."""
|
|
1093
|
+
if value is not None and value not in valid:
|
|
1094
|
+
errors.append(f"{path}: no {kind} with slug {value!r}")
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _check_task_ref(
|
|
1098
|
+
ref: str | None,
|
|
1099
|
+
enclosing_project: str,
|
|
1100
|
+
task_index: dict[str, set[str]],
|
|
1101
|
+
path: str,
|
|
1102
|
+
errors: list[str],
|
|
1103
|
+
) -> None:
|
|
1104
|
+
"""Resolve a bare or ``<project>:<wbs>`` task ref against the global index."""
|
|
1105
|
+
if ref is None:
|
|
1106
|
+
return
|
|
1107
|
+
if ":" in ref:
|
|
1108
|
+
project_slug, _, wbs = ref.partition(":")
|
|
1109
|
+
else:
|
|
1110
|
+
project_slug, wbs = enclosing_project, ref
|
|
1111
|
+
tasks = task_index.get(project_slug)
|
|
1112
|
+
if tasks is None:
|
|
1113
|
+
errors.append(f"{path}: no project with slug {project_slug!r} for task ref {ref!r}")
|
|
1114
|
+
elif wbs not in tasks:
|
|
1115
|
+
errors.append(f"{path}: no task {wbs!r} in project {project_slug!r}")
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _check_risks(
|
|
1119
|
+
risks: list[dict[str, Any]],
|
|
1120
|
+
base: str,
|
|
1121
|
+
enclosing_project: str | None,
|
|
1122
|
+
account_slugs: set[str],
|
|
1123
|
+
task_index: dict[str, set[str]],
|
|
1124
|
+
project_slugs: set[str],
|
|
1125
|
+
errors: list[str],
|
|
1126
|
+
) -> None:
|
|
1127
|
+
"""Validate risk owner and task-linkage references.
|
|
1128
|
+
|
|
1129
|
+
Program-scoped risks (``enclosing_project is None``) must qualify every task
|
|
1130
|
+
ref with a project slug, because a bare path has no project to resolve
|
|
1131
|
+
against.
|
|
1132
|
+
"""
|
|
1133
|
+
seen: set[str] = set()
|
|
1134
|
+
for i, risk in enumerate(risks):
|
|
1135
|
+
rpath = f"{base}[{i}]"
|
|
1136
|
+
slug = risk.get("slug", "")
|
|
1137
|
+
if slug in seen:
|
|
1138
|
+
errors.append(f"{rpath}.slug: duplicate risk slug {slug!r}")
|
|
1139
|
+
seen.add(slug)
|
|
1140
|
+
_check_ref(risk.get("owner"), account_slugs, f"{rpath}.owner", "account", errors)
|
|
1141
|
+
for k, ref in enumerate(risk.get("tasks", [])):
|
|
1142
|
+
tref_path = f"{rpath}.tasks[{k}]"
|
|
1143
|
+
if enclosing_project is None and ":" not in ref:
|
|
1144
|
+
errors.append(
|
|
1145
|
+
f"{tref_path}: program-scoped risk task ref {ref!r} must be "
|
|
1146
|
+
f'qualified as "<project-slug>:<wbs-path>"'
|
|
1147
|
+
)
|
|
1148
|
+
continue
|
|
1149
|
+
_check_task_ref(ref, enclosing_project or "", task_index, tref_path, errors)
|