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,1152 @@
|
|
|
1
|
+
"""Per-row application for ``POST /projects/{pk}/tasks/bulk/`` (#2723, ADR-0772).
|
|
2
|
+
|
|
3
|
+
The endpoint used to apply operations in request order and stop at the first
|
|
4
|
+
rejection. That was not the clean all-or-nothing rollback it looked like:
|
|
5
|
+
``_apply_bulk_operations`` **returned** the error Response rather than raising, so
|
|
6
|
+
the enclosing ``transaction.atomic()`` block exited *normally* and every operation
|
|
7
|
+
before the rejection committed — while the ``transaction.on_commit`` registrations
|
|
8
|
+
for CPM recalculation and the board broadcast were skipped, because the view had
|
|
9
|
+
already returned. A partially-applied batch therefore left a silently stale
|
|
10
|
+
schedule and no notification to collaborators (#2746).
|
|
11
|
+
|
|
12
|
+
This module replaces that with per-row application and a 207 response:
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"applied": [{index, id, op, outcome, task}],
|
|
16
|
+
"rejected": [{index, id, code, message}],
|
|
17
|
+
"skipped": [{index, id, code, message}],
|
|
18
|
+
"dependencies": {"applied": [...], "rejected": [...]}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
``index`` — not ``id`` — is the correlation handle (ADR-0772 §2). A row rejected
|
|
22
|
+
because its id is unparseable has no usable id to echo back, and a create that
|
|
23
|
+
omitted one has none at all.
|
|
24
|
+
|
|
25
|
+
Two structural requirements the 207 contract introduces, neither of which the sync
|
|
26
|
+
path needs because it aborts wholesale:
|
|
27
|
+
|
|
28
|
+
- **Per-row savepoints.** A database-level failure (an ``IntegrityError`` the
|
|
29
|
+
serializer did not catch) poisons the enclosing transaction, and any subsequent
|
|
30
|
+
query then raises ``TransactionManagementError``. Continuing to the next row is
|
|
31
|
+
therefore impossible inside a single flat ``atomic()``: each row runs in its own
|
|
32
|
+
savepoint so one row's failure rolls back only that row.
|
|
33
|
+
- **Create ids join the lock set.** Under the upsert branch a create id may resolve
|
|
34
|
+
to an existing row, so create ids must be locked with the update/delete targets —
|
|
35
|
+
otherwise the existence check and the write are a TOCTOU window and two concurrent
|
|
36
|
+
batches minting the same id can interleave.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import dataclasses
|
|
42
|
+
import uuid
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
from django.db import DatabaseError, connection, transaction
|
|
46
|
+
from rest_framework.exceptions import PermissionDenied
|
|
47
|
+
from rest_framework.exceptions import ValidationError as DRFValidationError
|
|
48
|
+
from rest_framework.settings import api_settings
|
|
49
|
+
|
|
50
|
+
from trueppm_api.apps.access.models import Role
|
|
51
|
+
from trueppm_api.apps.access.permissions import can_user_edit_task
|
|
52
|
+
from trueppm_api.apps.projects.models import TaskSource
|
|
53
|
+
from trueppm_api.apps.projects.refusal_codes import BulkRefusalCode
|
|
54
|
+
from trueppm_api.apps.projects.row_identity import (
|
|
55
|
+
is_tombstoned,
|
|
56
|
+
parse_row_uuid,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from rest_framework.request import Request
|
|
61
|
+
|
|
62
|
+
from trueppm_api.apps.projects.models import Project, Task
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Reject / skip vocabulary
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# The codes themselves — and the notes on what each one means and whether
|
|
68
|
+
# retrying is worth the caller's time — live in ``refusal_codes.BulkRefusalCode``,
|
|
69
|
+
# which is also what the response serializer publishes as an ``enum`` into the
|
|
70
|
+
# OpenAPI schema (#3037). The names below are kept as module constants so the ~46
|
|
71
|
+
# call sites in this file read as prose rather than as attribute chains, and are
|
|
72
|
+
# bound to ``.value`` so what reaches ``json.dumps`` is a plain ``str`` and not an
|
|
73
|
+
# enum member whose repr could drift with a Django version.
|
|
74
|
+
#
|
|
75
|
+
# Add a code by adding a member there. Writing a bare string literal at a call
|
|
76
|
+
# site here puts a code on the wire that the schema does not publish, which is the
|
|
77
|
+
# exact defect #3037 was filed about.
|
|
78
|
+
|
|
79
|
+
CODE_MALFORMED_ID = BulkRefusalCode.MALFORMED_ID.value
|
|
80
|
+
CODE_ID_UNAVAILABLE = BulkRefusalCode.ID_UNAVAILABLE.value
|
|
81
|
+
CODE_NOT_FOUND = BulkRefusalCode.NOT_FOUND.value
|
|
82
|
+
CODE_FORBIDDEN = BulkRefusalCode.FORBIDDEN.value
|
|
83
|
+
CODE_INVALID = BulkRefusalCode.INVALID.value
|
|
84
|
+
CODE_CONFLICT = BulkRefusalCode.CONFLICT.value
|
|
85
|
+
CODE_CYCLIC_DEPENDENCY = BulkRefusalCode.CYCLIC_DEPENDENCY.value
|
|
86
|
+
CODE_SELF_REFERENCE = BulkRefusalCode.SELF_REFERENCE.value
|
|
87
|
+
CODE_UNRESOLVED_ENDPOINT = BulkRefusalCode.UNRESOLVED_ENDPOINT.value
|
|
88
|
+
CODE_TOMBSTONED = BulkRefusalCode.TOMBSTONED.value
|
|
89
|
+
CODE_MILESTONE_GATE = BulkRefusalCode.MILESTONE_GATE.value
|
|
90
|
+
|
|
91
|
+
#: Rejection text for a progress write with nothing to anchor the work to.
|
|
92
|
+
#:
|
|
93
|
+
#: Emitted from all three op handlers (create, update, delete-cascade), so it lives
|
|
94
|
+
#: here rather than being repeated — the three must stay identical, since clients
|
|
95
|
+
#: match on the pair (code, message) and a drifted copy reads as a different error.
|
|
96
|
+
MSG_PROGRESS_NEEDS_ANCHOR = (
|
|
97
|
+
"Cannot record progress without a planned start date or sprint assignment."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
#: Rejection text for a zero duration written onto a row that is not a milestone.
|
|
101
|
+
#:
|
|
102
|
+
#: Same reason as the constant above: emitted from the bulk row handler AND from
|
|
103
|
+
#: ``TaskViewSet._handle_task_write``, and clients match on the pair
|
|
104
|
+
#: (code, message), so a drifted copy reads as a different error (#3265).
|
|
105
|
+
MSG_ZERO_DURATION_NOT_MILESTONE = (
|
|
106
|
+
"A task with zero duration is a milestone — send is_milestone=true (or "
|
|
107
|
+
"delivery_mode='milestone') to make one. To record work with no estimate, use "
|
|
108
|
+
"a duration of at least 1."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
#: One resolved edge awaiting the graph guard: (row index, raw row, predecessor, successor).
|
|
112
|
+
_EdgeEntry = tuple[int, dict[str, Any], str, str]
|
|
113
|
+
|
|
114
|
+
#: Maximum operations in one batch.
|
|
115
|
+
#:
|
|
116
|
+
#: ADR-0772 decides that a cap is *required* and leaves the value to #2723. 500
|
|
117
|
+
#: matches ``sync/upload.py``'s ``DEFAULT_MAX_BATCH_ROWS``, so the two write paths
|
|
118
|
+
#: bound a batch identically, and it sits comfortably above the largest realistic
|
|
119
|
+
#: paste-many. Before this, the endpoint had no cap at all: ``operations`` was a
|
|
120
|
+
#: ``ListField(min_length=1)`` with no ``max_length`` and the view declared no
|
|
121
|
+
#: throttles, so the only real bounds were ``DATA_UPLOAD_MAX_MEMORY_SIZE`` and the
|
|
122
|
+
#: global per-user throttle.
|
|
123
|
+
TASK_BULK_MAX_OPERATIONS = 500
|
|
124
|
+
|
|
125
|
+
#: Maximum dependency edges in one batch. Separate budget from ``operations``: a
|
|
126
|
+
#: paste-many of N rows can legitimately carry up to N-1 edges chaining them, so
|
|
127
|
+
#: sharing one budget would make the edge count silently eat the row count.
|
|
128
|
+
TASK_BULK_MAX_DEPENDENCIES = 500
|
|
129
|
+
|
|
130
|
+
#: Maximum inline ``owners`` entries summed across an ENTIRE batch (#3643).
|
|
131
|
+
#:
|
|
132
|
+
#: ``MAX_TASK_OWNERS_PER_WRITE`` (serializers.py, #3596) caps ``owners`` at 100 per
|
|
133
|
+
#: ``TaskSerializer`` instance, but ``TaskBulkView`` builds one serializer per
|
|
134
|
+
#: operation — up to ``TASK_BULK_MAX_OPERATIONS`` of them — so the per-row cap alone
|
|
135
|
+
#: still lets one request compose 500 x 100 = 50,000 owner entries, each minting a
|
|
136
|
+
#: ``TaskActivityEvent`` and (absent the ``broadcast=False`` fix below) a post-commit
|
|
137
|
+
#: ``broadcast_board_event``. That is a ~34x reduction from uncapped, not a closure,
|
|
138
|
+
#: and the same order of magnitude the per-row cap was filed to remove.
|
|
139
|
+
#:
|
|
140
|
+
#: Bounded batch-wide rather than per row, mirroring ``TASK_BULK_MAX_DEPENDENCIES``:
|
|
141
|
+
#: ``dependencies`` is the other nested collection on this endpoint whose budget
|
|
142
|
+
#: could otherwise multiply against ``operations``, and it is capped once for the
|
|
143
|
+
#: whole request rather than once per edge-bearing row. 500 matches
|
|
144
|
+
#: ``TASK_BULK_MAX_OPERATIONS`` and ``TASK_BULK_MAX_DEPENDENCIES`` — a batch naming
|
|
145
|
+
#: more distinct owner assignments than it has rows, or an edge for every row, is
|
|
146
|
+
#: already at the outer edge of what a paste-many legitimately produces.
|
|
147
|
+
TASK_BULK_MAX_OWNERS = 500
|
|
148
|
+
|
|
149
|
+
#: Emitted verbatim when a batch's summed ``owners`` entries exceed the cap. Mirrors
|
|
150
|
+
#: ``MSG_TOO_MANY_OWNERS`` (serializers.py) but states the batch-wide framing so a
|
|
151
|
+
#: caller who already reads the per-row message is not confused by the difference.
|
|
152
|
+
MSG_TOO_MANY_OWNERS_IN_BATCH = (
|
|
153
|
+
"This batch names {actual} owner entries across its operations, more than the "
|
|
154
|
+
"batch-wide limit of {max_length}. Split the paste into smaller batches, or send "
|
|
155
|
+
"fewer owners per row — owners are upserted, so a later write never removes an "
|
|
156
|
+
"owner named by an earlier one."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclasses.dataclass
|
|
161
|
+
class BulkOutcome:
|
|
162
|
+
"""Accumulator for one batch's per-row results."""
|
|
163
|
+
|
|
164
|
+
applied: list[dict[str, Any]] = dataclasses.field(default_factory=list)
|
|
165
|
+
rejected: list[dict[str, Any]] = dataclasses.field(default_factory=list)
|
|
166
|
+
skipped: list[dict[str, Any]] = dataclasses.field(default_factory=list)
|
|
167
|
+
dep_applied: list[dict[str, Any]] = dataclasses.field(default_factory=list)
|
|
168
|
+
dep_rejected: list[dict[str, Any]] = dataclasses.field(default_factory=list)
|
|
169
|
+
|
|
170
|
+
#: Task ids that actually changed, in application order — the set the post-commit
|
|
171
|
+
#: recalculation and the ``tasks_bulk_mutated`` broadcast are computed over. A
|
|
172
|
+
#: partial 207 must never broadcast an id it rejected.
|
|
173
|
+
created_ids: list[uuid.UUID] = dataclasses.field(default_factory=list)
|
|
174
|
+
updated_ids: list[uuid.UUID] = dataclasses.field(default_factory=list)
|
|
175
|
+
deleted_ids: list[str] = dataclasses.field(default_factory=list)
|
|
176
|
+
project_start_shifted: bool = False
|
|
177
|
+
|
|
178
|
+
def reject(self, index: int, row_id: Any, code: str, message: str) -> None:
|
|
179
|
+
self.rejected.append(
|
|
180
|
+
{
|
|
181
|
+
"index": index,
|
|
182
|
+
"id": str(row_id) if row_id else None,
|
|
183
|
+
"code": code,
|
|
184
|
+
"message": message,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def skip(self, index: int, row_id: Any, code: str, message: str) -> None:
|
|
189
|
+
self.skipped.append(
|
|
190
|
+
{
|
|
191
|
+
"index": index,
|
|
192
|
+
"id": str(row_id) if row_id else None,
|
|
193
|
+
"code": code,
|
|
194
|
+
"message": message,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def dep_reject(self, index: int, code: str, message: str, **extra: Any) -> None:
|
|
199
|
+
"""Reject one dependency-edge row.
|
|
200
|
+
|
|
201
|
+
``id`` is always ``null`` — dependency ids are server-assigned (ADR-0772), so
|
|
202
|
+
there is never a client-resolvable id to echo back. Still required in the
|
|
203
|
+
dict: ``TaskBulkProblemEntrySerializer`` (reused for both ``rejected`` and
|
|
204
|
+
``dependencies.rejected``) declares ``id`` as a required, nullable field, and
|
|
205
|
+
the view returns this dict directly rather than through the serializer, so an
|
|
206
|
+
omitted key reaches the client as a schema violation instead of being caught.
|
|
207
|
+
"""
|
|
208
|
+
self.dep_rejected.append(
|
|
209
|
+
{"index": index, "id": None, "code": code, "message": message, **extra}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def mutated_task_ids(self) -> list[str]:
|
|
214
|
+
"""Every task id this batch actually changed."""
|
|
215
|
+
return [str(t) for t in self.created_ids + self.updated_ids] + list(self.deleted_ids)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def touched_anything(self) -> bool:
|
|
219
|
+
"""Whether any write committed — the condition for recalculating and broadcasting."""
|
|
220
|
+
return bool(self.created_ids or self.updated_ids or self.deleted_ids or self.dep_applied)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclasses.dataclass
|
|
224
|
+
class _CachedParent:
|
|
225
|
+
"""A placement-cache entry (#2724 perf): a parent resolved and guard-checked once."""
|
|
226
|
+
|
|
227
|
+
task: Task
|
|
228
|
+
#: 1-based WBS position the NEXT child placed under this parent should get.
|
|
229
|
+
next_child_index: int
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclasses.dataclass
|
|
233
|
+
class _PlacementCache:
|
|
234
|
+
"""Batch-local memo for :func:`_resolve_bulk_create_placement` (#2724).
|
|
235
|
+
|
|
236
|
+
Without this, a paste of N children under one new phase re-queries that phase's
|
|
237
|
+
sibling count on every row via ``_get_siblings`` — a query whose result set grows
|
|
238
|
+
each time, so N rows cost O(N) queries scanning O(N) cumulative locked rows. Each
|
|
239
|
+
distinct parent is resolved and guard-checked (milestone/depth/phase) exactly ONCE
|
|
240
|
+
per batch; every later row targeting the same parent reuses the cached, already-
|
|
241
|
+
validated parent and increments an in-memory counter instead. Root-level rows get
|
|
242
|
+
the same treatment via a single counter, replacing N calls to
|
|
243
|
+
``_next_root_wbs_path``'s locked ``.count()`` with one.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
parents: dict[str, _CachedParent] = dataclasses.field(default_factory=dict)
|
|
247
|
+
#: Next root-level WBS position, or ``None`` until the first root row asks.
|
|
248
|
+
next_root_index: int | None = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclasses.dataclass(frozen=True)
|
|
252
|
+
class BulkContext:
|
|
253
|
+
"""Per-request inputs every row in a batch shares."""
|
|
254
|
+
|
|
255
|
+
project: Project
|
|
256
|
+
request: Request
|
|
257
|
+
caller_role: int
|
|
258
|
+
caller: Any
|
|
259
|
+
#: Live rows this batch references, locked FOR UPDATE, keyed by ``str(pk)``.
|
|
260
|
+
#: Includes create-op ids (ADR-0772): under the upsert branch a create id may
|
|
261
|
+
#: resolve to an existing row, so it must be locked with the rest or the
|
|
262
|
+
#: existence check and the write are a TOCTOU window.
|
|
263
|
+
locked_by_id: dict[str, Task]
|
|
264
|
+
#: Ids that resolve to a task in another project (guard 2).
|
|
265
|
+
foreign_ids: set[str]
|
|
266
|
+
#: Provenance to stamp on rows this batch *creates* (#3038, ADR-0786). Defaults
|
|
267
|
+
#: to ``TaskSource.HAND`` — a caller that declares nothing keeps today's
|
|
268
|
+
#: behavior. Never applied to a recreate-as-edit (``_apply_recreate_as_edit``):
|
|
269
|
+
#: that path edits a row that already exists, so its provenance was set when
|
|
270
|
+
#: the row was first created and is not this write's to overwrite.
|
|
271
|
+
source_kind: str = TaskSource.HAND
|
|
272
|
+
#: Mutated in place across rows (#2724) — frozen only blocks reassigning the
|
|
273
|
+
#: attribute itself, not mutating what it points to.
|
|
274
|
+
placement_cache: _PlacementCache = dataclasses.field(default_factory=_PlacementCache)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _row_serializer_context(ctx: BulkContext) -> dict[str, Any]:
|
|
278
|
+
"""Context every per-row ``TaskSerializer``/``DependencySerializer`` shares.
|
|
279
|
+
|
|
280
|
+
``suppress_owner_broadcast`` (#3643): this endpoint already fires one coarse
|
|
281
|
+
``tasks_bulk_mutated`` covering every id the batch touched
|
|
282
|
+
(``_register_bulk_commit_hooks``), unconditionally including every row an
|
|
283
|
+
``owners`` write applies to — ``_apply_create``/``_apply_update`` add the row's
|
|
284
|
+
id to ``created_ids``/``updated_ids`` regardless of whether it also carried
|
|
285
|
+
``owners``. A per-entry ``assignment_*`` broadcast from ``apply_task_owners``
|
|
286
|
+
would be pure duplicate load on top of that: both event families funnel to the
|
|
287
|
+
same ``scheduleInvalidate('tasks')`` client-side. Always ``True`` here, not
|
|
288
|
+
conditional — every caller of this helper is inside the batch transaction that
|
|
289
|
+
makes the coarse event true, so there is no branch where the per-entry event
|
|
290
|
+
would be the only signal reaching a client.
|
|
291
|
+
"""
|
|
292
|
+
return {
|
|
293
|
+
"request": ctx.request,
|
|
294
|
+
"caller_role": ctx.caller_role,
|
|
295
|
+
"suppress_owner_broadcast": True,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _first_leaf_message(node: Any) -> str:
|
|
300
|
+
"""The first message string under ``node``, descending lists and dicts (#3596).
|
|
301
|
+
|
|
302
|
+
DRF nests a validation error as deep as the payload and only the **leaves** are
|
|
303
|
+
message strings: a scalar field holds a list of them, a nested object holds a dict
|
|
304
|
+
keyed by subfield, a list of objects holds a list with ``{}`` in the slots that
|
|
305
|
+
validated, and a constraint on a *list as a whole* holds a dict keyed
|
|
306
|
+
``non_field_errors``. Taking ``str()`` of an intermediate node therefore puts a Python
|
|
307
|
+
``dict``/``ErrorDetail`` repr on the wire as the caller's error message — which is
|
|
308
|
+
what the ``owners`` cap produced before this descended.
|
|
309
|
+
|
|
310
|
+
``non_field_errors`` wins when present: it carries the constraint that was violated,
|
|
311
|
+
which is the sentence worth showing, rather than an arbitrarily-ordered sibling.
|
|
312
|
+
"""
|
|
313
|
+
if isinstance(node, dict):
|
|
314
|
+
key = (
|
|
315
|
+
api_settings.NON_FIELD_ERRORS_KEY
|
|
316
|
+
if api_settings.NON_FIELD_ERRORS_KEY in node
|
|
317
|
+
else next((k for k, v in node.items() if v), None)
|
|
318
|
+
)
|
|
319
|
+
if key is not None:
|
|
320
|
+
return _first_leaf_message(node[key])
|
|
321
|
+
return str(node)
|
|
322
|
+
if isinstance(node, list):
|
|
323
|
+
# Skip the `{}` slots a list-of-objects error uses for the items that validated.
|
|
324
|
+
populated = next((item for item in node if item), None)
|
|
325
|
+
if populated is not None:
|
|
326
|
+
return _first_leaf_message(populated)
|
|
327
|
+
return str(node)
|
|
328
|
+
return str(node)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _first_error_message(exc: DRFValidationError) -> str:
|
|
332
|
+
"""Flatten a DRF validation error to one human sentence for ``rejected[].message``."""
|
|
333
|
+
detail = exc.detail
|
|
334
|
+
if isinstance(detail, dict):
|
|
335
|
+
for field, errors in detail.items():
|
|
336
|
+
return f"{field}: {_first_leaf_message(errors)}"
|
|
337
|
+
if isinstance(detail, list) and detail:
|
|
338
|
+
return _first_leaf_message(detail)
|
|
339
|
+
return str(detail)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _validate_or_reject(ser: Any, index: int, row_id: Any, out: BulkOutcome) -> bool:
|
|
343
|
+
"""Validate one row's serializer, converting a known failure to a rejection.
|
|
344
|
+
|
|
345
|
+
Returns ``True`` when the row validated and the caller may save.
|
|
346
|
+
|
|
347
|
+
The failures are caught separately rather than as one ``Exception`` arm
|
|
348
|
+
because clients match on the pair ``(code, message)`` and each carries a
|
|
349
|
+
different remediation. ``ProgressAnchorError``, ``MilestoneRollupLockedError``
|
|
350
|
+
and ``ZeroDurationNotMilestoneError`` all derive from plain ``Exception``, not
|
|
351
|
+
from ``DRFValidationError``, so the order of the arms is not load-bearing —
|
|
352
|
+
but omitting an arm is: the refusal would escape the row's savepoint and 500
|
|
353
|
+
the whole batch rather than rejecting one row.
|
|
354
|
+
|
|
355
|
+
Safe to use on the create path even though ``MilestoneRollupLockedError`` cannot
|
|
356
|
+
arise there: ``_enforce_milestone_rollup_lock`` short-circuits on
|
|
357
|
+
``self.instance is None``, so that arm is dead for an unbound serializer rather
|
|
358
|
+
than newly-reachable behavior.
|
|
359
|
+
"""
|
|
360
|
+
from trueppm_api.apps.projects.serializers import (
|
|
361
|
+
MilestoneRollupLockedError,
|
|
362
|
+
ProgressAnchorError,
|
|
363
|
+
ZeroDurationNotMilestoneError,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
ser.is_valid(raise_exception=True)
|
|
368
|
+
except ProgressAnchorError:
|
|
369
|
+
out.reject(index, row_id, CODE_INVALID, MSG_PROGRESS_NEEDS_ANCHOR)
|
|
370
|
+
return False
|
|
371
|
+
except ZeroDurationNotMilestoneError:
|
|
372
|
+
# Needs its own arm for a reason the other two do not illustrate: this
|
|
373
|
+
# exception does NOT derive from DRFValidationError, so without it the
|
|
374
|
+
# refusal escapes the row's savepoint and 500s the whole batch instead of
|
|
375
|
+
# rejecting one row (#3265).
|
|
376
|
+
out.reject(index, row_id, CODE_INVALID, MSG_ZERO_DURATION_NOT_MILESTONE)
|
|
377
|
+
return False
|
|
378
|
+
except MilestoneRollupLockedError:
|
|
379
|
+
out.reject(
|
|
380
|
+
index, row_id, CODE_INVALID, "This milestone's progress is rolled up from a sprint."
|
|
381
|
+
)
|
|
382
|
+
return False
|
|
383
|
+
except DRFValidationError as exc:
|
|
384
|
+
out.reject(index, row_id, CODE_INVALID, _first_error_message(exc))
|
|
385
|
+
return False
|
|
386
|
+
return True
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _milestone_gate_applies(task: Task, data: dict[str, Any]) -> bool:
|
|
390
|
+
"""Whether a classification op must skip this row rather than re-type it (#2723 §4).
|
|
391
|
+
|
|
392
|
+
``delivery_mode`` and ``is_milestone`` are two encodings of one fact, coupled by
|
|
393
|
+
``TaskSerializer._reconcile_milestone_signals``. So a classification that sets
|
|
394
|
+
``delivery_mode`` on a milestone row would silently *un-milestone* it — quietly
|
|
395
|
+
dissolving a gate that the plan's structure depends on.
|
|
396
|
+
|
|
397
|
+
A milestone is therefore skipped, not rejected: a classification crossing a gate
|
|
398
|
+
is a documented no-op, never a failure, so one milestone inside a pasted subtree
|
|
399
|
+
cannot fail the rows around it.
|
|
400
|
+
|
|
401
|
+
An **explicit** ``is_milestone`` in the same op is a deliberate act by an author
|
|
402
|
+
who can see the row, so it proceeds. The gate only catches the case where the
|
|
403
|
+
caller classified a range and a milestone happened to be inside it.
|
|
404
|
+
"""
|
|
405
|
+
from trueppm_api.apps.projects.models import DeliveryMode
|
|
406
|
+
|
|
407
|
+
if "delivery_mode" not in data or "is_milestone" in data:
|
|
408
|
+
return False
|
|
409
|
+
if data["delivery_mode"] == DeliveryMode.MILESTONE:
|
|
410
|
+
return False
|
|
411
|
+
return bool(task.is_milestone)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _resolve_bulk_create_placement(
|
|
415
|
+
index: int,
|
|
416
|
+
row_id: uuid.UUID | None,
|
|
417
|
+
parent_id_raw: Any,
|
|
418
|
+
is_subtask: bool,
|
|
419
|
+
ctx: BulkContext,
|
|
420
|
+
out: BulkOutcome,
|
|
421
|
+
) -> tuple[Task | None, str] | None:
|
|
422
|
+
"""Resolve a bulk create's WBS placement, or record its rejection and return ``None``.
|
|
423
|
+
|
|
424
|
+
Mirrors ``TaskViewSet.perform_create`` rather than duplicating its guards, and
|
|
425
|
+
caches per parent for the batch's duration (``ctx.placement_cache`` — see
|
|
426
|
+
``_PlacementCache``) so an N-child paste costs O(distinct parents), not O(N),
|
|
427
|
+
in placement queries. Imported locally — ``views`` imports ``task_bulk`` at
|
|
428
|
+
call time (``TaskBulkView.post``), so a module-level import here would be
|
|
429
|
+
circular.
|
|
430
|
+
"""
|
|
431
|
+
from trueppm_api.apps.projects.views import (
|
|
432
|
+
_build_wbs_path,
|
|
433
|
+
_next_root_wbs_path,
|
|
434
|
+
_resolve_create_parent,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
cache = ctx.placement_cache
|
|
438
|
+
|
|
439
|
+
if not parent_id_raw:
|
|
440
|
+
if cache.next_root_index is None:
|
|
441
|
+
cache.next_root_index = int(_next_root_wbs_path(ctx.project))
|
|
442
|
+
wbs_path = str(cache.next_root_index)
|
|
443
|
+
cache.next_root_index += 1
|
|
444
|
+
return None, wbs_path
|
|
445
|
+
|
|
446
|
+
# Unlike `id`, `parent_id` reaches here straight from a raw, unvalidated
|
|
447
|
+
# `DictField` (`data`) — a non-UUID value passed to `.get(pk=...)` raises
|
|
448
|
+
# Django's core `ValidationError`, not DRF's. That type is NOT one of the
|
|
449
|
+
# exceptions `apply_task_operations`'s per-row savepoint catches, so an
|
|
450
|
+
# unparseable `parent_id` would 500 the WHOLE request and roll back every row
|
|
451
|
+
# already applied in this batch — exactly the "one bad row" failure #2723 was
|
|
452
|
+
# written to prevent. Parsed the same way `id` already is (row_identity).
|
|
453
|
+
parent_id = parse_row_uuid(parent_id_raw)
|
|
454
|
+
if parent_id is None:
|
|
455
|
+
out.reject(index, row_id, CODE_MALFORMED_ID, "'parent_id' is not a valid UUID.")
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
cache_key = str(parent_id)
|
|
459
|
+
cached = cache.parents.get(cache_key)
|
|
460
|
+
if cached is not None:
|
|
461
|
+
wbs_path = _build_wbs_path(str(cached.task.wbs_path), cached.next_child_index)
|
|
462
|
+
cached.next_child_index += 1
|
|
463
|
+
return cached.task, wbs_path
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
parent, wbs_path = _resolve_create_parent(ctx.project, parent_id, is_subtask=is_subtask)
|
|
467
|
+
except DRFValidationError as exc:
|
|
468
|
+
out.reject(index, row_id, CODE_INVALID, _first_error_message(exc))
|
|
469
|
+
return None
|
|
470
|
+
next_child_index = int(wbs_path.rsplit(".", 1)[-1]) + 1
|
|
471
|
+
cache.parents[cache_key] = _CachedParent(task=parent, next_child_index=next_child_index)
|
|
472
|
+
return parent, wbs_path
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _spawn_subtask_under(parent_task: Task, task: Task, ctx: BulkContext, out: BulkOutcome) -> None:
|
|
476
|
+
"""Record a subtask's spawn and fold the parent's mutation into the batch outcome.
|
|
477
|
+
|
|
478
|
+
``_record_subtask_spawn`` bumps the parent's ``server_version`` — a real mutation
|
|
479
|
+
``mutated_task_ids`` must carry, or the ``tasks_bulk_mutated`` broadcast silently
|
|
480
|
+
omits it (its own docstring promises "every task id this batch actually changed").
|
|
481
|
+
Skipped when the parent was itself created earlier in this same batch (already in
|
|
482
|
+
``created_ids``), so the payload never carries a duplicate id.
|
|
483
|
+
"""
|
|
484
|
+
from trueppm_api.apps.projects.views import _record_subtask_spawn
|
|
485
|
+
|
|
486
|
+
_record_subtask_spawn(parent_task, task, ctx.request.user)
|
|
487
|
+
if parent_task.pk not in out.created_ids and parent_task.pk not in out.updated_ids:
|
|
488
|
+
out.updated_ids.append(parent_task.pk)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _apply_recreate_as_edit(
|
|
492
|
+
index: int,
|
|
493
|
+
row_id: uuid.UUID | None,
|
|
494
|
+
existing: Task,
|
|
495
|
+
data: dict[str, Any],
|
|
496
|
+
ctx: BulkContext,
|
|
497
|
+
out: BulkOutcome,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Apply a ``create`` whose id already exists as an in-place edit (ADR-0772 guard 4).
|
|
500
|
+
|
|
501
|
+
The row landed in a prior batch, so this is an idempotent re-create. It is applied
|
|
502
|
+
under the STRICTER *edit* bar, not the create bar. Getting that backwards is the
|
|
503
|
+
trap the guard names: it would turn a client-mintable id into a privilege-escalation
|
|
504
|
+
primitive, letting a Member mint the id of somebody else's task and rewrite it as a
|
|
505
|
+
"create".
|
|
506
|
+
"""
|
|
507
|
+
from trueppm_api.apps.projects.serializers import TaskSerializer
|
|
508
|
+
from trueppm_api.apps.projects.services import maybe_record_scope_injection
|
|
509
|
+
|
|
510
|
+
if not can_user_edit_task(ctx.request, existing, method="PATCH"):
|
|
511
|
+
out.reject(index, row_id, CODE_FORBIDDEN, "You may not edit this task.")
|
|
512
|
+
return
|
|
513
|
+
# Snapshot BEFORE the serializer save: `ser.save()` mutates the instance in
|
|
514
|
+
# place, so reading `sprint_id` afterwards would compare the new value with
|
|
515
|
+
# itself and the injection would never be detected.
|
|
516
|
+
old_sprint_id = str(existing.sprint_id) if existing.sprint_id else None
|
|
517
|
+
ser = TaskSerializer(existing, data=data, partial=True, context=_row_serializer_context(ctx))
|
|
518
|
+
if not _validate_or_reject(ser, index, row_id, out):
|
|
519
|
+
return
|
|
520
|
+
task = ser.save()
|
|
521
|
+
maybe_record_scope_injection(task, old_sprint_id, ctx.request.user)
|
|
522
|
+
out.updated_ids.append(task.pk)
|
|
523
|
+
out.applied.append({"index": index, "id": str(task.pk), "op": "create", "outcome": "updated"})
|
|
524
|
+
_note_project_start_shift(task, out)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _apply_create(index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome) -> None:
|
|
528
|
+
"""Apply one ``create`` op, honoring the four client-minted-id guards."""
|
|
529
|
+
from trueppm_api.apps.projects.serializers import TaskSerializer
|
|
530
|
+
from trueppm_api.apps.projects.services import maybe_record_scope_injection
|
|
531
|
+
|
|
532
|
+
data = dict(op.get("data") or {})
|
|
533
|
+
raw_id = op.get("id")
|
|
534
|
+
row_id: uuid.UUID | None = None
|
|
535
|
+
|
|
536
|
+
if raw_id is not None:
|
|
537
|
+
row_id = parse_row_uuid(raw_id)
|
|
538
|
+
if row_id is None:
|
|
539
|
+
out.reject(index, None, CODE_MALFORMED_ID, "'id' is not a valid UUID.")
|
|
540
|
+
return
|
|
541
|
+
if str(row_id) in ctx.foreign_ids:
|
|
542
|
+
out.reject(
|
|
543
|
+
index, row_id, CODE_ID_UNAVAILABLE, "That id is not available — mint a new one."
|
|
544
|
+
)
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
existing = ctx.locked_by_id.get(str(row_id)) if row_id is not None else None
|
|
548
|
+
if is_tombstoned(existing):
|
|
549
|
+
out.skip(
|
|
550
|
+
index, row_id, CODE_TOMBSTONED, "That id belongs to a deleted row; not re-created."
|
|
551
|
+
)
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
# project is injected from the URL, never read from the row body, so a batch can
|
|
555
|
+
# never create into a different project.
|
|
556
|
+
data.pop("project", None)
|
|
557
|
+
data.pop("wbs_path", None)
|
|
558
|
+
data.pop("id", None)
|
|
559
|
+
# Popped here (not just on the create branch below) so a re-create-as-edit never
|
|
560
|
+
# re-parents an existing row through this path — same as perform_update, which
|
|
561
|
+
# never touches wbs_path on PATCH.
|
|
562
|
+
parent_id_raw = data.pop("parent_id", None)
|
|
563
|
+
is_subtask = str(data.pop("is_subtask", "") or "").lower() in ("true", "1")
|
|
564
|
+
|
|
565
|
+
if existing is not None:
|
|
566
|
+
_apply_recreate_as_edit(index, row_id, existing, data, ctx, out)
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
# Hierarchy placement (#2724). A batch's rows apply in `operations` order
|
|
570
|
+
# (apply_task_operations), so a child row naming a parent created earlier IN
|
|
571
|
+
# THIS SAME BATCH resolves: the parent's INSERT already committed to this
|
|
572
|
+
# transaction, just not to the outer request's response yet — client-minted
|
|
573
|
+
# ids (ADR-0772) make that forward reference possible without a round trip.
|
|
574
|
+
placement = _resolve_bulk_create_placement(index, row_id, parent_id_raw, is_subtask, ctx, out)
|
|
575
|
+
if placement is None:
|
|
576
|
+
return
|
|
577
|
+
parent_task, wbs_path = placement
|
|
578
|
+
|
|
579
|
+
ser = TaskSerializer(
|
|
580
|
+
data={**data, "project": str(ctx.project.pk)}, context=_row_serializer_context(ctx)
|
|
581
|
+
)
|
|
582
|
+
if not _validate_or_reject(ser, index, row_id, out):
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
# The client's UUID becomes the primary key verbatim — never remapped. It is
|
|
586
|
+
# passed as an explicit save() kwarg rather than made writable on TaskSerializer,
|
|
587
|
+
# which is shared by the plain REST create and the sync upload: making the field
|
|
588
|
+
# writable would silently hand a caller-supplied PK to every one of them.
|
|
589
|
+
#
|
|
590
|
+
# source_kind rides the same mechanism (#3038): it answers "what wrote this row"
|
|
591
|
+
# and TaskSource.PASTE sat declared and unwritten until this fix. Deliberately
|
|
592
|
+
# NOT paired with a seeded_at stamp — this path saves through TaskSerializer /
|
|
593
|
+
# Task.save(), which stamps edited_at on the very write that creates the row
|
|
594
|
+
# (task_batch_services.py's module docstring), so seeded_at is meant to stay
|
|
595
|
+
# null here. Setting it would pull every pasted row into
|
|
596
|
+
# TaskManager.untouched_seeded()'s "Delete untouched rows (N)" sweep — a
|
|
597
|
+
# different, already-decided question (ADR-0786 §2) that this fix does not
|
|
598
|
+
# reopen. Do not "fix" that by adding seeded_at alongside it.
|
|
599
|
+
save_kwargs: dict[str, Any] = {
|
|
600
|
+
"wbs_path": wbs_path,
|
|
601
|
+
"is_subtask": is_subtask,
|
|
602
|
+
"source_kind": ctx.source_kind,
|
|
603
|
+
}
|
|
604
|
+
if row_id is not None:
|
|
605
|
+
save_kwargs["id"] = row_id
|
|
606
|
+
task = ser.save(**save_kwargs)
|
|
607
|
+
if is_subtask and parent_task is not None:
|
|
608
|
+
_spawn_subtask_under(parent_task, task, ctx, out)
|
|
609
|
+
maybe_record_scope_injection(task, None, ctx.request.user)
|
|
610
|
+
out.created_ids.append(task.pk)
|
|
611
|
+
out.applied.append({"index": index, "id": str(task.pk), "op": "create", "outcome": "created"})
|
|
612
|
+
_note_project_start_shift(task, out)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _apply_update(index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome) -> None:
|
|
616
|
+
"""Apply one ``update`` op under the per-row edit bar.
|
|
617
|
+
|
|
618
|
+
**Sprint sovereignty is not optional on this path.** Moving a task into an
|
|
619
|
+
ACTIVE sprint is an *injection* (ADR-0102): it must write a
|
|
620
|
+
``SprintScopeChange`` and set ``sprint_pending`` so the row is visible but
|
|
621
|
+
excluded from the commitment until someone accepts it. Every other write
|
|
622
|
+
path routes through ``maybe_record_scope_injection`` — ``perform_create`` /
|
|
623
|
+
``perform_update`` on ``TaskViewSet``, both sync upload buckets, and
|
|
624
|
+
``_apply_create`` right above. This branch did not, so a bulk sprint write
|
|
625
|
+
landed straight in the commitment and moved the burndown with nothing to
|
|
626
|
+
accept or reject (#3152).
|
|
627
|
+
"""
|
|
628
|
+
from trueppm_api.apps.projects.serializers import TaskSerializer
|
|
629
|
+
from trueppm_api.apps.projects.services import maybe_record_scope_injection
|
|
630
|
+
|
|
631
|
+
task = _resolve_target(index, op, ctx, out)
|
|
632
|
+
if task is None:
|
|
633
|
+
return
|
|
634
|
+
if not can_user_edit_task(ctx.request, task, method="PATCH"):
|
|
635
|
+
out.reject(index, task.pk, CODE_FORBIDDEN, "You may not edit this task.")
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
data = dict(op.get("data") or {})
|
|
639
|
+
data.pop("project", None)
|
|
640
|
+
data.pop("wbs_path", None)
|
|
641
|
+
data.pop("id", None)
|
|
642
|
+
|
|
643
|
+
if _milestone_gate_applies(task, data):
|
|
644
|
+
out.skip(
|
|
645
|
+
index,
|
|
646
|
+
task.pk,
|
|
647
|
+
CODE_MILESTONE_GATE,
|
|
648
|
+
"Milestone left unchanged — a classification does not re-type a gate.",
|
|
649
|
+
)
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
# Snapshot BEFORE the save, for the same reason `_apply_recreate_as_edit` does.
|
|
653
|
+
old_sprint_id = str(task.sprint_id) if task.sprint_id else None
|
|
654
|
+
|
|
655
|
+
ser = TaskSerializer(task, data=data, partial=True, context=_row_serializer_context(ctx))
|
|
656
|
+
if not _validate_or_reject(ser, index, task.pk, out):
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
saved = ser.save()
|
|
660
|
+
# Unconditional: the helper self-guards subtasks, unchanged links, rows that
|
|
661
|
+
# are already pending, and non-ACTIVE targets, so there is no condition to
|
|
662
|
+
# restate here that could drift from the one the other write paths use.
|
|
663
|
+
maybe_record_scope_injection(saved, old_sprint_id, ctx.request.user)
|
|
664
|
+
out.updated_ids.append(saved.pk)
|
|
665
|
+
out.applied.append({"index": index, "id": str(saved.pk), "op": "update", "outcome": "updated"})
|
|
666
|
+
_note_project_start_shift(saved, out)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _apply_delete(index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome) -> None:
|
|
670
|
+
"""Soft-delete one row, mirroring ``IsProjectMemberWriteOrOwn``."""
|
|
671
|
+
task = _resolve_target(index, op, ctx, out)
|
|
672
|
+
if task is None:
|
|
673
|
+
return
|
|
674
|
+
if (
|
|
675
|
+
ctx.caller_role < Role.ADMIN
|
|
676
|
+
and not task.assignments.filter(resource__user=ctx.caller).exists()
|
|
677
|
+
):
|
|
678
|
+
out.reject(
|
|
679
|
+
index,
|
|
680
|
+
task.pk,
|
|
681
|
+
CODE_FORBIDDEN,
|
|
682
|
+
"Only Project Managers and task assignees may delete tasks.",
|
|
683
|
+
)
|
|
684
|
+
return
|
|
685
|
+
task.soft_delete()
|
|
686
|
+
out.deleted_ids.append(str(task.pk))
|
|
687
|
+
out.applied.append({"index": index, "id": str(task.pk), "op": "delete", "outcome": "deleted"})
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _resolve_target(
|
|
691
|
+
index: int, op: dict[str, Any], ctx: BulkContext, out: BulkOutcome
|
|
692
|
+
) -> Task | None:
|
|
693
|
+
"""Resolve an update/delete op's ``id`` to a live, locked row in this project.
|
|
694
|
+
|
|
695
|
+
A missing id, an unparseable id, a foreign id and an unknown id are reported
|
|
696
|
+
with distinct *codes* but none of them asserts that the row exists elsewhere —
|
|
697
|
+
``not_found`` and ``id_unavailable`` are both non-committal, matching the
|
|
698
|
+
sibling ``_lock_bulk_targets`` behavior and #359.
|
|
699
|
+
"""
|
|
700
|
+
raw_id = op.get("id")
|
|
701
|
+
if raw_id is None:
|
|
702
|
+
out.reject(index, None, CODE_INVALID, f"'id' is required for op='{op['op']}'.")
|
|
703
|
+
return None
|
|
704
|
+
row_id = parse_row_uuid(raw_id)
|
|
705
|
+
if row_id is None:
|
|
706
|
+
out.reject(index, None, CODE_MALFORMED_ID, "'id' is not a valid UUID.")
|
|
707
|
+
return None
|
|
708
|
+
task = ctx.locked_by_id.get(str(row_id))
|
|
709
|
+
if task is None or task.is_deleted:
|
|
710
|
+
out.reject(index, row_id, CODE_NOT_FOUND, "No such task in this project.")
|
|
711
|
+
return None
|
|
712
|
+
return task
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _note_project_start_shift(task: Task, out: BulkOutcome) -> None:
|
|
716
|
+
"""#867: a row that pulled the project start earlier needs a boundary broadcast."""
|
|
717
|
+
if getattr(task, "_project_start_shifted_from", None) is not None:
|
|
718
|
+
out.project_start_shifted = True
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
_OP_HANDLERS = {"create": _apply_create, "update": _apply_update, "delete": _apply_delete}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
#: The deferred exclusion constraint that guards one live task per (project, wbs_path).
|
|
725
|
+
#:
|
|
726
|
+
#: Declared ``Deferrable.DEFERRED`` on ``Task.Meta`` because every WBS-rewrite path
|
|
727
|
+
#: (indent/outdent/reorder/group/ungroup, structural undo) renumbers a whole sibling
|
|
728
|
+
#: level one row at a time and necessarily passes through duplicate intermediate
|
|
729
|
+
#: states. See the constraint's own comment in ``models.py`` for the full reasoning.
|
|
730
|
+
WBS_PATH_CONSTRAINT = "unique_task_wbs_path_per_project_live"
|
|
731
|
+
|
|
732
|
+
#: The two statements :func:`_settle_wbs_path_constraint` runs.
|
|
733
|
+
#:
|
|
734
|
+
#: **Whole literals, not composed from** ``WBS_PATH_CONSTRAINT`` **above.**
|
|
735
|
+
#: ``SET CONSTRAINTS`` takes a constraint *name*, which is an identifier — SQL has no
|
|
736
|
+
#: parameter placeholder for one, so a parameterized form of these statements does not
|
|
737
|
+
#: exist and any composition has to be textual. Semgrep's ``formatted-sql-query`` is a
|
|
738
|
+
#: blocking rule and is right to be, and it follows a constant back to its f-string, so
|
|
739
|
+
#: moving the interpolation off the call site does not answer it. Repeating the name is
|
|
740
|
+
#: the honest answer: there is then no formatting anywhere to justify. The duplication
|
|
741
|
+
#: is pinned by ``test_the_statements_name_the_constraint_that_is_declared``.
|
|
742
|
+
_SET_WBS_CONSTRAINT_IMMEDIATE = 'SET CONSTRAINTS "unique_task_wbs_path_per_project_live" IMMEDIATE'
|
|
743
|
+
_SET_WBS_CONSTRAINT_DEFERRED = 'SET CONSTRAINTS "unique_task_wbs_path_per_project_live" DEFERRED'
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _settle_wbs_path_constraint() -> None:
|
|
747
|
+
"""Check the deferred wbs_path constraint now, while the caller's savepoint is open.
|
|
748
|
+
|
|
749
|
+
Postgres evaluates a ``DEFERRED`` constraint at ``COMMIT``, **not** at
|
|
750
|
+
``RELEASE SAVEPOINT``. Without this the per-row savepoint in
|
|
751
|
+
:func:`apply_task_operations` releases cleanly on a colliding row, the handler
|
|
752
|
+
reports success, the 207 body is assembled with that row marked applied — and the
|
|
753
|
+
violation then surfaces when ``ATOMIC_REQUESTS`` commits *after* the view has
|
|
754
|
+
returned, outside DRF's ``exception_handler``. The client gets an opaque 500 in
|
|
755
|
+
place of the 207 the endpoint's contract promises, and the failure names a
|
|
756
|
+
constraint with no row attribution (#3070).
|
|
757
|
+
|
|
758
|
+
Setting the constraint ``IMMEDIATE`` drains its pending events right here, so a
|
|
759
|
+
violation is raised inside the savepoint and is attributable to the op that caused
|
|
760
|
+
it. The mode is then restored to ``DEFERRED`` so the *next* row is free to pass
|
|
761
|
+
through its own transient duplicates again — the property the constraint was
|
|
762
|
+
deferred for in the first place. On the failure path the restore is not needed:
|
|
763
|
+
the savepoint rollback reverts ``SET CONSTRAINTS`` along with everything else.
|
|
764
|
+
|
|
765
|
+
Postgres-only, and a no-op elsewhere; ``SET CONSTRAINTS`` has no portable
|
|
766
|
+
equivalent and no other backend is supported for this deployment.
|
|
767
|
+
"""
|
|
768
|
+
if connection.vendor != "postgresql":
|
|
769
|
+
return
|
|
770
|
+
with connection.cursor() as cursor:
|
|
771
|
+
cursor.execute(_SET_WBS_CONSTRAINT_IMMEDIATE)
|
|
772
|
+
cursor.execute(_SET_WBS_CONSTRAINT_DEFERRED)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _conflict_message(exc: DatabaseError) -> str:
|
|
776
|
+
"""Render a row-level conflict for the 207 body without leaking database text.
|
|
777
|
+
|
|
778
|
+
A wbs_path collision arrives as Postgres's exclusion-constraint message, which
|
|
779
|
+
names the constraint and repeats the raw key — neither of which means anything to
|
|
780
|
+
an API client, and the row attribution the client actually needs is already
|
|
781
|
+
carried by the report's ``index``. Every other ``DatabaseError`` keeps its own
|
|
782
|
+
text: those are unclassified by definition, and hiding them would leave the caller
|
|
783
|
+
with no signal at all.
|
|
784
|
+
"""
|
|
785
|
+
if WBS_PATH_CONSTRAINT in str(exc):
|
|
786
|
+
return (
|
|
787
|
+
"Another live task in this project already holds this WBS position. "
|
|
788
|
+
"Move or reorder the row and retry."
|
|
789
|
+
)
|
|
790
|
+
return str(exc)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def apply_task_operations(
|
|
794
|
+
operations: list[dict[str, Any]], ctx: BulkContext, out: BulkOutcome
|
|
795
|
+
) -> None:
|
|
796
|
+
"""Phase 1 — apply every task op, each inside its own savepoint.
|
|
797
|
+
|
|
798
|
+
The savepoint is what makes per-row continuation possible at all. A row that
|
|
799
|
+
fails at the database level (an ``IntegrityError`` the serializer did not catch)
|
|
800
|
+
poisons the enclosing transaction; without a savepoint to roll back to, the very
|
|
801
|
+
next query would raise ``TransactionManagementError`` and the remaining rows
|
|
802
|
+
would fail for a reason that has nothing to do with them.
|
|
803
|
+
|
|
804
|
+
A savepoint alone is not enough for a **deferred** constraint, though: Postgres
|
|
805
|
+
checks those at ``COMMIT``, so the savepoint releases cleanly and the failure lands
|
|
806
|
+
after the view has returned. :func:`_settle_wbs_path_constraint` closes that gap
|
|
807
|
+
for the one deferred constraint task rows can violate (#3070).
|
|
808
|
+
"""
|
|
809
|
+
for index, op in enumerate(operations):
|
|
810
|
+
handler = _OP_HANDLERS.get(op.get("op", ""))
|
|
811
|
+
if handler is None:
|
|
812
|
+
out.reject(index, op.get("id"), CODE_INVALID, f"Unknown op '{op.get('op')}'.")
|
|
813
|
+
continue
|
|
814
|
+
before = _outcome_lengths(out)
|
|
815
|
+
try:
|
|
816
|
+
with transaction.atomic(savepoint=True):
|
|
817
|
+
handler(index, op, ctx, out)
|
|
818
|
+
if _outcome_lengths(out) == before:
|
|
819
|
+
# The handler neither applied nor reported — defensive only.
|
|
820
|
+
out.reject(index, op.get("id"), CODE_INVALID, "Operation produced no result.")
|
|
821
|
+
elif op.get("op") != "delete" and len(out.applied) > before[0]:
|
|
822
|
+
# Only a row that actually wrote can have collided, and a delete
|
|
823
|
+
# only ever *frees* a path: soft-delete flips ``is_deleted``, which
|
|
824
|
+
# takes the row out of the constraint's partial condition. Skipping
|
|
825
|
+
# those two cases keeps the two extra statements off the rows that
|
|
826
|
+
# cannot need them, which matters on a large batch.
|
|
827
|
+
_settle_wbs_path_constraint()
|
|
828
|
+
except PermissionDenied as exc:
|
|
829
|
+
_rollback_bookkeeping(out, before)
|
|
830
|
+
out.reject(index, op.get("id"), CODE_FORBIDDEN, str(exc.detail))
|
|
831
|
+
except DRFValidationError as exc:
|
|
832
|
+
_rollback_bookkeeping(out, before)
|
|
833
|
+
out.reject(index, op.get("id"), CODE_INVALID, _first_error_message(exc))
|
|
834
|
+
except DatabaseError as exc:
|
|
835
|
+
# The savepoint has rolled back, so the transaction is usable again and
|
|
836
|
+
# the next row may proceed.
|
|
837
|
+
_rollback_bookkeeping(out, before)
|
|
838
|
+
out.reject(index, op.get("id"), CODE_CONFLICT, _conflict_message(exc))
|
|
839
|
+
|
|
840
|
+
_declare_parents_of_created_rows(ctx, out)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _declare_parents_of_created_rows(ctx: BulkContext, out: BulkOutcome) -> None:
|
|
844
|
+
"""Declare every parent this batch has just given a first structural child (#3036).
|
|
845
|
+
|
|
846
|
+
The single-row create path does this inline; the ``bulk_create`` importers do it
|
|
847
|
+
through ``declare_containers_in_batch``. This endpoint reached neither — it saves
|
|
848
|
+
per row through ``TaskSerializer`` — so a ``create`` naming an existing
|
|
849
|
+
``parent_id`` left that parent ``structure_role='work'`` **with children**, and the
|
|
850
|
+
Board's rendering rule (ADR-0843) drew it as a card rather than a lane. That is the
|
|
851
|
+
artifact epic #2946 exists to remove, on the endpoint its own headline gesture uses.
|
|
852
|
+
|
|
853
|
+
**Derived from ``out.created_ids``, not recorded as the rows apply.** A handler runs
|
|
854
|
+
inside its own savepoint and ``_rollback_bookkeeping`` truncates ``created_ids`` when
|
|
855
|
+
that savepoint rolls back — so reading the list afterwards is automatically correct
|
|
856
|
+
for the partial-application case, while a parent noted during ``_apply_create`` would
|
|
857
|
+
survive its child's rollback and declare a container for a row that does not exist.
|
|
858
|
+
|
|
859
|
+
**One query set for the batch, not one per row.** A 500-op batch under a single
|
|
860
|
+
parent must not become 500 probes; ``declare_ancestors_of_paths`` sweeps the paths
|
|
861
|
+
and locks the ancestor rows in chunks, which is the shape ``declare_containers_in_batch``
|
|
862
|
+
was written to preserve. Rows created *inside* this batch that are themselves parents
|
|
863
|
+
are covered too — they are ancestors of their own children's paths, and by this point
|
|
864
|
+
every row is in the table.
|
|
865
|
+
|
|
866
|
+
**A declared parent joins ``updated_ids``, or the promotion is invisible to everyone
|
|
867
|
+
else.** ``tasks_bulk_mutated`` carries the batch's ids so clients refetch *those rows*
|
|
868
|
+
rather than the whole board (#1009), and the parent is not any op's target — so
|
|
869
|
+
leaving it out would broadcast a change set that omits the one row whose rendering
|
|
870
|
+
actually changed, and a collaborator's board would keep drawing a card where there is
|
|
871
|
+
now a lane. It does **not** join ``applied``: that bucket is the per-op report,
|
|
872
|
+
correlated by ``index``, and this row answers to no op.
|
|
873
|
+
"""
|
|
874
|
+
from trueppm_api.apps.projects.models import Task, declare_ancestors_of_paths
|
|
875
|
+
|
|
876
|
+
if not out.created_ids:
|
|
877
|
+
return
|
|
878
|
+
paths = {
|
|
879
|
+
str(path)
|
|
880
|
+
for path in Task.objects.filter(
|
|
881
|
+
pk__in=out.created_ids, is_subtask=False, is_deleted=False
|
|
882
|
+
).values_list("wbs_path", flat=True)
|
|
883
|
+
if path
|
|
884
|
+
}
|
|
885
|
+
if not paths:
|
|
886
|
+
return
|
|
887
|
+
for parent in declare_ancestors_of_paths(ctx.project.pk, paths):
|
|
888
|
+
if parent.pk not in out.created_ids and parent.pk not in out.updated_ids:
|
|
889
|
+
out.updated_ids.append(parent.pk)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _outcome_lengths(out: BulkOutcome) -> tuple[int, ...]:
|
|
893
|
+
return (
|
|
894
|
+
len(out.applied),
|
|
895
|
+
len(out.rejected),
|
|
896
|
+
len(out.skipped),
|
|
897
|
+
len(out.created_ids),
|
|
898
|
+
len(out.updated_ids),
|
|
899
|
+
len(out.deleted_ids),
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _rollback_bookkeeping(out: BulkOutcome, before: tuple[int, ...]) -> None:
|
|
904
|
+
"""Discard entries a handler recorded before its savepoint rolled back.
|
|
905
|
+
|
|
906
|
+
Without this, a row whose serializer save succeeded and whose *database flush*
|
|
907
|
+
then failed would stay in ``applied`` and in the broadcast id list while its
|
|
908
|
+
write was rolled back — reporting a mutation that did not happen.
|
|
909
|
+
"""
|
|
910
|
+
(
|
|
911
|
+
applied,
|
|
912
|
+
rejected,
|
|
913
|
+
skipped,
|
|
914
|
+
created,
|
|
915
|
+
updated,
|
|
916
|
+
deleted,
|
|
917
|
+
) = before
|
|
918
|
+
del out.applied[applied:]
|
|
919
|
+
del out.rejected[rejected:]
|
|
920
|
+
del out.skipped[skipped:]
|
|
921
|
+
del out.created_ids[created:]
|
|
922
|
+
del out.updated_ids[updated:]
|
|
923
|
+
del out.deleted_ids[deleted:]
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
class InvalidGraphInput(Exception):
|
|
927
|
+
"""The batch's dependency graph is malformed rather than cyclic.
|
|
928
|
+
|
|
929
|
+
``validate_task_graph`` can raise instead of returning a cycle path — a
|
|
930
|
+
pathological summary fan-out beyond the engine's cap, or a ``children_map`` that
|
|
931
|
+
is itself cyclic. That is a 400 on the batch as a whole, **not** a per-edge
|
|
932
|
+
``cyclic_dependency`` rejection: there is no identified cycle path, so there is
|
|
933
|
+
no principled subset of edges to reject (ADR-0772 §4).
|
|
934
|
+
"""
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _cycle_edge_pairs(cycle_path: list[str]) -> set[tuple[str, str]]:
|
|
938
|
+
"""Consecutive ``(pred, succ)`` pairs along a reported cycle path.
|
|
939
|
+
|
|
940
|
+
``find_cycle`` returns the path with the first id repeated at the end
|
|
941
|
+
(``["A", "B", "A"]``), so the edges on the cycle are its adjacent pairs.
|
|
942
|
+
"""
|
|
943
|
+
return {(cycle_path[i], cycle_path[i + 1]) for i in range(len(cycle_path) - 1)}
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _resolve_edges(
|
|
947
|
+
edge_rows: list[dict[str, Any]], ctx: BulkContext, out: BulkOutcome
|
|
948
|
+
) -> list[_EdgeEntry]:
|
|
949
|
+
"""Resolve and role-gate every proposed edge; return the survivors.
|
|
950
|
+
|
|
951
|
+
Two gates, both per-edge rather than per-request, so a Member's task rows still
|
|
952
|
+
apply and only their edge rows reject (ADR-0772 §3).
|
|
953
|
+
"""
|
|
954
|
+
from trueppm_api.apps.projects.models import Task
|
|
955
|
+
|
|
956
|
+
survivors: list[_EdgeEntry] = []
|
|
957
|
+
for index, row in enumerate(edge_rows):
|
|
958
|
+
# Dependency ops sit at IsProjectScheduler — one role ABOVE this view's
|
|
959
|
+
# IsProjectMemberWrite floor. Accepting edges under the view's own gate
|
|
960
|
+
# would silently hand dependency authoring to Member.
|
|
961
|
+
if ctx.caller_role < Role.SCHEDULER:
|
|
962
|
+
out.dep_reject(
|
|
963
|
+
index, CODE_FORBIDDEN, "Your role cannot create dependencies on this project."
|
|
964
|
+
)
|
|
965
|
+
continue
|
|
966
|
+
if row.get("id") is not None:
|
|
967
|
+
# Client-minted ids are scoped to Task only. All four guards are written
|
|
968
|
+
# about task ids and none would cover a caller-supplied Dependency.id, so
|
|
969
|
+
# a supplied edge id is rejected rather than ignored.
|
|
970
|
+
out.dep_reject(index, CODE_INVALID, "Dependency ids are server-assigned.")
|
|
971
|
+
continue
|
|
972
|
+
|
|
973
|
+
pred = parse_row_uuid(row.get("predecessor"))
|
|
974
|
+
succ = parse_row_uuid(row.get("successor"))
|
|
975
|
+
if pred is None or succ is None:
|
|
976
|
+
out.dep_reject(index, CODE_MALFORMED_ID, "Edge endpoints must be task UUIDs.")
|
|
977
|
+
continue
|
|
978
|
+
# Forward references are legal: an edge may name a task whose create op
|
|
979
|
+
# appears LATER in `operations`, because phase 1 has already materialized
|
|
980
|
+
# every row by the time we get here.
|
|
981
|
+
live = set(
|
|
982
|
+
Task.objects.filter(pk__in=[pred, succ], is_deleted=False).values_list("pk", flat=True)
|
|
983
|
+
)
|
|
984
|
+
if pred not in live or succ not in live:
|
|
985
|
+
out.dep_reject(
|
|
986
|
+
index, CODE_UNRESOLVED_ENDPOINT, "Edge endpoint does not resolve to a live task."
|
|
987
|
+
)
|
|
988
|
+
continue
|
|
989
|
+
survivors.append((index, row, str(pred), str(succ)))
|
|
990
|
+
return survivors
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _drop_offending_edges(
|
|
994
|
+
remaining: list[_EdgeEntry],
|
|
995
|
+
offending_pairs: set[tuple[str, str]],
|
|
996
|
+
out: BulkOutcome,
|
|
997
|
+
code: str,
|
|
998
|
+
message: str,
|
|
999
|
+
offending: list[str],
|
|
1000
|
+
) -> list[_EdgeEntry] | None:
|
|
1001
|
+
"""Reject the batch edges the guard blamed; return the survivors.
|
|
1002
|
+
|
|
1003
|
+
Returns ``None`` when nothing was dropped. That is the terminating signal for
|
|
1004
|
+
the caller's loop: every edge the guard named is already persisted, so this
|
|
1005
|
+
batch offered nothing on the offending path and rejecting more of its own rows
|
|
1006
|
+
cannot clear the condition.
|
|
1007
|
+
"""
|
|
1008
|
+
kept = [entry for entry in remaining if (entry[2], entry[3]) not in offending_pairs]
|
|
1009
|
+
if len(kept) == len(remaining):
|
|
1010
|
+
return None
|
|
1011
|
+
for index, _row, pred, succ in remaining:
|
|
1012
|
+
if (pred, succ) in offending_pairs:
|
|
1013
|
+
out.dep_reject(index, code, message, offending=offending)
|
|
1014
|
+
return kept
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _guard_edge_graph(
|
|
1018
|
+
survivors: list[_EdgeEntry], ctx: BulkContext, out: BulkOutcome
|
|
1019
|
+
) -> list[_EdgeEntry]:
|
|
1020
|
+
"""Run the ADR-0259 graph guard over existing ∪ batch edges, before any write.
|
|
1021
|
+
|
|
1022
|
+
The guard is absolute over what gets written, and what it refuses to write is
|
|
1023
|
+
exactly the edges that would close a cycle — so "one bad row out of 38 must not
|
|
1024
|
+
discard the other 37" stays true without weakening it. Task rows already applied
|
|
1025
|
+
in phase 1 are never rolled back by a cycle.
|
|
1026
|
+
|
|
1027
|
+
Iterates because rejecting one cycle can leave another: each pass drops the batch
|
|
1028
|
+
edges lying on the reported path and re-runs. A pass that finds a cycle composed
|
|
1029
|
+
entirely of *already-persisted* edges terminates the loop — that is a pre-existing
|
|
1030
|
+
condition this batch did not introduce and cannot fix by rejecting its own rows.
|
|
1031
|
+
"""
|
|
1032
|
+
from trueppm_scheduler import InvalidScheduleInput
|
|
1033
|
+
|
|
1034
|
+
from trueppm_api.apps.projects.models import Dependency
|
|
1035
|
+
from trueppm_api.apps.projects.serializers import _load_project_tasks_and_children_map
|
|
1036
|
+
from trueppm_api.apps.scheduling.graph_guard import (
|
|
1037
|
+
InfeasibleGraphError,
|
|
1038
|
+
validate_task_graph,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
if not survivors:
|
|
1042
|
+
return survivors
|
|
1043
|
+
|
|
1044
|
+
task_ids, children_map = _load_project_tasks_and_children_map(ctx.project.pk)
|
|
1045
|
+
persisted: list[tuple[str, str]] = [
|
|
1046
|
+
(str(p), str(s))
|
|
1047
|
+
for p, s in Dependency.objects.filter(
|
|
1048
|
+
is_deleted=False, predecessor_id__in=[uuid.UUID(t) for t in task_ids]
|
|
1049
|
+
).values_list("predecessor_id", "successor_id")
|
|
1050
|
+
]
|
|
1051
|
+
|
|
1052
|
+
remaining = list(survivors)
|
|
1053
|
+
while remaining:
|
|
1054
|
+
batch_edges = [(pred, succ) for _idx, _row, pred, succ in remaining]
|
|
1055
|
+
try:
|
|
1056
|
+
validate_task_graph(persisted + batch_edges, children_map=children_map)
|
|
1057
|
+
except InfeasibleGraphError as exc:
|
|
1058
|
+
# Both verdicts reduce to the same shape — a set of offending (pred, succ)
|
|
1059
|
+
# pairs — so they share one rejection pass. A self-reference is the edge
|
|
1060
|
+
# (x, x) for each blamed node x; a cycle is the adjacent pairs along the
|
|
1061
|
+
# reported path.
|
|
1062
|
+
if exc.reason == "self_reference":
|
|
1063
|
+
offending_pairs = {(node, node) for node in exc.offending}
|
|
1064
|
+
code = CODE_SELF_REFERENCE
|
|
1065
|
+
message = "A task cannot depend on itself."
|
|
1066
|
+
else:
|
|
1067
|
+
offending_pairs = _cycle_edge_pairs(exc.offending)
|
|
1068
|
+
code = CODE_CYCLIC_DEPENDENCY
|
|
1069
|
+
message = "This dependency would create a cycle."
|
|
1070
|
+
|
|
1071
|
+
kept = _drop_offending_edges(
|
|
1072
|
+
remaining, offending_pairs, out, code, message, exc.offending
|
|
1073
|
+
)
|
|
1074
|
+
if kept is None:
|
|
1075
|
+
# Nothing of ours is on the offending path — it lies entirely in
|
|
1076
|
+
# already-persisted edges, so dropping more of our own rows cannot help.
|
|
1077
|
+
break
|
|
1078
|
+
remaining = kept
|
|
1079
|
+
continue
|
|
1080
|
+
except InvalidScheduleInput as exc:
|
|
1081
|
+
raise InvalidGraphInput(str(exc)) from exc
|
|
1082
|
+
return remaining
|
|
1083
|
+
return remaining
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def apply_dependency_edges(
|
|
1087
|
+
edge_rows: list[dict[str, Any]], ctx: BulkContext, out: BulkOutcome
|
|
1088
|
+
) -> set[str]:
|
|
1089
|
+
"""Phase 2 — resolve, guard, then write the batch's dependency edges.
|
|
1090
|
+
|
|
1091
|
+
Runs after phase 1 inside the SAME ``transaction.atomic()``, so a crash between
|
|
1092
|
+
the two cannot leave tasks without their batch's edges. Returns the set of
|
|
1093
|
+
project ids whose schedule the written edges invalidated — the predecessor's
|
|
1094
|
+
project always, plus the successor's when an accepted cross-project edge changes
|
|
1095
|
+
the CPM input on both sides (ADR-0120 D3).
|
|
1096
|
+
|
|
1097
|
+
Skipped entirely when the batch carries no edges: the union graph is built from
|
|
1098
|
+
every persisted edge in the project, so its cost scales with project size rather
|
|
1099
|
+
than batch size, and a paste-many of 38 rows that creates no edges must not pay
|
|
1100
|
+
for the rare case that does.
|
|
1101
|
+
"""
|
|
1102
|
+
from trueppm_api.apps.projects.serializers import DependencySerializer
|
|
1103
|
+
|
|
1104
|
+
if not edge_rows:
|
|
1105
|
+
return set()
|
|
1106
|
+
|
|
1107
|
+
survivors = _guard_edge_graph(_resolve_edges(edge_rows, ctx, out), ctx, out)
|
|
1108
|
+
recalc_project_ids: set[str] = set()
|
|
1109
|
+
|
|
1110
|
+
for index, row, pred, succ in survivors:
|
|
1111
|
+
payload = {
|
|
1112
|
+
"predecessor": pred,
|
|
1113
|
+
"successor": succ,
|
|
1114
|
+
"dep_type": row.get("dep_type", "FS"),
|
|
1115
|
+
"lag": row.get("lag", 0),
|
|
1116
|
+
}
|
|
1117
|
+
before = (len(out.dep_applied), len(out.dep_rejected))
|
|
1118
|
+
try:
|
|
1119
|
+
with transaction.atomic(savepoint=True):
|
|
1120
|
+
ser = DependencySerializer(data=payload, context=_row_serializer_context(ctx))
|
|
1121
|
+
ser.is_valid(raise_exception=True)
|
|
1122
|
+
# Writing through the serializer rather than Dependency.objects is not
|
|
1123
|
+
# a style preference: it carries the ADR-0120 D2 consent gate, the
|
|
1124
|
+
# cross-program rejection, the merged-program cycle check and the span
|
|
1125
|
+
# guard. A direct write re-implements four controls.
|
|
1126
|
+
consent = getattr(ser, "_consent", None)
|
|
1127
|
+
instance = ser.save(**consent) if consent else ser.save()
|
|
1128
|
+
pred_pid = str(instance.predecessor.project_id)
|
|
1129
|
+
succ_pid = str(instance.successor.project_id)
|
|
1130
|
+
recalc_project_ids.add(pred_pid)
|
|
1131
|
+
if pred_pid != succ_pid and not instance.pending_acceptance:
|
|
1132
|
+
recalc_project_ids.add(succ_pid)
|
|
1133
|
+
out.dep_applied.append(
|
|
1134
|
+
{
|
|
1135
|
+
"index": index,
|
|
1136
|
+
"id": str(instance.pk),
|
|
1137
|
+
"predecessor": pred,
|
|
1138
|
+
"successor": succ,
|
|
1139
|
+
"pending_acceptance": instance.pending_acceptance,
|
|
1140
|
+
}
|
|
1141
|
+
)
|
|
1142
|
+
except PermissionDenied as exc:
|
|
1143
|
+
del out.dep_applied[before[0] :]
|
|
1144
|
+
out.dep_reject(index, CODE_FORBIDDEN, str(exc.detail))
|
|
1145
|
+
except DRFValidationError as exc:
|
|
1146
|
+
del out.dep_applied[before[0] :]
|
|
1147
|
+
out.dep_reject(index, CODE_INVALID, _first_error_message(exc))
|
|
1148
|
+
except DatabaseError as exc:
|
|
1149
|
+
del out.dep_applied[before[0] :]
|
|
1150
|
+
out.dep_reject(index, CODE_CONFLICT, str(exc))
|
|
1151
|
+
|
|
1152
|
+
return recalc_project_ids
|