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,1106 @@
|
|
|
1
|
+
"""ViewSets for the integrations API (ADR-0049 §3).
|
|
2
|
+
|
|
3
|
+
Two surfaces live here:
|
|
4
|
+
|
|
5
|
+
- ``IntegrationCredentialViewSet`` — the per-user credentials store. Exposes
|
|
6
|
+
list, upsert (connect/rotate), and delete (revoke); read-of-the-secret is
|
|
7
|
+
intentionally absent and every action auto-scopes to ``request.user``.
|
|
8
|
+
- ``TaskLinkViewSet`` (#637) — git/PM links on a task, nested under
|
|
9
|
+
``/projects/{project_pk}/tasks/{task_pk}/links/`` with a synchronous refresh
|
|
10
|
+
action. Scoped to project membership; write follows task-edit, read follows
|
|
11
|
+
task-read.
|
|
12
|
+
|
|
13
|
+
Credentials URL contract:
|
|
14
|
+
GET /api/v1/me/credentials/
|
|
15
|
+
POST /api/v1/me/credentials/<provider>/
|
|
16
|
+
DELETE /api/v1/me/credentials/<provider>/
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import secrets
|
|
23
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
24
|
+
|
|
25
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
26
|
+
from django.db import transaction
|
|
27
|
+
from django.db.models import F
|
|
28
|
+
from django.http import Http404
|
|
29
|
+
from django.shortcuts import get_object_or_404
|
|
30
|
+
from django.utils import timezone
|
|
31
|
+
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
|
32
|
+
from rest_framework import mixins, status, viewsets
|
|
33
|
+
from rest_framework.decorators import action
|
|
34
|
+
from rest_framework.permissions import AllowAny, BasePermission, IsAuthenticated
|
|
35
|
+
from rest_framework.response import Response
|
|
36
|
+
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
|
|
37
|
+
from rest_framework.views import APIView
|
|
38
|
+
|
|
39
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
40
|
+
from trueppm_api.apps.access.permissions import (
|
|
41
|
+
IsNotTokenAuthenticated,
|
|
42
|
+
IsProjectAdmin,
|
|
43
|
+
IsProjectMember,
|
|
44
|
+
IsProjectMemberWrite,
|
|
45
|
+
IsProjectNotArchived,
|
|
46
|
+
ProjectScopedViewSet,
|
|
47
|
+
)
|
|
48
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
49
|
+
from trueppm_api.apps.projects.models import Project, Task
|
|
50
|
+
from trueppm_api.core.openapi import state_refusal_400, suppress_list_pagination
|
|
51
|
+
|
|
52
|
+
from . import git_webhook_auth, providers
|
|
53
|
+
from .encryption import CredentialEncryptionError, decrypt_secret
|
|
54
|
+
from .git_automation_services import apply_git_event_to_card
|
|
55
|
+
from .models import BoardAutomation, IntegrationCredential, TaskLink
|
|
56
|
+
from .registry import TASK_LINK_PROVIDERS
|
|
57
|
+
from .serializers import (
|
|
58
|
+
GIT_WEBHOOK_PAYLOAD_SCHEMA,
|
|
59
|
+
GIT_WEBHOOK_RESULT_SCHEMA,
|
|
60
|
+
CredentialSummarySerializer,
|
|
61
|
+
CredentialUpsertSerializer,
|
|
62
|
+
CredentialVerificationErrorSerializer,
|
|
63
|
+
GitAutomationConfigSerializer,
|
|
64
|
+
GitAutomationSecretSerializer,
|
|
65
|
+
GitAutomationUpdateSerializer,
|
|
66
|
+
TaskLinkCredentialRequiredSerializer,
|
|
67
|
+
TaskLinkSerializer,
|
|
68
|
+
serialize_credential_summaries,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if TYPE_CHECKING:
|
|
72
|
+
from django.db.models import QuerySet
|
|
73
|
+
from rest_framework.request import Request
|
|
74
|
+
from rest_framework.serializers import BaseSerializer
|
|
75
|
+
|
|
76
|
+
# Used only in ``cast("type[TaskLinkProvider]", ...)`` string annotations, so
|
|
77
|
+
# it needs no runtime import (keeps ruff and CodeQL in agreement).
|
|
78
|
+
from .registry import TaskLinkProvider
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Human-readable detail per ``VerifyResult.reason`` for the 422 body. The
|
|
82
|
+
# machine-readable ``reason`` is returned alongside so the client can branch
|
|
83
|
+
# without string-matching the prose.
|
|
84
|
+
_VERIFY_FAILURE_DETAIL: dict[str | None, str] = {
|
|
85
|
+
"invalid_token": (
|
|
86
|
+
"The provider rejected this token. Check it is valid, unexpired, and "
|
|
87
|
+
"issued for the correct host."
|
|
88
|
+
),
|
|
89
|
+
"provider_unreachable": (
|
|
90
|
+
"Could not reach the provider to verify this token. Check the host URL and try again."
|
|
91
|
+
),
|
|
92
|
+
"provider_timeout": "Verifying this token with the provider timed out. Try again.",
|
|
93
|
+
"blocked_host": ("The host URL is not allowed — it resolves to a private or internal address."),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
logger = logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
# ── Inbound Git-webhook delivery outcomes (#2882) ────────────────────────────
|
|
99
|
+
# The vocabulary written to ``BoardAutomation.last_delivery_outcome`` and logged by
|
|
100
|
+
# the receiver. Pre-verification outcomes are what an operator cannot otherwise see
|
|
101
|
+
# at all: every one of them returns the same opaque 404 to the caller (#2881), so
|
|
102
|
+
# this record and the receiver log are the *only* places the reason survives.
|
|
103
|
+
# Post-verification outcomes duplicate the ``reason`` already in the 200 body, which
|
|
104
|
+
# only the provider's delivery log shows — the record is what puts them in front of
|
|
105
|
+
# the admin who is asking "why didn't my card move?".
|
|
106
|
+
GIT_DELIVERY_DISABLED = "automation_disabled"
|
|
107
|
+
GIT_DELIVERY_NO_SECRET = "no_secret"
|
|
108
|
+
GIT_DELIVERY_UNKNOWN_PROVIDER = "unknown_provider"
|
|
109
|
+
GIT_DELIVERY_SECRET_UNREADABLE = "secret_unreadable"
|
|
110
|
+
GIT_DELIVERY_BAD_SIGNATURE = "bad_signature"
|
|
111
|
+
#: The project is archived, so the receiver refuses to move its cards (#3414).
|
|
112
|
+
#: A *refusal*, not a delivery outcome: it is decided before the signature is
|
|
113
|
+
#: verified, so it answers the same bare 404 as every other pre-verification
|
|
114
|
+
#: refusal and cannot tell an unauthenticated caller that the project exists,
|
|
115
|
+
#: is configured, or is archived.
|
|
116
|
+
GIT_DELIVERY_PROJECT_ARCHIVED = "project_archived"
|
|
117
|
+
GIT_DELIVERY_MALFORMED = "malformed_payload"
|
|
118
|
+
GIT_DELIVERY_IGNORED = "ignored"
|
|
119
|
+
GIT_DELIVERY_DUPLICATE = "duplicate"
|
|
120
|
+
|
|
121
|
+
# Body cap for the inbound Git webhook, enforced *before* the body is buffered.
|
|
122
|
+
# Without it the only bound was the global 100 MB ``DATA_UPLOAD_MAX_MEMORY_SIZE``,
|
|
123
|
+
# so an anonymous caller could push 20 MB at a project UUID that has no automation
|
|
124
|
+
# at all and have it fully accepted before the 404 (#2881). A ``pull_request`` /
|
|
125
|
+
# ``merge_request` payload is tens of kilobytes; GitHub itself caps a delivery at
|
|
126
|
+
# 25 MB. 1 MB leaves two orders of magnitude of headroom over real traffic while
|
|
127
|
+
# taking the amplification factor from 100 MB to 1 MB. Not operator-tunable on
|
|
128
|
+
# purpose — an operator has no legitimate reason to widen it, and every knob on an
|
|
129
|
+
# unauthenticated path is a knob that can be widened by mistake.
|
|
130
|
+
_WEBHOOK_MAX_BODY_BYTES = 1 * 1024 * 1024
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@extend_schema(tags=["me"])
|
|
134
|
+
class IntegrationCredentialViewSet(
|
|
135
|
+
IdempotencyMixin, viewsets.GenericViewSet[IntegrationCredential]
|
|
136
|
+
):
|
|
137
|
+
"""Per-user integration credentials.
|
|
138
|
+
|
|
139
|
+
Lookup is by ``provider`` key rather than the row PK so the URL is
|
|
140
|
+
stable across rotations (rotating creates a new ciphertext but does
|
|
141
|
+
not change the row identity from the client's perspective).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
serializer_class = CredentialSummarySerializer
|
|
145
|
+
# Session/JWT only (#2878), same rule as the API-token surface: a credential store
|
|
146
|
+
# is not something a credential may rewrite. A leaked personal access token could
|
|
147
|
+
# otherwise overwrite or delete the owner's connected-account credential — swapping
|
|
148
|
+
# in the attacker's own Jira so it feeds the owner's "My Work", or simply breaking
|
|
149
|
+
# the link. There is no exfiltration risk (the ciphertext is never serialized), but
|
|
150
|
+
# "my script manages my credentials" is not an automation story worth leaving open
|
|
151
|
+
# to a bearer the owner did not knowingly hand it to.
|
|
152
|
+
permission_classes: list[type[BasePermission]] = [IsAuthenticated, IsNotTokenAuthenticated]
|
|
153
|
+
lookup_field = "provider"
|
|
154
|
+
lookup_url_kwarg = "provider"
|
|
155
|
+
# Rate-limit the whole viewset under one per-user scope (#1551). connect/rotate
|
|
156
|
+
# mint a fresh ciphertext and revoke/read/list all touch the credential store;
|
|
157
|
+
# a shared 10/min bucket bounds credential-store abuse without a per-action idiom.
|
|
158
|
+
throttle_classes = [ScopedRateThrottle]
|
|
159
|
+
throttle_scope = "credential_rotate"
|
|
160
|
+
|
|
161
|
+
def get_queryset(self) -> QuerySet[IntegrationCredential]:
|
|
162
|
+
"""Restrict to the authenticated user's rows only.
|
|
163
|
+
|
|
164
|
+
The queryset is the single permission boundary — list, retrieve,
|
|
165
|
+
upsert, and delete all read through it, so cross-user access is
|
|
166
|
+
impossible by construction.
|
|
167
|
+
"""
|
|
168
|
+
user = self.request.user
|
|
169
|
+
if not user.is_authenticated:
|
|
170
|
+
return IntegrationCredential.objects.none()
|
|
171
|
+
return IntegrationCredential.objects.filter(user=user)
|
|
172
|
+
|
|
173
|
+
@extend_schema(responses={200: CredentialSummarySerializer(many=True)})
|
|
174
|
+
@suppress_list_pagination
|
|
175
|
+
def list(self, request: Request) -> Response:
|
|
176
|
+
"""Return one summary row per registered provider.
|
|
177
|
+
|
|
178
|
+
Providers without a stored credential still appear (``exists=False``)
|
|
179
|
+
so the page can render the "Not connected" state without two round-
|
|
180
|
+
trips. The encrypted secret is never serialized.
|
|
181
|
+
"""
|
|
182
|
+
queryset = list(self.get_queryset())
|
|
183
|
+
payload = serialize_credential_summaries(request.user, queryset)
|
|
184
|
+
return Response(CredentialSummarySerializer(payload, many=True).data)
|
|
185
|
+
|
|
186
|
+
@extend_schema(
|
|
187
|
+
request=CredentialUpsertSerializer,
|
|
188
|
+
responses={
|
|
189
|
+
200: CredentialSummarySerializer(many=True),
|
|
190
|
+
422: CredentialVerificationErrorSerializer,
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
@suppress_list_pagination
|
|
194
|
+
def create(self, request: Request, provider: str | None = None) -> Response:
|
|
195
|
+
"""Connect or rotate the credential for ``provider``.
|
|
196
|
+
|
|
197
|
+
``create`` is a deliberate misnomer here: connect-and-rotate are
|
|
198
|
+
the same upsert. The viewset uses ``create`` so the standard DRF
|
|
199
|
+
action set names the route ``POST /<provider>/`` rather than
|
|
200
|
+
``POST /<provider>/rotate/``, which is what ADR-0049 §3 spells out.
|
|
201
|
+
|
|
202
|
+
Before persisting, the PAT is verified against the provider (#677):
|
|
203
|
+
we ping the provider's ``/user`` endpoint with the token so a wrong,
|
|
204
|
+
expired, wrong-scope, or wrong-host token is rejected here with 422
|
|
205
|
+
rather than silently accepted and discovered later by #637's status
|
|
206
|
+
fetch. The plaintext is never written to the DB on a failed verify —
|
|
207
|
+
``upsert`` (and therefore encryption) is not reached.
|
|
208
|
+
"""
|
|
209
|
+
provider_cls = TASK_LINK_PROVIDERS.get(provider) if provider is not None else None
|
|
210
|
+
if provider is None or provider_cls is None:
|
|
211
|
+
return Response(
|
|
212
|
+
{"detail": f"Unknown provider {provider!r}."},
|
|
213
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
214
|
+
)
|
|
215
|
+
serializer = CredentialUpsertSerializer(data=request.data)
|
|
216
|
+
serializer.is_valid(raise_exception=True)
|
|
217
|
+
secret = serializer.validated_data["secret"]
|
|
218
|
+
base_url = serializer.validated_data.get("base_url", "")
|
|
219
|
+
|
|
220
|
+
# Gate the host BEFORE verify_token: verify ships the PAT to base_url, so
|
|
221
|
+
# an attacker-controlled host must be rejected before the token is on the
|
|
222
|
+
# wire (#902), not merely have its 422 surfaced after exfiltration.
|
|
223
|
+
try:
|
|
224
|
+
providers.assert_base_url_allowed(provider, base_url)
|
|
225
|
+
except providers.BaseUrlNotAllowed as exc:
|
|
226
|
+
return Response(
|
|
227
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
228
|
+
{"detail": str(exc), "code": "base_url_not_allowed"},
|
|
229
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
provider_handler = cast("type[TaskLinkProvider]", provider_cls)
|
|
233
|
+
result = provider_handler.verify_token(secret, base_url=base_url or None)
|
|
234
|
+
if not result.ok:
|
|
235
|
+
return Response(
|
|
236
|
+
{
|
|
237
|
+
"detail": _VERIFY_FAILURE_DETAIL.get(
|
|
238
|
+
result.reason,
|
|
239
|
+
f"Could not verify the credential with {provider}.",
|
|
240
|
+
),
|
|
241
|
+
"code": "provider_verification_failed",
|
|
242
|
+
"reason": result.reason,
|
|
243
|
+
},
|
|
244
|
+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
IntegrationCredential.upsert(
|
|
248
|
+
user=request.user,
|
|
249
|
+
provider=provider,
|
|
250
|
+
secret=secret,
|
|
251
|
+
base_url=base_url,
|
|
252
|
+
expires_at=serializer.validated_data.get("expires_at"),
|
|
253
|
+
)
|
|
254
|
+
# Re-render the summary list so the client doesn't need a second
|
|
255
|
+
# request to refresh after a connect / rotate.
|
|
256
|
+
queryset = list(self.get_queryset())
|
|
257
|
+
payload = serialize_credential_summaries(request.user, queryset)
|
|
258
|
+
return Response(
|
|
259
|
+
CredentialSummarySerializer(payload, many=True).data,
|
|
260
|
+
status=status.HTTP_200_OK,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
@extend_schema(
|
|
264
|
+
responses={
|
|
265
|
+
204: None,
|
|
266
|
+
400: state_refusal_400(
|
|
267
|
+
"``provider`` does not name a registered task-link provider. The "
|
|
268
|
+
"path segment is free text, so an unregistered value reaches the "
|
|
269
|
+
"handler and is refused here rather than 404ing (#3319)."
|
|
270
|
+
),
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
def destroy(self, request: Request, provider: str | None = None) -> Response:
|
|
274
|
+
"""Revoke (delete) the credential for ``provider``.
|
|
275
|
+
|
|
276
|
+
Returns 204 even if no row exists — DELETE is idempotent. Clients
|
|
277
|
+
polling the list after a revoke see ``exists=False`` either way.
|
|
278
|
+
"""
|
|
279
|
+
if provider is None or TASK_LINK_PROVIDERS.get(provider) is None:
|
|
280
|
+
return Response(
|
|
281
|
+
{"detail": f"Unknown provider {provider!r}."},
|
|
282
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
283
|
+
)
|
|
284
|
+
# filter().delete() rather than get()+delete() so DELETE on a
|
|
285
|
+
# never-connected provider is a 204 no-op rather than a 404.
|
|
286
|
+
self.get_queryset().filter(provider=provider).delete()
|
|
287
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
288
|
+
|
|
289
|
+
@extend_schema(responses={200: CredentialSummarySerializer})
|
|
290
|
+
def retrieve(self, request: Request, provider: str | None = None) -> Response:
|
|
291
|
+
"""Return the single-provider summary row.
|
|
292
|
+
|
|
293
|
+
Convenience endpoint for the deep-link case (``#github`` anchor on
|
|
294
|
+
the page) when the client wants a single row's freshness rather
|
|
295
|
+
than the full list.
|
|
296
|
+
"""
|
|
297
|
+
if provider is None or TASK_LINK_PROVIDERS.get(provider) is None:
|
|
298
|
+
return Response(
|
|
299
|
+
{"detail": f"Unknown provider {provider!r}."},
|
|
300
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
301
|
+
)
|
|
302
|
+
queryset = list(self.get_queryset().filter(provider=provider))
|
|
303
|
+
payload = serialize_credential_summaries(request.user, queryset)
|
|
304
|
+
match = next((row for row in payload if row["provider"] == provider), None)
|
|
305
|
+
if match is None: # pragma: no cover — provider validated above
|
|
306
|
+
raise Http404
|
|
307
|
+
return Response(CredentialSummarySerializer(match).data)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@extend_schema(tags=["tasks"])
|
|
311
|
+
class TaskLinkViewSet(
|
|
312
|
+
ProjectScopedViewSet,
|
|
313
|
+
mixins.ListModelMixin,
|
|
314
|
+
mixins.CreateModelMixin,
|
|
315
|
+
mixins.RetrieveModelMixin,
|
|
316
|
+
mixins.UpdateModelMixin,
|
|
317
|
+
mixins.DestroyModelMixin,
|
|
318
|
+
viewsets.GenericViewSet[TaskLink],
|
|
319
|
+
):
|
|
320
|
+
"""Git/PM links on a task (ADR-0049 §3, #637; redesign #970).
|
|
321
|
+
|
|
322
|
+
Routes (relative to ``/projects/{project_pk}/tasks/{task_pk}/``):
|
|
323
|
+
GET links/
|
|
324
|
+
POST links/ (body ``{url, custom_title?, labels?}``; provider detected)
|
|
325
|
+
GET links/{pk}/
|
|
326
|
+
PATCH links/{pk}/ (edit custom_title / labels / url / display_order)
|
|
327
|
+
DELETE links/{pk}/ (soft-delete)
|
|
328
|
+
POST links/{pk}/refresh/ (synchronous, 5s; refresh cached status)
|
|
329
|
+
|
|
330
|
+
Permissions: create/update/destroy follow task-edit (Member+, ``IsProjectMemberWrite``);
|
|
331
|
+
list/retrieve/refresh follow task-read (Viewer+, ``IsProjectMember``). The
|
|
332
|
+
queryset is the IDOR boundary — it scopes to the caller's project membership
|
|
333
|
+
and the task in the URL, so a link id from another project is a 404.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
serializer_class = TaskLinkSerializer
|
|
337
|
+
# A task carries a handful of links — return the bare array (matching the
|
|
338
|
+
# client contract and the credentials viewset) rather than a paged envelope.
|
|
339
|
+
pagination_class = None
|
|
340
|
+
# PATCH-only edits (custom_title/labels/url). A full PUT replace of the
|
|
341
|
+
# server-owned status/provider/title fields is meaningless, so drop it.
|
|
342
|
+
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
|
343
|
+
|
|
344
|
+
def get_queryset(self) -> QuerySet[TaskLink]:
|
|
345
|
+
"""Scope to the caller's project membership + the task in the URL.
|
|
346
|
+
|
|
347
|
+
Explicit membership check (rather than ``ProjectScopedViewSet``'s
|
|
348
|
+
``project``/``predecessor`` auto-filter) because ``TaskLink`` reaches
|
|
349
|
+
the project via ``task__project`` — the same pattern as
|
|
350
|
+
``TaskAttachmentViewSet``.
|
|
351
|
+
"""
|
|
352
|
+
user = self.request.user
|
|
353
|
+
if not user.is_authenticated:
|
|
354
|
+
return TaskLink.objects.none()
|
|
355
|
+
project_pk = self.kwargs["project_pk"]
|
|
356
|
+
task_pk = self.kwargs["task_pk"]
|
|
357
|
+
if not ProjectMembership.objects.filter(
|
|
358
|
+
user=user, project_id=project_pk, is_deleted=False
|
|
359
|
+
).exists():
|
|
360
|
+
return TaskLink.objects.none()
|
|
361
|
+
return TaskLink.objects.filter(
|
|
362
|
+
task__project_id=project_pk,
|
|
363
|
+
task_id=task_pk,
|
|
364
|
+
is_deleted=False,
|
|
365
|
+
).select_related("task")
|
|
366
|
+
|
|
367
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
368
|
+
if self.action in ("create", "update", "partial_update", "destroy"):
|
|
369
|
+
return [IsAuthenticated(), IsProjectMemberWrite(), IsProjectNotArchived()]
|
|
370
|
+
return [IsAuthenticated(), IsProjectMember(), IsProjectNotArchived()]
|
|
371
|
+
|
|
372
|
+
def get_throttles(self) -> list[BaseThrottle]:
|
|
373
|
+
"""Rate-limit only the refresh action — it makes an outbound fetch (#571).
|
|
374
|
+
|
|
375
|
+
The cloud-file unfurl is an anonymous outbound GET, so refresh is a
|
|
376
|
+
potential egress amplifier; the per-user throttle caps the call rate on
|
|
377
|
+
top of the SSRF guard. CRUD reads/writes touch only the DB and stay
|
|
378
|
+
un-throttled.
|
|
379
|
+
"""
|
|
380
|
+
if self.action == "refresh":
|
|
381
|
+
from .throttles import TaskLinkRefreshThrottle
|
|
382
|
+
|
|
383
|
+
return [TaskLinkRefreshThrottle()]
|
|
384
|
+
return super().get_throttles()
|
|
385
|
+
|
|
386
|
+
def _get_task(self) -> Task:
|
|
387
|
+
return get_object_or_404(
|
|
388
|
+
Task,
|
|
389
|
+
pk=self.kwargs["task_pk"],
|
|
390
|
+
project_id=self.kwargs["project_pk"],
|
|
391
|
+
is_deleted=False,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def perform_create(self, serializer: BaseSerializer[TaskLink]) -> None:
|
|
395
|
+
"""Create a link, resolving its provider server-side from the URL.
|
|
396
|
+
|
|
397
|
+
``provider`` is never trusted from the client — it is resolved against
|
|
398
|
+
the SaaS hosts and the caller's connected self-hosted ``base_url`` hosts.
|
|
399
|
+
Status starts ``unknown`` (no fetch on add, per ADR-0049 §3).
|
|
400
|
+
"""
|
|
401
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
402
|
+
|
|
403
|
+
task = self._get_task()
|
|
404
|
+
self.check_object_permissions(self.request, task)
|
|
405
|
+
url = serializer.validated_data["url"]
|
|
406
|
+
provider = providers.resolve_provider_key(url, user=self.request.user)
|
|
407
|
+
instance = serializer.save(task=task, provider=provider)
|
|
408
|
+
link_id = str(instance.pk)
|
|
409
|
+
task_id = str(task.pk)
|
|
410
|
+
project_id = str(task.project_id)
|
|
411
|
+
transaction.on_commit(
|
|
412
|
+
lambda: broadcast_board_event(
|
|
413
|
+
project_id, "task_link_created", {"id": link_id, "task_id": task_id}
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def perform_update(self, serializer: BaseSerializer[TaskLink]) -> None:
|
|
418
|
+
"""Edit a link's user fields (custom_title / labels / url) (#970).
|
|
419
|
+
|
|
420
|
+
If the ``url`` itself changes the provider is re-resolved server-side
|
|
421
|
+
(same rule as create — never trusted from the client); ``status`` /
|
|
422
|
+
``title`` / ``fetched_at`` are left untouched here and only move on an
|
|
423
|
+
explicit refresh. Broadcasts ``task_link_updated`` on commit so other
|
|
424
|
+
board viewers see the edited title/labels live.
|
|
425
|
+
"""
|
|
426
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
427
|
+
|
|
428
|
+
update_kwargs: dict[str, object] = {}
|
|
429
|
+
if "url" in serializer.validated_data:
|
|
430
|
+
update_kwargs["provider"] = providers.resolve_provider_key(
|
|
431
|
+
serializer.validated_data["url"], user=self.request.user
|
|
432
|
+
)
|
|
433
|
+
link = serializer.save(**update_kwargs)
|
|
434
|
+
link_id = str(link.pk)
|
|
435
|
+
task_id = str(link.task_id)
|
|
436
|
+
project_id = str(link.task.project_id)
|
|
437
|
+
transaction.on_commit(
|
|
438
|
+
lambda: broadcast_board_event(
|
|
439
|
+
project_id, "task_link_updated", {"id": link_id, "task_id": task_id}
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def perform_destroy(self, instance: TaskLink) -> None:
|
|
444
|
+
"""Soft-delete so the removal reaches mobile as a sync tombstone."""
|
|
445
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
446
|
+
|
|
447
|
+
instance.soft_delete()
|
|
448
|
+
link_id = str(instance.pk)
|
|
449
|
+
# instance.task is select_related-loaded by get_queryset, so .pk is free
|
|
450
|
+
# and avoids the django-stubs task_id descriptor gap.
|
|
451
|
+
task_id = str(instance.task.pk)
|
|
452
|
+
project_id = str(instance.task.project_id)
|
|
453
|
+
transaction.on_commit(
|
|
454
|
+
lambda: broadcast_board_event(
|
|
455
|
+
project_id, "task_link_deleted", {"id": link_id, "task_id": task_id}
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
@extend_schema(
|
|
460
|
+
request=None,
|
|
461
|
+
responses={200: TaskLinkSerializer, 422: TaskLinkCredentialRequiredSerializer},
|
|
462
|
+
)
|
|
463
|
+
@action(detail=True, methods=["post"])
|
|
464
|
+
def refresh(
|
|
465
|
+
self,
|
|
466
|
+
request: Request,
|
|
467
|
+
project_pk: str | None = None,
|
|
468
|
+
task_pk: str | None = None,
|
|
469
|
+
pk: str | None = None,
|
|
470
|
+
) -> Response:
|
|
471
|
+
"""Refresh the cached status/title from the provider (synchronous, 5s).
|
|
472
|
+
|
|
473
|
+
Read-permission action (a Viewer can refresh). If the provider needs a
|
|
474
|
+
PAT and the caller has not connected one, returns 422 ``credential_required``
|
|
475
|
+
so the UI can prompt a connect rather than silently leaving the link
|
|
476
|
+
``unknown``. Transport/parse failures degrade the status to ``unknown``
|
|
477
|
+
— ``fetch_metadata`` does not raise for an unreachable provider.
|
|
478
|
+
"""
|
|
479
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
480
|
+
|
|
481
|
+
user = request.user
|
|
482
|
+
if not user.is_authenticated:
|
|
483
|
+
return Response(status=status.HTTP_403_FORBIDDEN)
|
|
484
|
+
|
|
485
|
+
link = self.get_object()
|
|
486
|
+
handler_cls = TASK_LINK_PROVIDERS.get(link.provider)
|
|
487
|
+
if handler_cls is None:
|
|
488
|
+
# Provider un-registered (Enterprise downgraded / plugin removed) —
|
|
489
|
+
# nothing to fetch; return the row unchanged rather than 500ing.
|
|
490
|
+
return Response(TaskLinkSerializer(link).data)
|
|
491
|
+
|
|
492
|
+
requires_credential = getattr(handler_cls, "requires_credential", True)
|
|
493
|
+
credential = IntegrationCredential.objects.filter(user=user, provider=link.provider).first()
|
|
494
|
+
if requires_credential and credential is None:
|
|
495
|
+
return Response(
|
|
496
|
+
{
|
|
497
|
+
"detail": f"Connect your {link.provider} account to refresh this link.",
|
|
498
|
+
"code": "credential_required",
|
|
499
|
+
"provider": link.provider,
|
|
500
|
+
"requires_credential": True,
|
|
501
|
+
},
|
|
502
|
+
status=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
handler = cast("type[TaskLinkProvider]", handler_cls)()
|
|
506
|
+
metadata = handler.fetch_metadata(link.url, credential)
|
|
507
|
+
link.status = metadata.status
|
|
508
|
+
if metadata.title:
|
|
509
|
+
link.title = metadata.title
|
|
510
|
+
# Cloud-file preview cache (#571, ADR-0163). A file provider returns these;
|
|
511
|
+
# git/generic providers leave them None and the row's empty defaults stand.
|
|
512
|
+
# Coalesce None → "" so the column type (CharField/URLField) is satisfied
|
|
513
|
+
# and a provider that returns no preview clears any stale one.
|
|
514
|
+
link.description = metadata.description or ""
|
|
515
|
+
link.thumbnail_url = metadata.thumbnail_url or ""
|
|
516
|
+
link.preview_type = metadata.preview_type or ""
|
|
517
|
+
link.fetched_at = timezone.now()
|
|
518
|
+
# VersionedModel.save() bumps server_version atomically; we pass the
|
|
519
|
+
# changed fields only and let it handle the version bump + sync delta.
|
|
520
|
+
link.save(
|
|
521
|
+
update_fields=[
|
|
522
|
+
"status",
|
|
523
|
+
"title",
|
|
524
|
+
"description",
|
|
525
|
+
"thumbnail_url",
|
|
526
|
+
"preview_type",
|
|
527
|
+
"fetched_at",
|
|
528
|
+
]
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if credential is not None:
|
|
532
|
+
credential.last_used_at = timezone.now()
|
|
533
|
+
credential.save(update_fields=["last_used_at"])
|
|
534
|
+
|
|
535
|
+
link_id = str(link.pk)
|
|
536
|
+
task_id = str(link.task_id)
|
|
537
|
+
project_id = str(link.task.project_id)
|
|
538
|
+
transaction.on_commit(
|
|
539
|
+
lambda: broadcast_board_event(
|
|
540
|
+
project_id, "task_link_updated", {"id": link_id, "task_id": task_id}
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
return Response(TaskLinkSerializer(link).data)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _git_webhook_url(request: Request, project_pk: Any) -> str:
|
|
547
|
+
"""Absolute URL of a project's inbound Git-webhook receiver (admin pastes this).
|
|
548
|
+
|
|
549
|
+
Prefers the explicit ``TRUEPPM_PUBLIC_API_BASE_URL`` for the same reason
|
|
550
|
+
``sso.views._derive_redirect_uri`` does (#3515): this value leaves the system
|
|
551
|
+
— an admin copies it into GitHub/GitLab — so it must be the origin the
|
|
552
|
+
operator chose, not the ``Host`` header of whoever happened to ask. Behind a
|
|
553
|
+
proxy that rewrites ``Host`` the request-derived form yields an unreachable
|
|
554
|
+
internal name and fails silently forever, since the wrong URL only shows up
|
|
555
|
+
as webhooks that never arrive. Falls back to the request's absolute URI for
|
|
556
|
+
zero-config single-origin dev, where the two agree.
|
|
557
|
+
"""
|
|
558
|
+
from django.conf import settings
|
|
559
|
+
from django.urls import reverse
|
|
560
|
+
|
|
561
|
+
path = reverse("git-webhook", kwargs={"project_pk": str(project_pk)})
|
|
562
|
+
base = (getattr(settings, "TRUEPPM_PUBLIC_API_BASE_URL", "") or "").rstrip("/")
|
|
563
|
+
if base:
|
|
564
|
+
return f"{base}{path}"
|
|
565
|
+
return request.build_absolute_uri(path)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class _WebhookRefusal(Exception):
|
|
569
|
+
"""A pre-verification refusal and the reason for it (#2881).
|
|
570
|
+
|
|
571
|
+
Internal control flow, never surfaced: the receiver catches it and answers with
|
|
572
|
+
the one uniform 404 that every refusal shares, after recording ``outcome`` where
|
|
573
|
+
only a project admin can read it.
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
def __init__(
|
|
577
|
+
self, outcome: str, automation_id: Any = None, provider: str | None = None
|
|
578
|
+
) -> None:
|
|
579
|
+
super().__init__(outcome)
|
|
580
|
+
self.outcome = outcome
|
|
581
|
+
# ``None`` when there is no row to record against (no project, or automation
|
|
582
|
+
# never configured at all) — then the receiver log is the only record.
|
|
583
|
+
self.automation_id = automation_id
|
|
584
|
+
self.provider = provider
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _record_git_delivery(automation_id: Any, provider: str | None, outcome: str) -> None:
|
|
588
|
+
"""Stamp the last **verified** delivery on a ``BoardAutomation`` row (#2882).
|
|
589
|
+
|
|
590
|
+
Only reached once the signature has verified, which is the whole point — see
|
|
591
|
+
:func:`_record_git_refusal` for why the two are separate columns.
|
|
592
|
+
|
|
593
|
+
A queryset ``.update()`` rather than ``instance.save()`` on purpose: ``save()``
|
|
594
|
+
would fire ``auto_now`` on ``updated_at``, and that field means "an admin last
|
|
595
|
+
changed this config" — a webhook arriving must not look like a config change on
|
|
596
|
+
the settings card. One UPDATE, no model state reload, no signals.
|
|
597
|
+
"""
|
|
598
|
+
BoardAutomation.objects.filter(pk=automation_id).update(
|
|
599
|
+
last_delivery_at=timezone.now(),
|
|
600
|
+
last_delivery_outcome=outcome[:32],
|
|
601
|
+
last_delivery_provider=(provider or "")[:16],
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _record_git_refusal(automation_id: Any, provider: str | None, outcome: str) -> None:
|
|
606
|
+
"""Stamp the last **refused** delivery, in its own columns (#2882).
|
|
607
|
+
|
|
608
|
+
Deliberately not the same columns as :func:`_record_git_delivery`, and the reason
|
|
609
|
+
is not tidiness. A refusal is recorded *before* the signature is checked, so
|
|
610
|
+
anyone who knows the project's UUID — which is not a secret; every project Viewer
|
|
611
|
+
reads it out of the SPA URL, and that is #2881's own premise — can force one at
|
|
612
|
+
will with two forged headers. Sharing one column would hand that caller two
|
|
613
|
+
things: the power to erase the genuine ``no_link`` outcome an admin is in the
|
|
614
|
+
middle of diagnosing, and a UI that then tells the admin their secret is wrong and
|
|
615
|
+
to rotate it — taking down a working integration on an unauthenticated party's
|
|
616
|
+
say-so. Split, a hostile caller can only ever write to the row that is already
|
|
617
|
+
labeled "unauthenticated, may not be your provider".
|
|
618
|
+
"""
|
|
619
|
+
BoardAutomation.objects.filter(pk=automation_id).update(
|
|
620
|
+
last_refusal_at=timezone.now(),
|
|
621
|
+
last_refusal_outcome=outcome[:32],
|
|
622
|
+
last_refusal_provider=(provider or "")[:16],
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _client_ident(request: Request) -> str:
|
|
627
|
+
"""Client identity for the receiver log, resolved the way the throttle resolves it.
|
|
628
|
+
|
|
629
|
+
Reuses ``BaseThrottle.get_ident`` so the logged address is the same
|
|
630
|
+
``NUM_PROXIES``-aware value the per-IP bucket keys on — logging a raw
|
|
631
|
+
``REMOTE_ADDR`` would name the ingress on every line, and logging a raw
|
|
632
|
+
``X-Forwarded-For`` would name whatever the caller put there.
|
|
633
|
+
"""
|
|
634
|
+
from .throttles import GitWebhookIpThrottle
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
return str(GitWebhookIpThrottle().get_ident(request) or "-")
|
|
638
|
+
except Exception: # pragma: no cover - diagnostics must never break the response
|
|
639
|
+
return "-"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _provider_delivery_id(request: Request) -> str:
|
|
643
|
+
"""The provider's own delivery id, so a log line can be matched to their UI.
|
|
644
|
+
|
|
645
|
+
GitHub stamps ``X-GitHub-Delivery``; GitLab stamps ``X-Gitlab-Event-UUID``.
|
|
646
|
+
Truncated because it is attacker-controlled on a refusal — it is a correlation
|
|
647
|
+
hint, not evidence.
|
|
648
|
+
"""
|
|
649
|
+
raw = request.headers.get("X-GitHub-Delivery") or request.headers.get("X-Gitlab-Event-UUID")
|
|
650
|
+
return str(raw)[:64] if raw else ""
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _webhook_body_within_cap(request: Request) -> bool:
|
|
654
|
+
"""Whether the request body is inside :data:`_WEBHOOK_MAX_BODY_BYTES`.
|
|
655
|
+
|
|
656
|
+
Checks the declared ``Content-Length`` **first**, so an oversized delivery is
|
|
657
|
+
refused before this process copies it anywhere — the old code read ``request.body``
|
|
658
|
+
as its first statement and materialized up to the global 100 MB cap before it even
|
|
659
|
+
knew whether the project had automation.
|
|
660
|
+
|
|
661
|
+
Be precise about what this does and does not buy, because the obvious reading is
|
|
662
|
+
too strong. Under ASGI, Django assembles the request body into a spooled temporary
|
|
663
|
+
file during request setup, before any middleware, throttle, or view code runs — so
|
|
664
|
+
the bytes have already arrived by the time this executes, and no view-level check
|
|
665
|
+
can prevent that. What the cap removes is the *second*, in-memory copy and every
|
|
666
|
+
allocation downstream of it (HMAC over the full body, JSON parse, envelope). The
|
|
667
|
+
wire-level ceiling is the ingress ``proxy-body-size``, and bounding an oversized
|
|
668
|
+
delivery before it reaches the pod is an edge-config job, tracked separately.
|
|
669
|
+
|
|
670
|
+
Both providers always send ``Content-Length``. The ``request.body`` length check
|
|
671
|
+
behind it is the net for a chunked or header-less caller, where measuring the bytes
|
|
672
|
+
that actually arrived is the only option available (same trade-off as the
|
|
673
|
+
seed-import reader) — that path does take the in-memory copy before refusing.
|
|
674
|
+
"""
|
|
675
|
+
try:
|
|
676
|
+
declared = int(request.META.get("CONTENT_LENGTH") or 0)
|
|
677
|
+
except (TypeError, ValueError):
|
|
678
|
+
declared = 0
|
|
679
|
+
if declared > _WEBHOOK_MAX_BODY_BYTES:
|
|
680
|
+
return False
|
|
681
|
+
return len(request.body) <= _WEBHOOK_MAX_BODY_BYTES
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@extend_schema(
|
|
685
|
+
tags=["integrations"],
|
|
686
|
+
# Not ``None`` (#3364). ``request=None`` published "this endpoint accepts no
|
|
687
|
+
# body" while ``post`` reads ``request.data`` and refuses a non-object — a
|
|
688
|
+
# generated client had no parameter to send the provider payload through at all.
|
|
689
|
+
# See GIT_WEBHOOK_PAYLOAD_SCHEMA for why this is an open object rather than a
|
|
690
|
+
# serializer: the shape belongs to GitHub/GitLab, not to us.
|
|
691
|
+
#
|
|
692
|
+
# Keyed by media type, NOT passed bare. ``responses=`` accepts a raw schema dict
|
|
693
|
+
# (which is why the sibling GIT_WEBHOOK_RESULT_SCHEMA below is written that way),
|
|
694
|
+
# but ``request=`` does not: handed a bare dict it reads the dict's own top-level
|
|
695
|
+
# keys as media types, so ``type``/``additionalProperties``/``description`` became
|
|
696
|
+
# three bogus content entries and the real schema was replaced by drf-spectacular's
|
|
697
|
+
# "Unspecified request body" placeholder. It fails silently — the operation still
|
|
698
|
+
# has a ``requestBody``, so it looks declared from every angle except reading the
|
|
699
|
+
# generated document.
|
|
700
|
+
request={"application/json": GIT_WEBHOOK_PAYLOAD_SCHEMA},
|
|
701
|
+
responses={
|
|
702
|
+
200: GIT_WEBHOOK_RESULT_SCHEMA,
|
|
703
|
+
400: OpenApiResponse(description="Malformed webhook payload (signature verified)."),
|
|
704
|
+
404: OpenApiResponse(
|
|
705
|
+
description=(
|
|
706
|
+
"The uniform refusal for every pre-verification failure — no automation, "
|
|
707
|
+
"an archived project, disabled, no secret set, unrecognized provider, or "
|
|
708
|
+
"an invalid signature. "
|
|
709
|
+
"Deliberately indistinguishable so the endpoint cannot be used to discover "
|
|
710
|
+
"which projects have automation configured."
|
|
711
|
+
)
|
|
712
|
+
),
|
|
713
|
+
413: OpenApiResponse(description="Payload above the 1 MB webhook body cap."),
|
|
714
|
+
},
|
|
715
|
+
)
|
|
716
|
+
class GitWebhookIngestView(IdempotencyMixin, APIView):
|
|
717
|
+
"""Inbound Git-event receiver — ``POST .../{project_pk}/git-webhook/`` (#329, ADR-0158).
|
|
718
|
+
|
|
719
|
+
The OSS Git-event board-card auto-move receiver. **The signature is the gate** —
|
|
720
|
+
this endpoint is unauthenticated-by-session (``AllowAny``); a GitHub
|
|
721
|
+
``X-Hub-Signature-256`` HMAC or a GitLab ``X-Gitlab-Token`` over the project's
|
|
722
|
+
secret is what authorizes the call.
|
|
723
|
+
|
|
724
|
+
**Every pre-verification refusal is the same bare 404** — no automation row, a
|
|
725
|
+
disabled toggle, no secret set, an unrecognized provider, an undecryptable
|
|
726
|
+
secret, and a bad or missing signature are byte-for-byte indistinguishable to the
|
|
727
|
+
caller. This is stricter than the original design, which used 404 for
|
|
728
|
+
"not configured" and 401 for "bad signature" and so published exactly the fact
|
|
729
|
+
the 404 existed to hide: with a project UUID (which every project Viewer has,
|
|
730
|
+
straight out of the SPA URL) a 401 meant *automation is enabled and a secret is
|
|
731
|
+
set*, admin-only state that ``GitAutomationConfigView`` gates at Owner/Admin. A
|
|
732
|
+
non-ASCII byte in the signature header made it worse still by raising an
|
|
733
|
+
unhandled ``TypeError``, so a 500 read as the same disclosure (#2881).
|
|
734
|
+
|
|
735
|
+
The refusal *reason* is not thrown away — it goes to the structured receiver log
|
|
736
|
+
and to ``BoardAutomation.last_delivery_*``, which the admin-only config GET
|
|
737
|
+
returns and the settings card renders. Both require the reader to already be an
|
|
738
|
+
Owner/Admin of that project, which is the difference that matters.
|
|
739
|
+
|
|
740
|
+
Two refusals stay distinct on purpose, because neither depends on project state
|
|
741
|
+
and so neither can leak it: **413** for a body over
|
|
742
|
+
:data:`_WEBHOOK_MAX_BODY_BYTES` (checked before the body is buffered at all) and
|
|
743
|
+
**429** from the throttles. Post-verification responses are informative too — the
|
|
744
|
+
caller has already proven it holds the secret, so there is nothing left to hide.
|
|
745
|
+
|
|
746
|
+
OSS boundary (ADR-0097 carve-out, mirroring ADR-0148): a single project-scoped,
|
|
747
|
+
off-by-default, one-way Git→card receiver. No org connector, no OAuth, no
|
|
748
|
+
bidirectional sync, no conflict resolution, no reconciliation loop. The general
|
|
749
|
+
multi-provider bidirectional Integration Hub remains Enterprise.
|
|
750
|
+
"""
|
|
751
|
+
|
|
752
|
+
from .throttles import GitWebhookIpThrottle, GitWebhookThrottle
|
|
753
|
+
|
|
754
|
+
# Exempt from the HTTP idempotency model: an inbound webhook carries no JWT user
|
|
755
|
+
# to key the Idempotency-Key store on. Replay safety comes from two purpose-built
|
|
756
|
+
# layers instead — the Redis SET NX EX delivery claim and the forward-only status
|
|
757
|
+
# guard (a redelivered move is a no-op even if Redis is unavailable).
|
|
758
|
+
idempotency_exempt = True
|
|
759
|
+
authentication_classes: list[type] = []
|
|
760
|
+
permission_classes = [AllowAny]
|
|
761
|
+
|
|
762
|
+
# There is no principal on this route to hold a membership, so the role x route
|
|
763
|
+
# matrix (#3441) cannot judge it the way it judges every other project-scoped URL.
|
|
764
|
+
# The constant-time HMAC comparison over the raw body against the project's own
|
|
765
|
+
# stored secret is what stands in for membership: possession of that secret is the
|
|
766
|
+
# authorization, and it is scoped to exactly the one project the URL names.
|
|
767
|
+
role_gate_exempt = (
|
|
768
|
+
"Unauthenticated by design: the per-project HMAC signature over the raw body "
|
|
769
|
+
"is the gate, and it authorizes exactly the project the URL names. No "
|
|
770
|
+
"principal exists on this request to hold a membership (ADR-0158, #2881)."
|
|
771
|
+
)
|
|
772
|
+
# STACKED, and both are needed. The per-project bucket keys on a ``project_pk``
|
|
773
|
+
# the caller picks out of the URL, so rotating it defeated the limit entirely;
|
|
774
|
+
# the per-IP bucket closes that. Declaring ``throttle_classes`` here also
|
|
775
|
+
# replaces ``DEFAULT_THROTTLE_CLASSES``, which is why the global anon throttle
|
|
776
|
+
# never applied and the per-IP class has to be named explicitly (#2881).
|
|
777
|
+
throttle_classes = [GitWebhookThrottle, GitWebhookIpThrottle]
|
|
778
|
+
|
|
779
|
+
def _authorize(
|
|
780
|
+
self, request: Request, project_pk: str, raw_body: bytes
|
|
781
|
+
) -> tuple[BoardAutomation, str, str]:
|
|
782
|
+
"""Resolve the automation row, provider, and secret, or raise ``_WebhookRefusal``.
|
|
783
|
+
|
|
784
|
+
Every failure funnels through one exception type carrying its own outcome
|
|
785
|
+
token, which is what makes the indistinguishability property hold by
|
|
786
|
+
construction: there is a single ``Response`` literal for all six refusals
|
|
787
|
+
instead of six that have to stay in sync with each other.
|
|
788
|
+
"""
|
|
789
|
+
# Fetch the row regardless of ``enabled`` so a disabled toggle and a missing
|
|
790
|
+
# secret can be *recorded* (they are the two most common "I followed every
|
|
791
|
+
# step and nothing moves" causes) even though both still answer 404. Scope
|
|
792
|
+
# out soft-deleted projects.
|
|
793
|
+
automation = (
|
|
794
|
+
BoardAutomation.objects.filter(
|
|
795
|
+
project_id=project_pk,
|
|
796
|
+
project__is_deleted=False,
|
|
797
|
+
)
|
|
798
|
+
# The archived flag rides the join the ``project__is_deleted`` filter
|
|
799
|
+
# already forces, so this costs one extra column and no extra query — it
|
|
800
|
+
# deliberately does NOT ``select_related("project")``, for the 51-unused-
|
|
801
|
+
# columns reason recorded just below (#3414).
|
|
802
|
+
.annotate(project_is_archived=F("project__is_archived"))
|
|
803
|
+
# ``configured_by`` — not ``project``. ``project`` is never dereferenced
|
|
804
|
+
# on this path (the filter needs the join, not the columns) and pulling it
|
|
805
|
+
# cost 51 unused columns on every anonymous POST, including the ones that
|
|
806
|
+
# go on to 404. ``configured_by`` IS dereferenced, on every successful
|
|
807
|
+
# move, and was the lazy one.
|
|
808
|
+
.select_related("configured_by")
|
|
809
|
+
.first()
|
|
810
|
+
)
|
|
811
|
+
provider = git_webhook_auth.detect_provider(request.headers)
|
|
812
|
+
|
|
813
|
+
if automation is None:
|
|
814
|
+
# Nothing to record against — no project, or automation never touched.
|
|
815
|
+
raise _WebhookRefusal("no_automation", provider=provider)
|
|
816
|
+
if automation.project_is_archived:
|
|
817
|
+
# Archived projects are hard read-only (#530), and moving a board card is a
|
|
818
|
+
# write. The receiver cannot be gated with ``IsProjectNotArchived``: this
|
|
819
|
+
# endpoint is ``AllowAny`` and a permission-class 403 would publish archived
|
|
820
|
+
# state — admin-only state — to anyone holding the project UUID, re-opening
|
|
821
|
+
# the disclosure #2881 closed. Refusing through the same funnel keeps the
|
|
822
|
+
# response byte-identical to every other refusal while still recording the
|
|
823
|
+
# reason where an Owner/Admin can read it.
|
|
824
|
+
raise _WebhookRefusal(GIT_DELIVERY_PROJECT_ARCHIVED, automation.pk, provider)
|
|
825
|
+
if not automation.enabled:
|
|
826
|
+
raise _WebhookRefusal(GIT_DELIVERY_DISABLED, automation.pk, provider)
|
|
827
|
+
if not automation.has_secret:
|
|
828
|
+
raise _WebhookRefusal(GIT_DELIVERY_NO_SECRET, automation.pk, provider)
|
|
829
|
+
if provider is None:
|
|
830
|
+
raise _WebhookRefusal(GIT_DELIVERY_UNKNOWN_PROVIDER, automation.pk, provider)
|
|
831
|
+
|
|
832
|
+
try:
|
|
833
|
+
secret = decrypt_secret(automation.secret_ciphertext)
|
|
834
|
+
except (CredentialEncryptionError, ImproperlyConfigured, ValueError) as exc:
|
|
835
|
+
# Every way the stored secret can fail to come back, not just one. The
|
|
836
|
+
# obvious arm is CredentialEncryptionError (key rotated without
|
|
837
|
+
# re-encrypt), but ``decrypt_secret`` also raises ImproperlyConfigured
|
|
838
|
+
# when INTEGRATION_ENCRYPTION_KEY is unset and ValueError when it is
|
|
839
|
+
# malformed — the *same* operator scenario, and both would have escaped
|
|
840
|
+
# this funnel as an unhandled 500. That matters beyond the crash: a 500 is
|
|
841
|
+
# only reachable on a project that has automation with a secret set, which
|
|
842
|
+
# is precisely the disclosure #2881 exists to remove. Leaving two
|
|
843
|
+
# exception types out would have re-opened it one type over.
|
|
844
|
+
raise _WebhookRefusal(GIT_DELIVERY_SECRET_UNREADABLE, automation.pk, provider) from exc
|
|
845
|
+
|
|
846
|
+
try:
|
|
847
|
+
git_webhook_auth.verify_signature(provider, secret, raw_body, request.headers)
|
|
848
|
+
except git_webhook_auth.WebhookSignatureError as exc:
|
|
849
|
+
raise _WebhookRefusal(GIT_DELIVERY_BAD_SIGNATURE, automation.pk, provider) from exc
|
|
850
|
+
|
|
851
|
+
return automation, provider, secret
|
|
852
|
+
|
|
853
|
+
def post(self, request: Request, project_pk: str) -> Response:
|
|
854
|
+
from .throttles import claim_webhook_delivery
|
|
855
|
+
|
|
856
|
+
# Bound the body BEFORE anything reads or allocates it.
|
|
857
|
+
if not _webhook_body_within_cap(request):
|
|
858
|
+
logger.warning("git webhook refused: project=%s reason=payload_too_large", project_pk)
|
|
859
|
+
return Response(
|
|
860
|
+
{"detail": "Webhook payload too large."},
|
|
861
|
+
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Read the raw body BEFORE touching request.data — the HMAC must be checked
|
|
865
|
+
# against the exact bytes the provider signed, and accessing request.data
|
|
866
|
+
# consumes the stream.
|
|
867
|
+
raw_body = request.body
|
|
868
|
+
|
|
869
|
+
try:
|
|
870
|
+
automation, provider, _secret = self._authorize(request, project_pk, raw_body)
|
|
871
|
+
except _WebhookRefusal as refusal:
|
|
872
|
+
if refusal.automation_id is not None:
|
|
873
|
+
_record_git_refusal(refusal.automation_id, refusal.provider, refusal.outcome)
|
|
874
|
+
# Carry the client identity and the provider's own delivery id. Without
|
|
875
|
+
# them neither surface answers "who sent this?", so an admin staring at
|
|
876
|
+
# "Signature rejected" cannot tell a misconfigured provider from someone
|
|
877
|
+
# poking a public endpoint — and the two call for opposite actions.
|
|
878
|
+
logger.warning(
|
|
879
|
+
"git webhook refused: project=%s provider=%s reason=%s client=%s delivery=%s",
|
|
880
|
+
project_pk,
|
|
881
|
+
refusal.provider or "unknown",
|
|
882
|
+
refusal.outcome,
|
|
883
|
+
_client_ident(request),
|
|
884
|
+
_provider_delivery_id(request) or "-",
|
|
885
|
+
)
|
|
886
|
+
# Returned, NOT ``raise Http404``. DRF's exception handler calls
|
|
887
|
+
# ``set_rollback()`` for every APIException, and ``ATOMIC_REQUESTS`` is on
|
|
888
|
+
# — so raising here would roll the delivery record back out of existence
|
|
889
|
+
# and the whole diagnostic surface would silently record nothing. The body
|
|
890
|
+
# is byte-identical to what DRF's ``NotFound`` renders.
|
|
891
|
+
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
892
|
+
|
|
893
|
+
# --- Past the gate: the caller holds the secret, so be informative. -------
|
|
894
|
+
payload = request.data
|
|
895
|
+
if not isinstance(payload, dict):
|
|
896
|
+
_record_git_delivery(automation.pk, provider, GIT_DELIVERY_MALFORMED)
|
|
897
|
+
logger.warning(
|
|
898
|
+
"git webhook malformed payload: project=%s provider=%s", project_pk, provider
|
|
899
|
+
)
|
|
900
|
+
return Response(
|
|
901
|
+
{"detail": "Malformed webhook payload."},
|
|
902
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
envelope = git_webhook_auth.parse_envelope(provider, request.headers, payload)
|
|
906
|
+
if envelope.event is None:
|
|
907
|
+
# A configured-but-irrelevant event (push, comment, close) or a draft/WIP
|
|
908
|
+
# open, which deliberately does not promote the card. Not an error —
|
|
909
|
+
# return 2xx so the provider does not retry. ``reason`` distinguishes the
|
|
910
|
+
# two, because "I opened a draft and nothing happened" is a question
|
|
911
|
+
# someone will ask and the answer should not require reading Python.
|
|
912
|
+
outcome = envelope.ignored_reason or GIT_DELIVERY_IGNORED
|
|
913
|
+
_record_git_delivery(automation.pk, provider, outcome)
|
|
914
|
+
body: dict[str, Any] = {
|
|
915
|
+
"matched": False,
|
|
916
|
+
"moved": False,
|
|
917
|
+
"ignored": envelope.raw_event_name,
|
|
918
|
+
}
|
|
919
|
+
if envelope.ignored_reason:
|
|
920
|
+
body["reason"] = envelope.ignored_reason
|
|
921
|
+
return Response(body, status=status.HTTP_200_OK)
|
|
922
|
+
|
|
923
|
+
# Idempotency: a provider redelivery of the same event is a no-op. The
|
|
924
|
+
# forward-only guard in the service is a second, Redis-independent layer.
|
|
925
|
+
if not claim_webhook_delivery(project_pk, envelope.delivery_key):
|
|
926
|
+
_record_git_delivery(automation.pk, provider, GIT_DELIVERY_DUPLICATE)
|
|
927
|
+
return Response(
|
|
928
|
+
{"matched": False, "moved": False, "reason": "duplicate"},
|
|
929
|
+
status=status.HTTP_200_OK,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
result = apply_git_event_to_card(automation, provider, envelope.event, envelope.pr_url)
|
|
933
|
+
_record_git_delivery(automation.pk, provider, result.reason)
|
|
934
|
+
if not result.matched:
|
|
935
|
+
# ``no_url`` / ``no_link``: the delivery verified but matched nothing.
|
|
936
|
+
# This is THE silent failure of the feature — the provider shows a green
|
|
937
|
+
# check and no card moves, because matching needs a TaskLink on the task
|
|
938
|
+
# pointing at the exact PR/MR URL and nobody created one.
|
|
939
|
+
logger.warning(
|
|
940
|
+
"git webhook matched no task: project=%s provider=%s event=%s reason=%s",
|
|
941
|
+
project_pk,
|
|
942
|
+
provider,
|
|
943
|
+
envelope.event,
|
|
944
|
+
result.reason,
|
|
945
|
+
)
|
|
946
|
+
return Response(
|
|
947
|
+
{
|
|
948
|
+
"matched": result.matched,
|
|
949
|
+
"moved": result.moved,
|
|
950
|
+
"task": result.task_id,
|
|
951
|
+
"from": result.from_status,
|
|
952
|
+
"to": result.to_status,
|
|
953
|
+
"reason": result.reason,
|
|
954
|
+
},
|
|
955
|
+
status=status.HTTP_200_OK,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@extend_schema(tags=["integrations"], responses={200: GitAutomationConfigSerializer})
|
|
960
|
+
class GitAutomationConfigView(IdempotencyMixin, APIView):
|
|
961
|
+
"""``GET|PUT /api/v1/integrations/projects/{project_pk}/git-automation/`` — config (#329).
|
|
962
|
+
|
|
963
|
+
Project-admin only (Owner/Admin). Reads/sets the off-by-default toggle and
|
|
964
|
+
surfaces the webhook URL and whether a secret is set. The secret itself is
|
|
965
|
+
minted/rotated through :class:`GitAutomationRotateSecretView` and returned once.
|
|
966
|
+
"""
|
|
967
|
+
|
|
968
|
+
# The PUT sets ``enabled`` to an explicit value on the per-project singleton, so a
|
|
969
|
+
# replay converges to the same state (naturally idempotent — no replayable resource).
|
|
970
|
+
idempotency_exempt = True
|
|
971
|
+
# IsProjectNotArchived gates the PUT only — the class returns True for GET/HEAD, so
|
|
972
|
+
# an admin can still read an archived project's automation config. It fires in
|
|
973
|
+
# `has_permission` because the route names `project_pk` (#3414).
|
|
974
|
+
permission_classes = [IsAuthenticated, IsProjectAdmin, IsProjectNotArchived]
|
|
975
|
+
# Reveals whether a webhook secret is set / lets an admin flip the toggle; scope it
|
|
976
|
+
# under the shared credential bucket (#1551) so config-probing is rate-bounded.
|
|
977
|
+
throttle_classes = [ScopedRateThrottle]
|
|
978
|
+
throttle_scope = "credential_rotate"
|
|
979
|
+
|
|
980
|
+
def _config_payload(self, request: Request, automation: BoardAutomation) -> dict[str, Any]:
|
|
981
|
+
return {
|
|
982
|
+
"enabled": automation.enabled,
|
|
983
|
+
"secret_set": automation.has_secret,
|
|
984
|
+
"webhook_url": _git_webhook_url(request, automation.project_id),
|
|
985
|
+
"configured_by": automation.configured_by_id,
|
|
986
|
+
"secret_set_at": automation.secret_set_at,
|
|
987
|
+
"updated_at": automation.updated_at,
|
|
988
|
+
"last_delivery_at": automation.last_delivery_at,
|
|
989
|
+
"last_delivery_outcome": automation.last_delivery_outcome,
|
|
990
|
+
"last_delivery_provider": automation.last_delivery_provider,
|
|
991
|
+
"last_refusal_at": automation.last_refusal_at,
|
|
992
|
+
"last_refusal_outcome": automation.last_refusal_outcome,
|
|
993
|
+
"last_refusal_provider": automation.last_refusal_provider,
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
def _get_or_init(self, project_pk: str) -> BoardAutomation:
|
|
997
|
+
project = get_object_or_404(Project, pk=project_pk, is_deleted=False)
|
|
998
|
+
automation, _ = BoardAutomation.objects.get_or_create(project=project)
|
|
999
|
+
return automation
|
|
1000
|
+
|
|
1001
|
+
def get(self, request: Request, project_pk: str) -> Response:
|
|
1002
|
+
automation = self._get_or_init(project_pk)
|
|
1003
|
+
data = GitAutomationConfigSerializer(self._config_payload(request, automation)).data
|
|
1004
|
+
return Response(data)
|
|
1005
|
+
|
|
1006
|
+
# The class-level ``@extend_schema`` covers GET and PUT alike and names no
|
|
1007
|
+
# request, so PUT published no ``requestBody`` while requiring ``enabled``
|
|
1008
|
+
# (#3364). Declared on the method, not the class, because GET must keep taking
|
|
1009
|
+
# no body. This is a plain ``APIView``: there is no ``serializer_class`` for
|
|
1010
|
+
# drf-spectacular to infer a writable shape from, so nothing supplies this but
|
|
1011
|
+
# the annotation.
|
|
1012
|
+
@extend_schema(
|
|
1013
|
+
request=GitAutomationUpdateSerializer,
|
|
1014
|
+
responses={
|
|
1015
|
+
200: GitAutomationConfigSerializer,
|
|
1016
|
+
403: OpenApiResponse(
|
|
1017
|
+
description=(
|
|
1018
|
+
"Caller is not a project Admin, or the project is archived (#3414). "
|
|
1019
|
+
"Declared on the method, not the class: the class-level annotation "
|
|
1020
|
+
"covers GET too, and GET stays available while archived."
|
|
1021
|
+
)
|
|
1022
|
+
),
|
|
1023
|
+
},
|
|
1024
|
+
)
|
|
1025
|
+
def put(self, request: Request, project_pk: str) -> Response:
|
|
1026
|
+
serializer = GitAutomationUpdateSerializer(data=request.data)
|
|
1027
|
+
serializer.is_valid(raise_exception=True)
|
|
1028
|
+
automation = self._get_or_init(project_pk)
|
|
1029
|
+
automation.enabled = serializer.validated_data["enabled"]
|
|
1030
|
+
# request.user is a real User here (IsAuthenticated gates this view); the
|
|
1031
|
+
# cast narrows away the AnonymousUser arm the field FK does not accept.
|
|
1032
|
+
automation.configured_by = cast("Any", request.user)
|
|
1033
|
+
automation.save(update_fields=["enabled", "configured_by", "updated_at"])
|
|
1034
|
+
data = GitAutomationConfigSerializer(self._config_payload(request, automation)).data
|
|
1035
|
+
return Response(data)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
@extend_schema(
|
|
1039
|
+
tags=["integrations"],
|
|
1040
|
+
request=None,
|
|
1041
|
+
responses={
|
|
1042
|
+
201: GitAutomationSecretSerializer,
|
|
1043
|
+
403: OpenApiResponse(
|
|
1044
|
+
description=(
|
|
1045
|
+
"Caller is not a project Admin, or the project is archived (#3414) — "
|
|
1046
|
+
"minting a credential is a write. Nothing is stranded by the refusal: "
|
|
1047
|
+
"the receiver refuses an archived project's deliveries outright."
|
|
1048
|
+
)
|
|
1049
|
+
),
|
|
1050
|
+
},
|
|
1051
|
+
)
|
|
1052
|
+
class GitAutomationRotateSecretView(IdempotencyMixin, APIView):
|
|
1053
|
+
"""``POST .../git-automation/rotate-secret/`` — mint a new webhook secret (#329).
|
|
1054
|
+
|
|
1055
|
+
Project-admin only. Generates a fresh URL-safe secret, stores it Fernet-encrypted,
|
|
1056
|
+
and returns the plaintext **once** in the response — it is never retrievable again
|
|
1057
|
+
(the GET endpoint only reports whether a secret is set).
|
|
1058
|
+
"""
|
|
1059
|
+
|
|
1060
|
+
# Exempt: the secret lives in a single column on the per-project singleton, so a
|
|
1061
|
+
# double rotation is last-write-wins — only one secret is ever active and a replay
|
|
1062
|
+
# cannot mint a parallel credential. This is a deliberate admin action, not a
|
|
1063
|
+
# retry-prone client mutation.
|
|
1064
|
+
idempotency_exempt = True
|
|
1065
|
+
# Minting a credential on an archived project is a write (#3414). Nothing is
|
|
1066
|
+
# stranded by refusing it: the receiver now refuses an archived project's
|
|
1067
|
+
# deliveries outright, so there is no live webhook left to rotate away from.
|
|
1068
|
+
# IsNotTokenAuthenticated (#2939): this mints a durable, plaintext-once
|
|
1069
|
+
# credential a leaked PAT should not be able to extend itself with — the
|
|
1070
|
+
# same rule #2878 applied to /me/api-tokens/.
|
|
1071
|
+
permission_classes = [
|
|
1072
|
+
IsAuthenticated,
|
|
1073
|
+
IsProjectAdmin,
|
|
1074
|
+
IsProjectNotArchived,
|
|
1075
|
+
IsNotTokenAuthenticated,
|
|
1076
|
+
]
|
|
1077
|
+
# Mints and returns a fresh plaintext webhook secret; scope it under the shared
|
|
1078
|
+
# credential bucket (#1551) so secret rotation cannot be hammered.
|
|
1079
|
+
throttle_classes = [ScopedRateThrottle]
|
|
1080
|
+
throttle_scope = "credential_rotate"
|
|
1081
|
+
|
|
1082
|
+
def post(self, request: Request, project_pk: str) -> Response:
|
|
1083
|
+
project = get_object_or_404(Project, pk=project_pk, is_deleted=False)
|
|
1084
|
+
# 256 bits of entropy, URL-safe so it pastes cleanly into a provider's
|
|
1085
|
+
# webhook secret field.
|
|
1086
|
+
plaintext = secrets.token_urlsafe(32)
|
|
1087
|
+
with transaction.atomic():
|
|
1088
|
+
automation, _ = BoardAutomation.objects.get_or_create(project=project)
|
|
1089
|
+
automation.set_secret(plaintext)
|
|
1090
|
+
automation.configured_by = cast("Any", request.user)
|
|
1091
|
+
automation.save(
|
|
1092
|
+
update_fields=[
|
|
1093
|
+
"secret_ciphertext",
|
|
1094
|
+
"secret_set_at",
|
|
1095
|
+
"configured_by",
|
|
1096
|
+
"updated_at",
|
|
1097
|
+
]
|
|
1098
|
+
)
|
|
1099
|
+
return Response(
|
|
1100
|
+
{
|
|
1101
|
+
"secret": plaintext,
|
|
1102
|
+
"webhook_url": _git_webhook_url(request, project.pk),
|
|
1103
|
+
"secret_set_at": automation.secret_set_at,
|
|
1104
|
+
},
|
|
1105
|
+
status=status.HTTP_201_CREATED,
|
|
1106
|
+
)
|