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,1502 @@
|
|
|
1
|
+
"""Workspace settings API (#517 General, #518 Members/Invites, #519 Groups).
|
|
2
|
+
|
|
3
|
+
See ADR-0087. RBAC: any active member reads; ADMIN+ writes (``IsWorkspaceAdmin``).
|
|
4
|
+
The invite-accept endpoint is the one public surface (``AllowAny``) — it
|
|
5
|
+
authenticates with a one-time token, not a session.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from django.contrib.auth import get_user_model
|
|
14
|
+
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
15
|
+
from django.db import transaction
|
|
16
|
+
from django.db.models import Count, Q, QuerySet
|
|
17
|
+
from django.http import FileResponse, Http404
|
|
18
|
+
from django.shortcuts import get_object_or_404
|
|
19
|
+
from django.utils import timezone
|
|
20
|
+
from django.utils.dateparse import parse_date, parse_datetime
|
|
21
|
+
from drf_spectacular.types import OpenApiTypes
|
|
22
|
+
from drf_spectacular.utils import (
|
|
23
|
+
OpenApiParameter,
|
|
24
|
+
OpenApiResponse,
|
|
25
|
+
extend_schema,
|
|
26
|
+
inline_serializer,
|
|
27
|
+
)
|
|
28
|
+
from rest_framework import serializers, status
|
|
29
|
+
from rest_framework.exceptions import PermissionDenied, ValidationError
|
|
30
|
+
from rest_framework.pagination import CursorPagination, PageNumberPagination
|
|
31
|
+
from rest_framework.parsers import FormParser, MultiPartParser
|
|
32
|
+
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
33
|
+
from rest_framework.request import Request
|
|
34
|
+
from rest_framework.response import Response
|
|
35
|
+
from rest_framework.throttling import AnonRateThrottle, ScopedRateThrottle
|
|
36
|
+
from rest_framework.views import APIView
|
|
37
|
+
|
|
38
|
+
from trueppm_api.apps.access.models import Role
|
|
39
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
40
|
+
from trueppm_api.apps.workspace import services
|
|
41
|
+
from trueppm_api.apps.workspace.models import (
|
|
42
|
+
AuditEvent,
|
|
43
|
+
AuditEventType,
|
|
44
|
+
ExportJobStatus,
|
|
45
|
+
Group,
|
|
46
|
+
GroupMembership,
|
|
47
|
+
GroupProject,
|
|
48
|
+
InviteStatus,
|
|
49
|
+
MemberStatus,
|
|
50
|
+
Workspace,
|
|
51
|
+
WorkspaceExportJob,
|
|
52
|
+
WorkspaceInvite,
|
|
53
|
+
WorkspaceMembership,
|
|
54
|
+
WorkspaceRole,
|
|
55
|
+
)
|
|
56
|
+
from trueppm_api.apps.workspace.permissions import (
|
|
57
|
+
IsWorkspaceAdmin,
|
|
58
|
+
IsWorkspaceAdminStrict,
|
|
59
|
+
IsWorkspaceAuditViewer,
|
|
60
|
+
IsWorkspaceOwner,
|
|
61
|
+
_workspace_membership_role,
|
|
62
|
+
request_is_workspace_owner,
|
|
63
|
+
)
|
|
64
|
+
from trueppm_api.apps.workspace.serializers import (
|
|
65
|
+
AuditEventSerializer,
|
|
66
|
+
GroupMemberAddSerializer,
|
|
67
|
+
GroupProjectWriteSerializer,
|
|
68
|
+
GroupSerializer,
|
|
69
|
+
GroupWriteSerializer,
|
|
70
|
+
InviteAcceptSerializer,
|
|
71
|
+
TransferOwnershipSerializer,
|
|
72
|
+
WorkspaceExportJobSerializer,
|
|
73
|
+
WorkspaceInviteCreateSerializer,
|
|
74
|
+
WorkspaceInviteSerializer,
|
|
75
|
+
WorkspaceMemberSerializer,
|
|
76
|
+
WorkspaceMemberUpdateSerializer,
|
|
77
|
+
WorkspaceSettingsSerializer,
|
|
78
|
+
color_for,
|
|
79
|
+
display_name_for,
|
|
80
|
+
initials_for,
|
|
81
|
+
)
|
|
82
|
+
from trueppm_api.core.export_downloads import stream_export_job_or_error
|
|
83
|
+
from trueppm_api.core.openapi import state_refusal_400
|
|
84
|
+
|
|
85
|
+
# Typed-confirmation header for the destructive workspace delete (ADR-0174). Its
|
|
86
|
+
# value must equal the workspace name exactly — the web danger page sends it.
|
|
87
|
+
CONFIRM_WORKSPACE_HEADER = "X-Confirm-Workspace"
|
|
88
|
+
|
|
89
|
+
User = get_user_model()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InviteAcceptThrottle(AnonRateThrottle):
|
|
93
|
+
"""Defense-in-depth rate limit on the public invite-accept endpoint.
|
|
94
|
+
|
|
95
|
+
The token is 256-bit and SHA-256-hashed at rest, so brute force is already
|
|
96
|
+
infeasible; this caps it regardless and bounds account-provisioning abuse.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
scope = "invite_accept"
|
|
100
|
+
rate = "20/min"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Row builders (shared by list + detail responses)
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _initials(user: Any) -> str:
|
|
109
|
+
return initials_for(user.first_name, user.last_name, user.username)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_member_rows(users: list[Any]) -> list[dict[str, Any]]:
|
|
113
|
+
user_ids = [u.id for u in users]
|
|
114
|
+
memberships = {
|
|
115
|
+
m.user_id: m
|
|
116
|
+
for m in WorkspaceMembership.objects.filter(user_id__in=user_ids, is_deleted=False)
|
|
117
|
+
}
|
|
118
|
+
group_map: dict[Any, list[str]] = defaultdict(list)
|
|
119
|
+
for gm in GroupMembership.objects.filter(
|
|
120
|
+
user_id__in=user_ids, is_deleted=False, group__is_deleted=False
|
|
121
|
+
).select_related("group"):
|
|
122
|
+
group_map[gm.user_id].append(gm.group.name)
|
|
123
|
+
|
|
124
|
+
rows: list[dict[str, Any]] = []
|
|
125
|
+
for u in users:
|
|
126
|
+
membership = memberships.get(u.id)
|
|
127
|
+
if membership is not None:
|
|
128
|
+
role_val = membership.role
|
|
129
|
+
member_status = membership.status
|
|
130
|
+
avail_percent = membership.availability_percent
|
|
131
|
+
avail_from = membership.availability_effective_from
|
|
132
|
+
avail_to = membership.availability_effective_to
|
|
133
|
+
avail_notes = membership.availability_notes
|
|
134
|
+
else:
|
|
135
|
+
role_val = WorkspaceRole.OWNER if u.is_superuser else WorkspaceRole.MEMBER
|
|
136
|
+
member_status = MemberStatus.ACTIVE
|
|
137
|
+
# A user without a membership row is fully available by default —
|
|
138
|
+
# mirrors the lazy-creation default the PATCH handler applies (#542).
|
|
139
|
+
avail_percent = 100
|
|
140
|
+
avail_from = None
|
|
141
|
+
avail_to = None
|
|
142
|
+
avail_notes = ""
|
|
143
|
+
rows.append(
|
|
144
|
+
{
|
|
145
|
+
"id": str(u.id),
|
|
146
|
+
"name": display_name_for(u.first_name, u.last_name, u.username),
|
|
147
|
+
"initials": _initials(u),
|
|
148
|
+
"color": color_for(u.id),
|
|
149
|
+
"email": u.email,
|
|
150
|
+
"role": WorkspaceRole(role_val).label,
|
|
151
|
+
"role_value": role_val,
|
|
152
|
+
"groups": sorted(group_map.get(u.id, [])),
|
|
153
|
+
"project_count": getattr(u, "project_count", 0),
|
|
154
|
+
"last_active": u.last_login.isoformat() if u.last_login else None,
|
|
155
|
+
"status": member_status,
|
|
156
|
+
"availability_percent": avail_percent,
|
|
157
|
+
"availability_effective_from": avail_from.isoformat() if avail_from else None,
|
|
158
|
+
"availability_effective_to": avail_to.isoformat() if avail_to else None,
|
|
159
|
+
"availability_notes": avail_notes,
|
|
160
|
+
"sso": False,
|
|
161
|
+
"two_fa": False,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
return rows
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _group_dict(
|
|
168
|
+
group: Group, members: list[Any], project_links: list[dict[str, Any]]
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
lead = group.lead
|
|
171
|
+
return {
|
|
172
|
+
"id": str(group.pk),
|
|
173
|
+
"name": group.name,
|
|
174
|
+
"description": group.description,
|
|
175
|
+
"lead": _initials(lead) if lead is not None else None,
|
|
176
|
+
"lead_user_id": str(lead.pk) if lead is not None else None,
|
|
177
|
+
"member_count": len(members),
|
|
178
|
+
"members": [
|
|
179
|
+
{
|
|
180
|
+
"id": str(gm.user_id),
|
|
181
|
+
"name": display_name_for(gm.user.first_name, gm.user.last_name, gm.user.username),
|
|
182
|
+
"initials": _initials(gm.user),
|
|
183
|
+
"color": color_for(gm.user_id),
|
|
184
|
+
}
|
|
185
|
+
for gm in members
|
|
186
|
+
],
|
|
187
|
+
# #2253: project links carry id + conferred role (not just the name) so the
|
|
188
|
+
# management UI can render the granted role and revoke by project UUID.
|
|
189
|
+
"projects": project_links,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _project_link(project_id: Any, name: str, role: int) -> dict[str, Any]:
|
|
194
|
+
"""One group→project access grant, with the conferred role and its label (#2253)."""
|
|
195
|
+
return {"id": str(project_id), "name": name, "role": role, "role_label": Role(role).label}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _build_group_dict(group: Group) -> dict[str, Any]:
|
|
199
|
+
"""Single-group dict (used for create/patch/detail responses — 2 queries)."""
|
|
200
|
+
members = list(
|
|
201
|
+
GroupMembership.objects.filter(group=group, is_deleted=False).select_related("user")
|
|
202
|
+
)
|
|
203
|
+
project_links = [
|
|
204
|
+
_project_link(pid, name, role)
|
|
205
|
+
for pid, name, role in GroupProject.objects.filter(
|
|
206
|
+
group=group, project__is_deleted=False
|
|
207
|
+
).values_list("project_id", "project__name", "role")
|
|
208
|
+
]
|
|
209
|
+
return _group_dict(group, members, project_links)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_group_dicts(groups: list[Group]) -> list[dict[str, Any]]:
|
|
213
|
+
"""Bulk group dicts for the list endpoint — two queries total, no per-group N+1."""
|
|
214
|
+
group_ids = [g.pk for g in groups]
|
|
215
|
+
members_by_group: dict[Any, list[Any]] = defaultdict(list)
|
|
216
|
+
for gm in GroupMembership.objects.filter(
|
|
217
|
+
group_id__in=group_ids, is_deleted=False
|
|
218
|
+
).select_related("user"):
|
|
219
|
+
members_by_group[gm.group_id].append(gm)
|
|
220
|
+
projects_by_group: dict[Any, list[dict[str, Any]]] = defaultdict(list)
|
|
221
|
+
for group_id, project_id, project_name, role in GroupProject.objects.filter(
|
|
222
|
+
group_id__in=group_ids, project__is_deleted=False
|
|
223
|
+
).values_list("group_id", "project_id", "project__name", "role"):
|
|
224
|
+
projects_by_group[group_id].append(_project_link(project_id, project_name, role))
|
|
225
|
+
return [
|
|
226
|
+
_group_dict(g, members_by_group.get(g.pk, []), projects_by_group.get(g.pk, []))
|
|
227
|
+
for g in groups
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _invite_dict(invite: WorkspaceInvite) -> dict[str, Any]:
|
|
232
|
+
"""Shape one invite row for the admin list.
|
|
233
|
+
|
|
234
|
+
``accepted_at`` / ``accepted_by`` are the outcome half (#2911): both were
|
|
235
|
+
written by the accept path and read by nothing, so the endpoint could show that
|
|
236
|
+
an invite existed but never that it had been taken up, or by whom. ``accepted_by``
|
|
237
|
+
is initials rather than an email or a name, matching ``invited_by`` — this list
|
|
238
|
+
is already the workspace's PII-heaviest admin surface and there is no reason for
|
|
239
|
+
the outcome column to widen it.
|
|
240
|
+
"""
|
|
241
|
+
inviter = invite.invited_by
|
|
242
|
+
accepted_user = invite.accepted_user
|
|
243
|
+
return {
|
|
244
|
+
"id": str(invite.pk),
|
|
245
|
+
"email": invite.email,
|
|
246
|
+
"role": WorkspaceRole(invite.role).label,
|
|
247
|
+
"role_value": invite.role,
|
|
248
|
+
"status": invite.status,
|
|
249
|
+
"invited_by": _initials(inviter) if inviter is not None else None,
|
|
250
|
+
"created_at": invite.created_at,
|
|
251
|
+
"expires_at": invite.expires_at,
|
|
252
|
+
"accepted_at": invite.accepted_at,
|
|
253
|
+
"accepted_by": _initials(accepted_user) if accepted_user is not None else None,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# #517 — General settings
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class WorkspaceSettingsView(IdempotencyMixin, APIView):
|
|
263
|
+
"""GET/PATCH /api/v1/workspace/ — singleton workspace config (#517).
|
|
264
|
+
|
|
265
|
+
Read for any active member; PATCH requires workspace ADMIN+.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
269
|
+
|
|
270
|
+
@extend_schema(responses={200: WorkspaceSettingsSerializer})
|
|
271
|
+
def get(self, request: Request) -> Response:
|
|
272
|
+
workspace = Workspace.load()
|
|
273
|
+
return Response(WorkspaceSettingsSerializer(workspace).data)
|
|
274
|
+
|
|
275
|
+
@extend_schema(
|
|
276
|
+
request=WorkspaceSettingsSerializer, responses={200: WorkspaceSettingsSerializer}
|
|
277
|
+
)
|
|
278
|
+
def patch(self, request: Request) -> Response:
|
|
279
|
+
workspace = Workspace.load()
|
|
280
|
+
# The workspace calendar (or its override policy) is the root of the calendar
|
|
281
|
+
# inheritance chain (ADR-0441): changing either re-points every project that
|
|
282
|
+
# inherits up to the workspace, so capture the before-values to fan out a CPM
|
|
283
|
+
# recompute only when they actually change (a no-op PATCH must not recompute).
|
|
284
|
+
old_calendar_id = workspace.calendar_id
|
|
285
|
+
old_calendar_policy = workspace.calendar_override_policy
|
|
286
|
+
serializer = WorkspaceSettingsSerializer(workspace, data=request.data, partial=True)
|
|
287
|
+
serializer.is_valid(raise_exception=True)
|
|
288
|
+
serializer.save()
|
|
289
|
+
if (
|
|
290
|
+
workspace.calendar_id != old_calendar_id
|
|
291
|
+
or workspace.calendar_override_policy != old_calendar_policy
|
|
292
|
+
):
|
|
293
|
+
from trueppm_api.apps.projects.views import _recalc_projects_for_workspace_calendar
|
|
294
|
+
|
|
295
|
+
_recalc_projects_for_workspace_calendar(actor=request.user)
|
|
296
|
+
# Audit the change (ADR-0157). Record which settings keys were touched —
|
|
297
|
+
# not their values, which may be large or sensitive (e.g. branding blobs).
|
|
298
|
+
services.record_audit_event(
|
|
299
|
+
event_type=AuditEventType.SETTINGS_CHANGED,
|
|
300
|
+
actor=request.user,
|
|
301
|
+
target_type="workspace",
|
|
302
|
+
target_label="Workspace settings",
|
|
303
|
+
metadata={"fields": sorted(serializer.validated_data.keys())},
|
|
304
|
+
)
|
|
305
|
+
return Response(serializer.data)
|
|
306
|
+
|
|
307
|
+
@extend_schema(
|
|
308
|
+
request=None,
|
|
309
|
+
responses={
|
|
310
|
+
204: OpenApiResponse(description="Workspace purged and reset."),
|
|
311
|
+
400: state_refusal_400(
|
|
312
|
+
f"The {CONFIRM_WORKSPACE_HEADER} header is absent or does not match "
|
|
313
|
+
"the workspace name exactly. Verified against the typed-confirmation "
|
|
314
|
+
"guard in ``delete`` (#3319)."
|
|
315
|
+
),
|
|
316
|
+
},
|
|
317
|
+
description=(
|
|
318
|
+
"Permanently delete the workspace and all its data (factory reset). "
|
|
319
|
+
f"Owner only. Requires the {CONFIRM_WORKSPACE_HEADER} header set to the "
|
|
320
|
+
"exact workspace name."
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
def delete(self, request: Request) -> Response:
|
|
324
|
+
"""Hard-delete the workspace and all its data (ADR-0174, #641).
|
|
325
|
+
|
|
326
|
+
Owner-only (PATCH is ADMIN+, so the owner gate is enforced here rather than
|
|
327
|
+
via the class-level permission). A typed-confirmation header must match the
|
|
328
|
+
workspace name exactly, mirroring the inline typed-confirmation on the web
|
|
329
|
+
danger page. ``purge_workspace`` deletes every workspace-scoped row and the
|
|
330
|
+
singleton; ``Workspace.load()`` recreates a fresh default on next access.
|
|
331
|
+
"""
|
|
332
|
+
if not request_is_workspace_owner(request):
|
|
333
|
+
raise PermissionDenied("Only the workspace Owner can delete the workspace.")
|
|
334
|
+
|
|
335
|
+
workspace = Workspace.load()
|
|
336
|
+
confirmation = request.headers.get(CONFIRM_WORKSPACE_HEADER, "")
|
|
337
|
+
if confirmation != workspace.name:
|
|
338
|
+
raise ValidationError(
|
|
339
|
+
{
|
|
340
|
+
"detail": (
|
|
341
|
+
f"Confirmation failed — set the {CONFIRM_WORKSPACE_HEADER} header to the "
|
|
342
|
+
"exact workspace name to delete it."
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
services.purge_workspace()
|
|
348
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# #518 — Members
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class WorkspaceMemberCursorPagination(CursorPagination):
|
|
357
|
+
"""Depth-independent pagination for the workspace member list (#1317).
|
|
358
|
+
|
|
359
|
+
The roster grows with the org and was previously serialized in full —
|
|
360
|
+
``User.objects.all()`` with no bound — which risks OOM / slow responses on a
|
|
361
|
+
large workspace. A username-keyed cursor is stable under concurrent member
|
|
362
|
+
creation and avoids the COUNT + deep-OFFSET cost a page-number scheme would
|
|
363
|
+
pay on an unbounded user table. Mirrors AuditEventCursorPagination.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
ordering = "username"
|
|
367
|
+
page_size = 50
|
|
368
|
+
page_size_query_param = "page_size"
|
|
369
|
+
max_page_size = 200
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class WorkspaceMemberListView(APIView):
|
|
373
|
+
"""GET /api/v1/workspace/members/ — list members (#518).
|
|
374
|
+
|
|
375
|
+
Admins see every member; a non-admin member sees only their own row.
|
|
376
|
+
Cursor-paginated (#1317): each page materializes at most ``page_size`` rows,
|
|
377
|
+
so the member-row builder's batched queries stay bounded regardless of org
|
|
378
|
+
size.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
382
|
+
|
|
383
|
+
def _annotated_users(self, qs: QuerySet[Any]) -> QuerySet[Any]:
|
|
384
|
+
annotated: QuerySet[Any] = qs.annotate(
|
|
385
|
+
project_count=Count(
|
|
386
|
+
"memberships",
|
|
387
|
+
filter=Q(memberships__is_deleted=False, memberships__project__is_deleted=False),
|
|
388
|
+
distinct=True,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
return annotated
|
|
392
|
+
|
|
393
|
+
@extend_schema(
|
|
394
|
+
# This view manually cursor-paginates (it is an APIView, so drf-spectacular
|
|
395
|
+
# does not auto-wrap); declare the envelope explicitly so the schema matches
|
|
396
|
+
# the ``{next, previous, results}`` body (#2127).
|
|
397
|
+
responses={
|
|
398
|
+
200: inline_serializer(
|
|
399
|
+
name="PaginatedWorkspaceMemberList",
|
|
400
|
+
fields={
|
|
401
|
+
"next": serializers.URLField(allow_null=True),
|
|
402
|
+
"previous": serializers.URLField(allow_null=True),
|
|
403
|
+
"results": WorkspaceMemberSerializer(many=True),
|
|
404
|
+
},
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
def get(self, request: Request) -> Response:
|
|
409
|
+
role = _workspace_membership_role(request)
|
|
410
|
+
if role is not None and role >= WorkspaceRole.ADMIN:
|
|
411
|
+
qs = self._annotated_users(User.objects.all())
|
|
412
|
+
else:
|
|
413
|
+
# Non-admin: only your own row.
|
|
414
|
+
user_pk = request.user.pk
|
|
415
|
+
assert user_pk is not None # IsAuthenticated guarantees a real user
|
|
416
|
+
qs = self._annotated_users(User.objects.filter(pk=user_pk))
|
|
417
|
+
|
|
418
|
+
# Paginate the user queryset first, then build rows only for the page —
|
|
419
|
+
# _build_member_rows fans out into membership/group lookups keyed on the
|
|
420
|
+
# page's user ids, so bounding the page bounds those queries too.
|
|
421
|
+
paginator = WorkspaceMemberCursorPagination()
|
|
422
|
+
page = paginator.paginate_queryset(qs, request, view=self)
|
|
423
|
+
rows = _build_member_rows(list(page) if page is not None else [])
|
|
424
|
+
data = WorkspaceMemberSerializer(rows, many=True).data
|
|
425
|
+
return paginator.get_paginated_response(data)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _enforce_member_mutation_guard(
|
|
429
|
+
*,
|
|
430
|
+
membership: WorkspaceMembership,
|
|
431
|
+
actor_role: int | None,
|
|
432
|
+
actor_pk: Any,
|
|
433
|
+
target: Any,
|
|
434
|
+
new_role: Any,
|
|
435
|
+
new_status: Any,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Peer/higher-role guard for a member PATCH.
|
|
438
|
+
|
|
439
|
+
An actor may only change the role or the active status of a member whose
|
|
440
|
+
CURRENT role is strictly below their own (mirrors
|
|
441
|
+
access.views.ProjectMembershipViewSet.destroy). Without this an Admin could
|
|
442
|
+
deactivate a peer Admin, or — with two Owners — an Owner could be deactivated
|
|
443
|
+
by an Admin. REACTIVATION (status→ACTIVE) is gated too (#901): otherwise a
|
|
444
|
+
lower-role Admin could flip a deactivated Owner/peer-Admin back to ACTIVE,
|
|
445
|
+
restoring their login. Self-edits are exempt so an actor can still change
|
|
446
|
+
their own row (e.g. step down).
|
|
447
|
+
"""
|
|
448
|
+
mutating = new_role is not None or new_status in (
|
|
449
|
+
MemberStatus.DEACTIVATED,
|
|
450
|
+
MemberStatus.ACTIVE,
|
|
451
|
+
)
|
|
452
|
+
if (
|
|
453
|
+
mutating
|
|
454
|
+
and target.pk != actor_pk
|
|
455
|
+
and actor_role is not None
|
|
456
|
+
and membership.role >= actor_role
|
|
457
|
+
):
|
|
458
|
+
raise PermissionDenied("You can only modify members with a role lower than your own.")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _apply_member_role_change(
|
|
462
|
+
*, membership: WorkspaceMembership, new_role: Any, actor_role: int | None, target: Any
|
|
463
|
+
) -> int | None:
|
|
464
|
+
"""Apply a role change with grant + strand guards; return the prior role if changed.
|
|
465
|
+
|
|
466
|
+
An actor may only grant a role STRICTLY BELOW their own (#1728). ``>`` let an
|
|
467
|
+
Admin mint a peer Admin (equal role); combined with the peer-guard (an actor
|
|
468
|
+
can't modify a member at or above their own role) that peer was then
|
|
469
|
+
unmanageable by either Admin. ``>=`` matches the project analog
|
|
470
|
+
(access.views.ProjectMembershipViewSet.create). Demoting an owner must not
|
|
471
|
+
strand the workspace.
|
|
472
|
+
"""
|
|
473
|
+
if actor_role is not None and new_role >= actor_role:
|
|
474
|
+
raise PermissionDenied("You cannot assign a role equal to or higher than your own.")
|
|
475
|
+
if (
|
|
476
|
+
membership.role == WorkspaceRole.OWNER
|
|
477
|
+
and new_role < WorkspaceRole.OWNER
|
|
478
|
+
and services.would_strand_workspace(target.pk)
|
|
479
|
+
):
|
|
480
|
+
raise ValidationError({"detail": "Cannot demote the last Owner of the workspace."})
|
|
481
|
+
if new_role == membership.role:
|
|
482
|
+
return None
|
|
483
|
+
role_changed_from = membership.role
|
|
484
|
+
membership.role = new_role
|
|
485
|
+
membership.role_changed_at = timezone.now()
|
|
486
|
+
return role_changed_from
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _revoke_offboarded_credentials(target: Any, *, actor: Any = None) -> None:
|
|
490
|
+
"""Cut off every long-lived credential of a member being off-boarded (#2832).
|
|
491
|
+
|
|
492
|
+
``is_active = False`` alone terminates only the session/JWT path. A Personal
|
|
493
|
+
Access Token is a *separate*, long-lived bearer of the same authority, and its
|
|
494
|
+
``expires_at`` is optional — a ``legacy:full`` PAT minted without one never
|
|
495
|
+
retires by itself. Off-boarding is the control an operator relies on to
|
|
496
|
+
terminate access, and the visible signal (the browser session dying) made it
|
|
497
|
+
look complete while ``curl -H "Authorization: Bearer <PAT>"`` kept returning
|
|
498
|
+
200 at the member's full pre-departure permissions, indefinitely.
|
|
499
|
+
|
|
500
|
+
Revocation is **durable and one-way**: reactivating the member does not
|
|
501
|
+
un-revoke anything, and nothing else in the codebase clears ``revoked_at``.
|
|
502
|
+
That is deliberate. Tokens minted before a departure have been outside the
|
|
503
|
+
organization's control in the interim (a laptop, a CI secret store, a
|
|
504
|
+
personal script), so restoring them would restore credentials of unknown
|
|
505
|
+
provenance; a returning member mints new ones. This mirrors the password-reset
|
|
506
|
+
path (``core/password_reset.py``), which revokes for the same reason.
|
|
507
|
+
|
|
508
|
+
Scope: personal (``owner=target``) tokens and refresh tokens only, plus the
|
|
509
|
+
non-token durable grants ``target`` personally minted — their share links and
|
|
510
|
+
any git-automation webhook secret they configured (#2939;
|
|
511
|
+
``revoke_personal_durable_grants``). ``revoke_all_personal_access_tokens``
|
|
512
|
+
deliberately leaves project- and program-scoped *tokens* alone — those are org
|
|
513
|
+
assets minted *by* the departing member for a team's integration, not their
|
|
514
|
+
personal credentials, and killing them would break a team's CI on an
|
|
515
|
+
unrelated person's off-boarding.
|
|
516
|
+
|
|
517
|
+
All three calls are plain DB updates and run **inside** the caller's
|
|
518
|
+
``transaction.atomic()`` block, not under ``transaction.on_commit()``: they
|
|
519
|
+
must roll back with a failed deactivation, and there is no external
|
|
520
|
+
side effect that would justify deferring past commit. Deferring would open a
|
|
521
|
+
window in which the deactivation is committed but the credentials are still
|
|
522
|
+
live — precisely the state this function exists to prevent.
|
|
523
|
+
|
|
524
|
+
``actor`` is the admin performing the off-boarding, recorded on the per-token
|
|
525
|
+
``ApiTokenAuditEntry`` rows the revocation now writes (#2878). Off-boarding is one
|
|
526
|
+
of the two revocations an auditor actually asks about, and it was the one leaving
|
|
527
|
+
no trace at all.
|
|
528
|
+
"""
|
|
529
|
+
from trueppm_api.apps.access.services import (
|
|
530
|
+
revoke_all_personal_access_tokens,
|
|
531
|
+
revoke_all_refresh_tokens,
|
|
532
|
+
revoke_personal_durable_grants,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
revoke_all_refresh_tokens(target)
|
|
536
|
+
revoke_all_personal_access_tokens(target, actor=actor, reason="offboarding")
|
|
537
|
+
revoke_personal_durable_grants(target, actor=actor)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _apply_member_status_change(
|
|
541
|
+
*, membership: WorkspaceMembership, new_status: Any, target: Any, actor: Any = None
|
|
542
|
+
) -> Any:
|
|
543
|
+
"""Apply a status change, guarding the last Owner and syncing the login flag.
|
|
544
|
+
|
|
545
|
+
Returns the **prior** status when it actually changed, else ``None`` — the caller
|
|
546
|
+
audits on that, so a PATCH that re-sends the current status writes no audit row
|
|
547
|
+
(#2867), matching how ``_apply_member_role_change`` gates ``MEMBER_ROLE_CHANGED``.
|
|
548
|
+
"""
|
|
549
|
+
if (
|
|
550
|
+
new_status == MemberStatus.DEACTIVATED
|
|
551
|
+
and membership.role == WorkspaceRole.OWNER
|
|
552
|
+
and services.would_strand_workspace(target.pk)
|
|
553
|
+
):
|
|
554
|
+
raise ValidationError({"detail": "Cannot deactivate the last Owner of the workspace."})
|
|
555
|
+
status_changed_from = membership.status if membership.status != new_status else None
|
|
556
|
+
membership.status = new_status
|
|
557
|
+
# Sync the Django account active flag so a deactivated member cannot
|
|
558
|
+
# authenticate, and reactivation restores login.
|
|
559
|
+
target.is_active = new_status != MemberStatus.DEACTIVATED
|
|
560
|
+
target.save(update_fields=["is_active"])
|
|
561
|
+
if new_status == MemberStatus.DEACTIVATED:
|
|
562
|
+
# Deactivation only — the reactivation branch deliberately has no inverse
|
|
563
|
+
# (see _revoke_offboarded_credentials): revocation is durable, and a
|
|
564
|
+
# returning member mints new tokens.
|
|
565
|
+
_revoke_offboarded_credentials(target, actor=actor)
|
|
566
|
+
return status_changed_from
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _apply_member_availability(*, membership: WorkspaceMembership, data: dict[str, Any]) -> None:
|
|
570
|
+
"""Merge resource-availability baseline fields (#542) and validate the bounds.
|
|
571
|
+
|
|
572
|
+
Benign capacity metadata — it neither escalates a role nor gates login — so it
|
|
573
|
+
is deliberately NOT subject to the peer/higher-role guard: a resource manager
|
|
574
|
+
(Admin) declares availability for everyone, peers included. Presence of the key
|
|
575
|
+
(not its truthiness) drives the write so 0% and an explicit-null date clear are
|
|
576
|
+
honored, not skipped. The from<=to guard runs after the partial merge so a PATCH
|
|
577
|
+
that sets only one bound is still validated against the stored other.
|
|
578
|
+
"""
|
|
579
|
+
if "availability_percent" in data:
|
|
580
|
+
membership.availability_percent = data["availability_percent"]
|
|
581
|
+
if "availability_effective_from" in data:
|
|
582
|
+
membership.availability_effective_from = data["availability_effective_from"]
|
|
583
|
+
if "availability_effective_to" in data:
|
|
584
|
+
membership.availability_effective_to = data["availability_effective_to"]
|
|
585
|
+
if "availability_notes" in data:
|
|
586
|
+
membership.availability_notes = data["availability_notes"]
|
|
587
|
+
if (
|
|
588
|
+
membership.availability_effective_from is not None
|
|
589
|
+
and membership.availability_effective_to is not None
|
|
590
|
+
and membership.availability_effective_from > membership.availability_effective_to
|
|
591
|
+
):
|
|
592
|
+
raise ValidationError(
|
|
593
|
+
{"availability_effective_to": ("Must be on or after availability_effective_from.")}
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class WorkspaceMemberDetailView(IdempotencyMixin, APIView):
|
|
598
|
+
"""PATCH/DELETE /api/v1/workspace/members/{user_id}/ (#518). Admin only."""
|
|
599
|
+
|
|
600
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
601
|
+
|
|
602
|
+
def _get_user_or_404(self, user_id: str) -> Any:
|
|
603
|
+
return get_object_or_404(User, pk=user_id)
|
|
604
|
+
|
|
605
|
+
@extend_schema(
|
|
606
|
+
request=WorkspaceMemberUpdateSerializer, responses={200: WorkspaceMemberSerializer}
|
|
607
|
+
)
|
|
608
|
+
def patch(self, request: Request, user_id: str) -> Response:
|
|
609
|
+
target = self._get_user_or_404(user_id)
|
|
610
|
+
serializer = WorkspaceMemberUpdateSerializer(data=request.data)
|
|
611
|
+
serializer.is_valid(raise_exception=True)
|
|
612
|
+
data = serializer.validated_data
|
|
613
|
+
|
|
614
|
+
actor_role = _workspace_membership_role(request)
|
|
615
|
+
new_role = data.get("role")
|
|
616
|
+
new_status = data.get("status")
|
|
617
|
+
|
|
618
|
+
with transaction.atomic():
|
|
619
|
+
membership, _ = WorkspaceMembership.objects.select_for_update().get_or_create(
|
|
620
|
+
workspace=Workspace.load(),
|
|
621
|
+
user=target,
|
|
622
|
+
defaults={
|
|
623
|
+
"role": WorkspaceRole.OWNER if target.is_superuser else WorkspaceRole.MEMBER,
|
|
624
|
+
"status": MemberStatus.ACTIVE,
|
|
625
|
+
},
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
_enforce_member_mutation_guard(
|
|
629
|
+
membership=membership,
|
|
630
|
+
actor_role=actor_role,
|
|
631
|
+
actor_pk=request.user.pk,
|
|
632
|
+
target=target,
|
|
633
|
+
new_role=new_role,
|
|
634
|
+
new_status=new_status,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
role_changed_from: int | None = None
|
|
638
|
+
if new_role is not None:
|
|
639
|
+
role_changed_from = _apply_member_role_change(
|
|
640
|
+
membership=membership,
|
|
641
|
+
new_role=new_role,
|
|
642
|
+
actor_role=actor_role,
|
|
643
|
+
target=target,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
status_changed_from: Any = None
|
|
647
|
+
if new_status is not None:
|
|
648
|
+
status_changed_from = _apply_member_status_change(
|
|
649
|
+
membership=membership,
|
|
650
|
+
new_status=new_status,
|
|
651
|
+
target=target,
|
|
652
|
+
actor=request.user,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
_apply_member_availability(membership=membership, data=data)
|
|
656
|
+
|
|
657
|
+
membership.save()
|
|
658
|
+
|
|
659
|
+
# Audit a real status change (#2867) inside the same transaction, so a
|
|
660
|
+
# rolled-back deactivation leaves no audit row. Recorded separately from
|
|
661
|
+
# the role change because a PATCH may carry either or both, and because
|
|
662
|
+
# this is the destructive one: since #2832 a deactivation revokes the
|
|
663
|
+
# target's refresh tokens and PATs durably, so a status-only PATCH is
|
|
664
|
+
# irreversible and previously recorded nothing at all — while DELETE,
|
|
665
|
+
# which does strictly less, recorded MEMBER_REMOVED. `credentials_revoked`
|
|
666
|
+
# is what a later support conversation needs: "account re-enabled,
|
|
667
|
+
# credentials NOT restored" is a real state and this is its evidence.
|
|
668
|
+
if status_changed_from is not None:
|
|
669
|
+
services.record_audit_event(
|
|
670
|
+
event_type=AuditEventType.MEMBER_STATUS_CHANGED,
|
|
671
|
+
actor=request.user,
|
|
672
|
+
target_type="member",
|
|
673
|
+
target_label=services._actor_label(target),
|
|
674
|
+
metadata={
|
|
675
|
+
"old_status": MemberStatus(status_changed_from).label,
|
|
676
|
+
"new_status": MemberStatus(membership.status).label,
|
|
677
|
+
"credentials_revoked": new_status == MemberStatus.DEACTIVATED,
|
|
678
|
+
},
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
# Audit a real role change (ADR-0157) inside the same transaction so
|
|
682
|
+
# it rolls back with a failed save. A status-only PATCH leaves
|
|
683
|
+
# role_changed_from None and records nothing here.
|
|
684
|
+
if role_changed_from is not None:
|
|
685
|
+
services.record_audit_event(
|
|
686
|
+
event_type=AuditEventType.MEMBER_ROLE_CHANGED,
|
|
687
|
+
actor=request.user,
|
|
688
|
+
target_type="member",
|
|
689
|
+
target_label=services._actor_label(target),
|
|
690
|
+
metadata={
|
|
691
|
+
"old_role": WorkspaceRole(role_changed_from).label,
|
|
692
|
+
"new_role": WorkspaceRole(membership.role).label,
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
users = list(WorkspaceMemberListView()._annotated_users(User.objects.filter(pk=target.pk)))
|
|
697
|
+
return Response(_build_member_rows(users)[0] if users else {})
|
|
698
|
+
|
|
699
|
+
@extend_schema(
|
|
700
|
+
responses={
|
|
701
|
+
204: None,
|
|
702
|
+
400: state_refusal_400(
|
|
703
|
+
"Removing this member would leave the workspace with no Owner. "
|
|
704
|
+
"Reachable only on an Owner target — a peer/higher-role removal is "
|
|
705
|
+
"refused earlier with 403. Verified against the strand guard in "
|
|
706
|
+
"``delete`` (#3319)."
|
|
707
|
+
),
|
|
708
|
+
}
|
|
709
|
+
)
|
|
710
|
+
def delete(self, request: Request, user_id: str) -> Response:
|
|
711
|
+
target = self._get_user_or_404(user_id)
|
|
712
|
+
actor_role = _workspace_membership_role(request)
|
|
713
|
+
with transaction.atomic():
|
|
714
|
+
membership, _ = WorkspaceMembership.objects.select_for_update().get_or_create(
|
|
715
|
+
workspace=Workspace.load(),
|
|
716
|
+
user=target,
|
|
717
|
+
defaults={
|
|
718
|
+
# Materialize the implicit role so a deactivated superuser's
|
|
719
|
+
# row reflects their true OWNER tier (consistent with PATCH).
|
|
720
|
+
"role": WorkspaceRole.OWNER if target.is_superuser else WorkspaceRole.MEMBER,
|
|
721
|
+
"status": MemberStatus.ACTIVE,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
# Peer/higher-role guard (mirrors PATCH and access.views): an actor may
|
|
725
|
+
# only deactivate a member whose role is strictly below their own, so an
|
|
726
|
+
# Admin cannot deactivate a peer Admin or an Owner. Self-removal is exempt.
|
|
727
|
+
# This runs BEFORE the strand check so a forbidden peer/owner removal
|
|
728
|
+
# returns 403, not a 400 about ownership.
|
|
729
|
+
if (
|
|
730
|
+
target.pk != request.user.pk
|
|
731
|
+
and actor_role is not None
|
|
732
|
+
and membership.role >= actor_role
|
|
733
|
+
):
|
|
734
|
+
raise PermissionDenied(
|
|
735
|
+
"You can only remove members with a role lower than your own."
|
|
736
|
+
)
|
|
737
|
+
# Only an Owner removal can strand the workspace — gate the check on the
|
|
738
|
+
# target's role (mirrors PATCH) so deactivating a Member/Admin in an
|
|
739
|
+
# owner-less workspace is not spuriously blocked with a 400.
|
|
740
|
+
if membership.role == WorkspaceRole.OWNER and services.would_strand_workspace(
|
|
741
|
+
target.pk
|
|
742
|
+
):
|
|
743
|
+
raise ValidationError({"detail": "Cannot remove the last Owner of the workspace."})
|
|
744
|
+
membership.status = MemberStatus.DEACTIVATED
|
|
745
|
+
membership.save()
|
|
746
|
+
target.is_active = False
|
|
747
|
+
target.save(update_fields=["is_active"])
|
|
748
|
+
# Removal is a deactivation here, so it must cut off the same long-lived
|
|
749
|
+
# credentials the PATCH deactivate path does (#2832) — otherwise "remove
|
|
750
|
+
# from workspace," the strongest off-boarding action the UI offers, would
|
|
751
|
+
# be the weaker of the two.
|
|
752
|
+
_revoke_offboarded_credentials(target, actor=request.user)
|
|
753
|
+
|
|
754
|
+
# Audit inside the same transaction so the row rolls back with a
|
|
755
|
+
# failed deactivation (ADR-0157). "Removed" here is a deactivation —
|
|
756
|
+
# the OSS workspace deactivates members rather than hard-deleting them.
|
|
757
|
+
services.record_audit_event(
|
|
758
|
+
event_type=AuditEventType.MEMBER_REMOVED,
|
|
759
|
+
actor=request.user,
|
|
760
|
+
target_type="member",
|
|
761
|
+
target_label=services._actor_label(target),
|
|
762
|
+
metadata={"role": WorkspaceRole(membership.role).label},
|
|
763
|
+
)
|
|
764
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
class WorkspaceInviteListView(IdempotencyMixin, APIView):
|
|
768
|
+
"""GET/POST /api/v1/workspace/invites/ (#518). Admin only."""
|
|
769
|
+
|
|
770
|
+
# #1724: pending invites expose PII (email / role / invited_by). IsWorkspaceAdmin
|
|
771
|
+
# would admit any implicit member on GET — gate reads at ADMIN too.
|
|
772
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
|
|
773
|
+
# Standard page-number envelope so this bounded admin list returns the same
|
|
774
|
+
# {count,next,previous,results} shape as every other list endpoint — an
|
|
775
|
+
# integrator can't tell paginated from unpaginated when one list returns a
|
|
776
|
+
# bare array (#1355). Set as an attribute (not just instantiated below) so
|
|
777
|
+
# drf-spectacular documents the envelope in the OpenAPI schema too.
|
|
778
|
+
pagination_class = PageNumberPagination
|
|
779
|
+
|
|
780
|
+
@extend_schema(
|
|
781
|
+
parameters=[
|
|
782
|
+
OpenApiParameter(
|
|
783
|
+
name="status",
|
|
784
|
+
type=OpenApiTypes.STR,
|
|
785
|
+
location=OpenApiParameter.QUERY,
|
|
786
|
+
required=False,
|
|
787
|
+
enum=[*InviteStatus.values, "all"],
|
|
788
|
+
description=(
|
|
789
|
+
"Which invites to list. Defaults to `pending` — the working set the "
|
|
790
|
+
"Members page shows. Pass a terminal status (`accepted`, `revoked`, "
|
|
791
|
+
"`expired`, `failed`) or `all` to read the invite history, including "
|
|
792
|
+
"rows the default hides. Unknown value → 400."
|
|
793
|
+
),
|
|
794
|
+
)
|
|
795
|
+
],
|
|
796
|
+
responses={200: WorkspaceInviteSerializer(many=True)},
|
|
797
|
+
)
|
|
798
|
+
def get(self, request: Request) -> Response:
|
|
799
|
+
"""List workspace invites, defaulting to the pending working set (#518, #2911).
|
|
800
|
+
|
|
801
|
+
``?status=`` exists because the default was previously the *only* view: the
|
|
802
|
+
query was hard-filtered to PENDING, so accepting or revoking an invite erased
|
|
803
|
+
it from the one admin surface that showed it and nothing replaced it — an
|
|
804
|
+
admin could not answer "who was invited last month, and did they join?"
|
|
805
|
+
``accepted_at`` and ``accepted_user`` were written by the accept path and
|
|
806
|
+
exposed by no endpoint at all.
|
|
807
|
+
|
|
808
|
+
The default stays ``pending`` rather than widening to everything: the Members
|
|
809
|
+
page's pending-invite section reads this endpoint, and a silent change to what
|
|
810
|
+
an unparameterized GET returns would put revoked and expired rows into a list
|
|
811
|
+
captioned "Pending invites". Reading the history is an explicit ask.
|
|
812
|
+
|
|
813
|
+
``accepted_user`` joins ``invited_by`` in ``select_related`` because
|
|
814
|
+
``_invite_dict`` renders initials for both, which needs the user row — without
|
|
815
|
+
it the history page costs one extra query per accepted invite.
|
|
816
|
+
"""
|
|
817
|
+
requested = (request.query_params.get("status") or InviteStatus.PENDING).strip().lower()
|
|
818
|
+
if requested != "all" and requested not in InviteStatus.values:
|
|
819
|
+
raise ValidationError(
|
|
820
|
+
{"status": f"Must be one of {', '.join([*InviteStatus.values, 'all'])}."}
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
invites = WorkspaceInvite.objects.select_related("invited_by", "accepted_user")
|
|
824
|
+
if requested != "all":
|
|
825
|
+
invites = invites.filter(status=requested)
|
|
826
|
+
# Newest first, so the history reads like a log rather than in insertion order.
|
|
827
|
+
invites = invites.order_by("-created_at")
|
|
828
|
+
# Paginate the queryset first, then build dicts only for the page.
|
|
829
|
+
paginator = self.pagination_class()
|
|
830
|
+
page = paginator.paginate_queryset(invites, request, view=self)
|
|
831
|
+
rows = [_invite_dict(i) for i in (page if page is not None else invites)]
|
|
832
|
+
data = WorkspaceInviteSerializer(rows, many=True).data
|
|
833
|
+
return paginator.get_paginated_response(data)
|
|
834
|
+
|
|
835
|
+
@extend_schema(
|
|
836
|
+
request=WorkspaceInviteCreateSerializer, responses={201: WorkspaceInviteSerializer}
|
|
837
|
+
)
|
|
838
|
+
def post(self, request: Request) -> Response:
|
|
839
|
+
serializer = WorkspaceInviteCreateSerializer(data=request.data)
|
|
840
|
+
serializer.is_valid(raise_exception=True)
|
|
841
|
+
email = serializer.validated_data["email"]
|
|
842
|
+
|
|
843
|
+
# Actor-ceiling: an actor may only invite someone to a role STRICTLY
|
|
844
|
+
# BELOW their own — the same ``>=`` rule WorkspaceMemberDetailView.patch
|
|
845
|
+
# enforces on existing members (#1728), applied here so an invite can't
|
|
846
|
+
# be a back-door escalation. ``>`` would let an Admin invite a peer Admin
|
|
847
|
+
# (equal role); the accept path grants the invite's role verbatim, so the
|
|
848
|
+
# peer-Admin hole the PATCH gate closes would stay reachable via invites.
|
|
849
|
+
actor_role = _workspace_membership_role(request)
|
|
850
|
+
if actor_role is not None and serializer.validated_data["role"] >= actor_role:
|
|
851
|
+
raise PermissionDenied(
|
|
852
|
+
"You cannot invite someone to a role equal to or higher than your own."
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
if User.objects.filter(email__iexact=email, is_active=True).exists():
|
|
856
|
+
raise ValidationError({"email": "A member with this email already exists."})
|
|
857
|
+
if WorkspaceInvite.objects.filter(
|
|
858
|
+
email__iexact=email, status=InviteStatus.PENDING
|
|
859
|
+
).exists():
|
|
860
|
+
raise ValidationError({"email": "An invite for this email is already pending."})
|
|
861
|
+
|
|
862
|
+
invite = services.create_invite(
|
|
863
|
+
workspace=Workspace.load(),
|
|
864
|
+
email=email,
|
|
865
|
+
role=serializer.validated_data["role"],
|
|
866
|
+
invited_by=request.user,
|
|
867
|
+
)
|
|
868
|
+
# #2911: an invite grants a role to an address nobody has verified yet, and
|
|
869
|
+
# the row it creates is no longer visible once accepted or revoked. Record
|
|
870
|
+
# the grant at the moment it is made, so "who offered this role, and which
|
|
871
|
+
# role" survives independently of the invite row's lifecycle.
|
|
872
|
+
services.record_audit_event(
|
|
873
|
+
event_type=AuditEventType.INVITE_SENT,
|
|
874
|
+
actor=request.user,
|
|
875
|
+
target_type="invite",
|
|
876
|
+
target_id=invite.pk,
|
|
877
|
+
target_label=invite.email,
|
|
878
|
+
metadata={"role": WorkspaceRole(invite.role).label},
|
|
879
|
+
)
|
|
880
|
+
return Response(_invite_dict(invite), status=status.HTTP_201_CREATED)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
class WorkspaceInviteDetailView(IdempotencyMixin, APIView):
|
|
884
|
+
"""DELETE /api/v1/workspace/invites/{id}/ — revoke a pending invite (#518)."""
|
|
885
|
+
|
|
886
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
887
|
+
|
|
888
|
+
def delete(self, request: Request, invite_id: str) -> Response:
|
|
889
|
+
invite = get_object_or_404(WorkspaceInvite, pk=invite_id)
|
|
890
|
+
if invite.status == InviteStatus.PENDING:
|
|
891
|
+
invite.status = InviteStatus.REVOKED
|
|
892
|
+
invite.email_pending = False
|
|
893
|
+
invite.email_token = ""
|
|
894
|
+
invite.save(update_fields=["status", "email_pending", "email_token"])
|
|
895
|
+
# Only on a real state change (#2911). A repeat DELETE of an already
|
|
896
|
+
# revoked or accepted invite is a no-op and must not write a second row
|
|
897
|
+
# claiming a revocation that did not occur — the guard above is what
|
|
898
|
+
# makes the audit count match the number of revocations.
|
|
899
|
+
services.record_audit_event(
|
|
900
|
+
event_type=AuditEventType.INVITE_REVOKED,
|
|
901
|
+
actor=request.user,
|
|
902
|
+
target_type="invite",
|
|
903
|
+
target_id=invite.pk,
|
|
904
|
+
target_label=invite.email,
|
|
905
|
+
metadata={
|
|
906
|
+
"role": WorkspaceRole(invite.role).label,
|
|
907
|
+
"invited_by": services._actor_label(invite.invited_by),
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
class InviteAcceptView(IdempotencyMixin, APIView):
|
|
914
|
+
"""POST /api/v1/workspace/invites/accept/ — accept an invite (public, token-auth).
|
|
915
|
+
|
|
916
|
+
No session required: the one-time token in the body is the credential. Errors
|
|
917
|
+
are generic to avoid token enumeration.
|
|
918
|
+
"""
|
|
919
|
+
|
|
920
|
+
permission_classes = [AllowAny]
|
|
921
|
+
authentication_classes: list[Any] = []
|
|
922
|
+
throttle_classes = [InviteAcceptThrottle]
|
|
923
|
+
|
|
924
|
+
@extend_schema(request=InviteAcceptSerializer, responses={200: None})
|
|
925
|
+
def post(self, request: Request) -> Response:
|
|
926
|
+
serializer = InviteAcceptSerializer(data=request.data)
|
|
927
|
+
serializer.is_valid(raise_exception=True)
|
|
928
|
+
try:
|
|
929
|
+
user = services.accept_invite(
|
|
930
|
+
token=serializer.validated_data["token"],
|
|
931
|
+
username=serializer.validated_data.get("username", ""),
|
|
932
|
+
password=serializer.validated_data.get("password", ""),
|
|
933
|
+
)
|
|
934
|
+
except services.InviteError as exc:
|
|
935
|
+
# Return the 400 directly rather than raising: DRF's exception handler
|
|
936
|
+
# calls set_rollback(), which would revert the expired-status write
|
|
937
|
+
# accept_invite makes for an overdue token under ATOMIC_REQUESTS.
|
|
938
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
939
|
+
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
|
940
|
+
return Response({"detail": "Invitation accepted.", "username": user.username})
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
class InviteResendThrottle(ScopedRateThrottle):
|
|
944
|
+
"""Caps invite-resend frequency (ADR-0149) to bound email-bomb abuse.
|
|
945
|
+
|
|
946
|
+
Scoped per-admin via ``invite_resend`` (5/min). The bulk resend-all endpoint
|
|
947
|
+
is one request → one bucket hit, so it cannot be looped to exceed the cap.
|
|
948
|
+
|
|
949
|
+
``ScopedRateThrottle`` normally reassigns ``self.scope = getattr(view,
|
|
950
|
+
self.scope_attr, None)`` on every ``allow_request`` and returns ``True`` when
|
|
951
|
+
that is falsy — so a ``scope`` class attribute declared here alone is silently
|
|
952
|
+
overwritten and the throttle becomes a no-op (#3598). Both views below
|
|
953
|
+
therefore also set ``throttle_scope`` (mirrors ``_SsoProviderWriteThrottle``,
|
|
954
|
+
#3552).
|
|
955
|
+
"""
|
|
956
|
+
|
|
957
|
+
scope = "invite_resend"
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
class WorkspaceInviteResendView(IdempotencyMixin, APIView):
|
|
961
|
+
"""POST /api/v1/workspace/invites/{id}/resend/ — re-queue one invite (ADR-0149).
|
|
962
|
+
|
|
963
|
+
Admin only. Re-issues a fresh token (the old email link stops working) and puts
|
|
964
|
+
the row back in the outbox for ``drain_invite_emails`` to pick up. Best-effort
|
|
965
|
+
dispatch → 202 ``{"queued": true}``. Only PENDING/FAILED invites are resendable;
|
|
966
|
+
accepted/revoked/expired return 409.
|
|
967
|
+
"""
|
|
968
|
+
|
|
969
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
970
|
+
throttle_classes = [InviteResendThrottle]
|
|
971
|
+
# ScopedRateThrottle reads the scope off the VIEW, not the throttle class — see
|
|
972
|
+
# InviteResendThrottle's docstring (#3598).
|
|
973
|
+
throttle_scope = "invite_resend"
|
|
974
|
+
|
|
975
|
+
@extend_schema(
|
|
976
|
+
request=None,
|
|
977
|
+
responses={
|
|
978
|
+
202: OpenApiResponse(description="Invite re-queued for sending."),
|
|
979
|
+
404: OpenApiResponse(description="Invite not found."),
|
|
980
|
+
409: OpenApiResponse(description="Invite is not in a resendable state."),
|
|
981
|
+
},
|
|
982
|
+
)
|
|
983
|
+
def post(self, request: Request, invite_id: str) -> Response:
|
|
984
|
+
if not WorkspaceInvite.objects.filter(pk=invite_id).exists():
|
|
985
|
+
raise Http404("Invite not found.")
|
|
986
|
+
invite = services.resend_invite(invite_id)
|
|
987
|
+
if invite is None:
|
|
988
|
+
# Exists (checked above) but not resendable → accepted/revoked/expired.
|
|
989
|
+
return Response(
|
|
990
|
+
{"detail": "This invite can no longer be resent."},
|
|
991
|
+
status=status.HTTP_409_CONFLICT,
|
|
992
|
+
)
|
|
993
|
+
transaction.on_commit(services.drain_invite_emails_soon)
|
|
994
|
+
return Response({"queued": True}, status=status.HTTP_202_ACCEPTED)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
class WorkspaceInviteResendAllView(IdempotencyMixin, APIView):
|
|
998
|
+
"""POST /api/v1/workspace/invites/resend-all/ — re-queue every pending invite.
|
|
999
|
+
|
|
1000
|
+
Admin only. One transaction, one throttle bucket — emails everyone with a
|
|
1001
|
+
resendable invite at once. Returns 202 ``{"requeued": n}`` (n excludes invites
|
|
1002
|
+
already in flight).
|
|
1003
|
+
"""
|
|
1004
|
+
|
|
1005
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
1006
|
+
throttle_classes = [InviteResendThrottle]
|
|
1007
|
+
# ScopedRateThrottle reads the scope off the VIEW, not the throttle class — see
|
|
1008
|
+
# InviteResendThrottle's docstring (#3598).
|
|
1009
|
+
throttle_scope = "invite_resend"
|
|
1010
|
+
|
|
1011
|
+
@extend_schema(
|
|
1012
|
+
request=None,
|
|
1013
|
+
responses={202: OpenApiResponse(description="Pending invites re-queued.")},
|
|
1014
|
+
)
|
|
1015
|
+
def post(self, request: Request) -> Response:
|
|
1016
|
+
count = services.resend_all_pending(Workspace.load())
|
|
1017
|
+
if count:
|
|
1018
|
+
transaction.on_commit(services.drain_invite_emails_soon)
|
|
1019
|
+
return Response({"requeued": count}, status=status.HTTP_202_ACCEPTED)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# Magic-byte signatures for the raster logo allowlist (ADR-0149). We sniff the
|
|
1023
|
+
# leading bytes rather than trust the multipart Content-Type, and accept no SVG —
|
|
1024
|
+
# a publicly-served SVG is a stored-XSS vector.
|
|
1025
|
+
_LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2 MB
|
|
1026
|
+
_LOGO_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
|
1027
|
+
_LOGO_MIME_PNG = "image/png"
|
|
1028
|
+
_LOGO_MIME_WEBP = "image/webp"
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _sniff_logo_mime(head: bytes) -> str | None:
|
|
1032
|
+
"""Return the canonical MIME for a PNG/WebP byte head, or None if neither."""
|
|
1033
|
+
if head.startswith(_LOGO_PNG_MAGIC):
|
|
1034
|
+
return _LOGO_MIME_PNG
|
|
1035
|
+
# WebP: 'RIFF' <4-byte size> 'WEBP'.
|
|
1036
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
1037
|
+
return _LOGO_MIME_WEBP
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
class WorkspaceLogoView(IdempotencyMixin, APIView):
|
|
1042
|
+
"""Workspace logo: POST upload, DELETE clear (admin), GET serve (public).
|
|
1043
|
+
|
|
1044
|
+
GET is ``AllowAny`` — the logo is non-sensitive branding shown on public share
|
|
1045
|
+
pages, so serving it without auth avoids a JWT-in-``<img>`` problem. Write paths
|
|
1046
|
+
require workspace ADMIN+. Raster only (PNG/WebP); content type is decided by a
|
|
1047
|
+
magic-byte sniff, not the client-declared Content-Type (ADR-0149).
|
|
1048
|
+
|
|
1049
|
+
Carries ``IdempotencyMixin`` like every other unsafe workspace view: the mixin
|
|
1050
|
+
only intercepts unsafe methods bearing an Idempotency-Key, so the public GET
|
|
1051
|
+
surface is unaffected.
|
|
1052
|
+
"""
|
|
1053
|
+
|
|
1054
|
+
parser_classes = [MultiPartParser, FormParser]
|
|
1055
|
+
|
|
1056
|
+
def get_permissions(self) -> list[Any]:
|
|
1057
|
+
if self.request.method == "GET":
|
|
1058
|
+
return [AllowAny()]
|
|
1059
|
+
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
|
1060
|
+
|
|
1061
|
+
def get_authenticators(self) -> list[Any]:
|
|
1062
|
+
# Public GET must not 401 on an absent/invalid token; only the write paths
|
|
1063
|
+
# authenticate. Mirrors InviteAcceptView's anonymous surface.
|
|
1064
|
+
if getattr(self.request, "method", None) == "GET":
|
|
1065
|
+
return []
|
|
1066
|
+
return super().get_authenticators()
|
|
1067
|
+
|
|
1068
|
+
@extend_schema(
|
|
1069
|
+
responses={
|
|
1070
|
+
200: OpenApiResponse(description="Logo image bytes."),
|
|
1071
|
+
404: OpenApiResponse(description="No logo set."),
|
|
1072
|
+
},
|
|
1073
|
+
description="Serve the workspace logo (public). Returns 404 when unset.",
|
|
1074
|
+
)
|
|
1075
|
+
def get(self, request: Request) -> FileResponse:
|
|
1076
|
+
workspace = Workspace.load()
|
|
1077
|
+
if not workspace.logo:
|
|
1078
|
+
raise Http404("No workspace logo set.")
|
|
1079
|
+
content_type = workspace.logo_mime or _LOGO_MIME_PNG
|
|
1080
|
+
response = FileResponse(workspace.logo.open("rb"), content_type=content_type)
|
|
1081
|
+
# Branding is non-sensitive but still user-uploaded: force inline rendering,
|
|
1082
|
+
# block content-type sniffing, and let caches hold it (busted via ?v= on the
|
|
1083
|
+
# serializer URL when the logo changes).
|
|
1084
|
+
response["Content-Disposition"] = "inline"
|
|
1085
|
+
response["X-Content-Type-Options"] = "nosniff"
|
|
1086
|
+
response["Cache-Control"] = "public, max-age=86400"
|
|
1087
|
+
return response
|
|
1088
|
+
|
|
1089
|
+
@extend_schema(
|
|
1090
|
+
request={
|
|
1091
|
+
"multipart/form-data": {
|
|
1092
|
+
"type": "object",
|
|
1093
|
+
"properties": {"file": {"type": "string", "format": "binary"}},
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
responses={200: WorkspaceSettingsSerializer},
|
|
1097
|
+
)
|
|
1098
|
+
def post(self, request: Request) -> Response:
|
|
1099
|
+
upload = request.FILES.get("file")
|
|
1100
|
+
if upload is None:
|
|
1101
|
+
raise ValidationError({"file": "No file was uploaded."})
|
|
1102
|
+
if upload.size > _LOGO_MAX_BYTES:
|
|
1103
|
+
return Response(
|
|
1104
|
+
{"file": "Logo must be 2 MB or smaller."},
|
|
1105
|
+
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
1106
|
+
)
|
|
1107
|
+
head = upload.read(32)
|
|
1108
|
+
upload.seek(0)
|
|
1109
|
+
mime = _sniff_logo_mime(head)
|
|
1110
|
+
if mime is None:
|
|
1111
|
+
return Response(
|
|
1112
|
+
{"file": "Logo must be a PNG or WebP image."},
|
|
1113
|
+
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
1114
|
+
)
|
|
1115
|
+
workspace = services.set_workspace_logo(file=upload, mime=mime)
|
|
1116
|
+
return Response(WorkspaceSettingsSerializer(workspace).data)
|
|
1117
|
+
|
|
1118
|
+
@extend_schema(responses={200: WorkspaceSettingsSerializer})
|
|
1119
|
+
def delete(self, request: Request) -> Response:
|
|
1120
|
+
workspace = services.clear_workspace_logo()
|
|
1121
|
+
return Response(WorkspaceSettingsSerializer(workspace).data)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
# ---------------------------------------------------------------------------
|
|
1125
|
+
# #519 — Groups & teams
|
|
1126
|
+
# ---------------------------------------------------------------------------
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
class GroupListView(IdempotencyMixin, APIView):
|
|
1130
|
+
"""GET/POST /api/v1/workspace/groups/ (#519). Read and create: admin only."""
|
|
1131
|
+
|
|
1132
|
+
# #1724: group rosters are org structure — IsWorkspaceAdmin would leak them to
|
|
1133
|
+
# any implicit member on GET. Gate reads at ADMIN, matching create.
|
|
1134
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
|
|
1135
|
+
# Standard page-number envelope so this admin list matches every other list
|
|
1136
|
+
# endpoint's {count,next,previous,results} shape (#1355).
|
|
1137
|
+
pagination_class = PageNumberPagination
|
|
1138
|
+
|
|
1139
|
+
@extend_schema(responses={200: GroupSerializer(many=True)})
|
|
1140
|
+
def get(self, request: Request) -> Response:
|
|
1141
|
+
groups = Group.objects.filter(is_deleted=False).select_related("lead")
|
|
1142
|
+
# Paginate the queryset first, then build dicts only for the page —
|
|
1143
|
+
# _build_group_dicts fans out into membership/project lookups.
|
|
1144
|
+
paginator = self.pagination_class()
|
|
1145
|
+
page = paginator.paginate_queryset(groups, request, view=self)
|
|
1146
|
+
rows = _build_group_dicts(list(page) if page is not None else list(groups))
|
|
1147
|
+
data = GroupSerializer(rows, many=True).data
|
|
1148
|
+
return paginator.get_paginated_response(data)
|
|
1149
|
+
|
|
1150
|
+
@extend_schema(request=GroupWriteSerializer, responses={201: GroupSerializer})
|
|
1151
|
+
def post(self, request: Request) -> Response:
|
|
1152
|
+
serializer = GroupWriteSerializer(data=request.data)
|
|
1153
|
+
serializer.is_valid(raise_exception=True)
|
|
1154
|
+
group = serializer.save(workspace=Workspace.load())
|
|
1155
|
+
return Response(_build_group_dict(group), status=status.HTTP_201_CREATED)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
class GroupDetailView(IdempotencyMixin, APIView):
|
|
1159
|
+
"""GET/PATCH/DELETE /api/v1/workspace/groups/{id}/ (#519)."""
|
|
1160
|
+
|
|
1161
|
+
# #1724: a group's roster is org structure — IsWorkspaceAdmin would leak it to
|
|
1162
|
+
# any implicit member on GET. Gate reads at ADMIN, matching patch/delete.
|
|
1163
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
|
|
1164
|
+
|
|
1165
|
+
def _get_group_or_404(self, group_id: str) -> Group:
|
|
1166
|
+
return get_object_or_404(Group, pk=group_id, is_deleted=False)
|
|
1167
|
+
|
|
1168
|
+
@extend_schema(responses={200: GroupSerializer})
|
|
1169
|
+
def get(self, request: Request, group_id: str) -> Response:
|
|
1170
|
+
return Response(_build_group_dict(self._get_group_or_404(group_id)))
|
|
1171
|
+
|
|
1172
|
+
@extend_schema(request=GroupWriteSerializer, responses={200: GroupSerializer})
|
|
1173
|
+
def patch(self, request: Request, group_id: str) -> Response:
|
|
1174
|
+
group = self._get_group_or_404(group_id)
|
|
1175
|
+
serializer = GroupWriteSerializer(group, data=request.data, partial=True)
|
|
1176
|
+
serializer.is_valid(raise_exception=True)
|
|
1177
|
+
serializer.save()
|
|
1178
|
+
return Response(_build_group_dict(group))
|
|
1179
|
+
|
|
1180
|
+
def delete(self, request: Request, group_id: str) -> Response:
|
|
1181
|
+
group = self._get_group_or_404(group_id)
|
|
1182
|
+
with transaction.atomic():
|
|
1183
|
+
group.soft_delete()
|
|
1184
|
+
services.reconcile_group_access(group.pk)
|
|
1185
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
class GroupMemberView(IdempotencyMixin, APIView):
|
|
1189
|
+
"""POST/DELETE /api/v1/workspace/groups/{id}/members/[{user_id}/] (#519)."""
|
|
1190
|
+
|
|
1191
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
1192
|
+
|
|
1193
|
+
def _get_group_or_404(self, group_id: str) -> Group:
|
|
1194
|
+
return get_object_or_404(Group, pk=group_id, is_deleted=False)
|
|
1195
|
+
|
|
1196
|
+
@extend_schema(request=GroupMemberAddSerializer, responses={201: GroupSerializer})
|
|
1197
|
+
def post(self, request: Request, group_id: str) -> Response:
|
|
1198
|
+
group = self._get_group_or_404(group_id)
|
|
1199
|
+
serializer = GroupMemberAddSerializer(data=request.data)
|
|
1200
|
+
serializer.is_valid(raise_exception=True)
|
|
1201
|
+
target = get_object_or_404(User, pk=serializer.validated_data["user"])
|
|
1202
|
+
with transaction.atomic():
|
|
1203
|
+
membership = (
|
|
1204
|
+
GroupMembership.objects.select_for_update().filter(group=group, user=target).first()
|
|
1205
|
+
)
|
|
1206
|
+
if membership is None:
|
|
1207
|
+
GroupMembership.objects.create(group=group, user=target)
|
|
1208
|
+
elif membership.is_deleted:
|
|
1209
|
+
membership.is_deleted = False
|
|
1210
|
+
membership.deleted_version = None
|
|
1211
|
+
membership.save()
|
|
1212
|
+
services.reconcile_group_access(group.pk)
|
|
1213
|
+
return Response(_build_group_dict(group), status=status.HTTP_201_CREATED)
|
|
1214
|
+
|
|
1215
|
+
def delete(self, request: Request, group_id: str, user_id: str) -> Response:
|
|
1216
|
+
group = self._get_group_or_404(group_id)
|
|
1217
|
+
with transaction.atomic():
|
|
1218
|
+
membership = (
|
|
1219
|
+
GroupMembership.objects.select_for_update()
|
|
1220
|
+
.filter(group=group, user_id=user_id, is_deleted=False)
|
|
1221
|
+
.first()
|
|
1222
|
+
)
|
|
1223
|
+
if membership is not None:
|
|
1224
|
+
membership.soft_delete()
|
|
1225
|
+
services.reconcile_group_access(group.pk)
|
|
1226
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
class GroupProjectView(IdempotencyMixin, APIView):
|
|
1230
|
+
"""POST/DELETE /api/v1/workspace/groups/{id}/projects/[{project_id}/] (#519).
|
|
1231
|
+
|
|
1232
|
+
Linking a group to a project confers the chosen role on every group member
|
|
1233
|
+
via the cascade (ADR-0087 §5).
|
|
1234
|
+
"""
|
|
1235
|
+
|
|
1236
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdmin]
|
|
1237
|
+
# Exempt from the archived-write invariant (#3414). This is a WORKSPACE surface —
|
|
1238
|
+
# mounted under `/workspace/groups/`, gated by `IsWorkspaceAdmin`, and the resource
|
|
1239
|
+
# written is the group's access list; the project is a link target. Archiving freezes
|
|
1240
|
+
# a plan's contents, not who may read it, and an admin must be able to add or remove
|
|
1241
|
+
# an archived project from a group while reorganizing access.
|
|
1242
|
+
#
|
|
1243
|
+
# Note the asymmetry with `ProjectMembershipViewSet`, which DOES carry
|
|
1244
|
+
# `IsProjectNotArchived`: the two surfaces disagree about whether access
|
|
1245
|
+
# administration is a project write. Reconciling them is a product decision, not a
|
|
1246
|
+
# side effect of this invariant — see the MR for #3414.
|
|
1247
|
+
archived_write_exempt = (
|
|
1248
|
+
"workspace-level group access administration: the write is the group's access "
|
|
1249
|
+
"list and archiving freezes a plan's contents, not who may read it"
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
def _get_group_or_404(self, group_id: str) -> Group:
|
|
1253
|
+
return get_object_or_404(Group, pk=group_id, is_deleted=False)
|
|
1254
|
+
|
|
1255
|
+
@extend_schema(request=GroupProjectWriteSerializer, responses={201: GroupSerializer})
|
|
1256
|
+
def post(self, request: Request, group_id: str) -> Response:
|
|
1257
|
+
from trueppm_api.apps.projects.models import Project
|
|
1258
|
+
|
|
1259
|
+
group = self._get_group_or_404(group_id)
|
|
1260
|
+
serializer = GroupProjectWriteSerializer(data=request.data)
|
|
1261
|
+
serializer.is_valid(raise_exception=True)
|
|
1262
|
+
project = get_object_or_404(
|
|
1263
|
+
Project, pk=serializer.validated_data["project"], is_deleted=False
|
|
1264
|
+
)
|
|
1265
|
+
with transaction.atomic():
|
|
1266
|
+
GroupProject.objects.update_or_create(
|
|
1267
|
+
group=group,
|
|
1268
|
+
project=project,
|
|
1269
|
+
defaults={
|
|
1270
|
+
"role": serializer.validated_data["role"],
|
|
1271
|
+
# IsAuthenticated guarantees a concrete User, not AnonymousUser.
|
|
1272
|
+
"added_by": request.user, # type: ignore[misc]
|
|
1273
|
+
},
|
|
1274
|
+
)
|
|
1275
|
+
services.reconcile_group_access(group.pk)
|
|
1276
|
+
return Response(_build_group_dict(group), status=status.HTTP_201_CREATED)
|
|
1277
|
+
|
|
1278
|
+
def delete(self, request: Request, group_id: str, project_id: str) -> Response:
|
|
1279
|
+
group = self._get_group_or_404(group_id)
|
|
1280
|
+
with transaction.atomic():
|
|
1281
|
+
GroupProject.objects.filter(group=group, project_id=project_id).delete()
|
|
1282
|
+
services.reconcile_group_access(group.pk)
|
|
1283
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
# ---------------------------------------------------------------------------
|
|
1287
|
+
# #641 — Workspace lifecycle: transfer ownership / export (ADR-0174)
|
|
1288
|
+
# (Workspace hard-delete is ``WorkspaceSettingsView.delete`` above.)
|
|
1289
|
+
# ---------------------------------------------------------------------------
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
class TransferOwnershipView(IdempotencyMixin, APIView):
|
|
1293
|
+
"""POST /api/v1/workspace/transfer-ownership/ — hand the workspace to a member.
|
|
1294
|
+
|
|
1295
|
+
Owner only. The target must already be an active member; the current owner is
|
|
1296
|
+
demoted to Admin (#641). Mirrors the project transfer action.
|
|
1297
|
+
"""
|
|
1298
|
+
|
|
1299
|
+
permission_classes = [IsAuthenticated, IsWorkspaceOwner]
|
|
1300
|
+
|
|
1301
|
+
@extend_schema(
|
|
1302
|
+
request=TransferOwnershipSerializer,
|
|
1303
|
+
responses={200: OpenApiResponse(description="Ownership transferred.")},
|
|
1304
|
+
)
|
|
1305
|
+
def post(self, request: Request) -> Response:
|
|
1306
|
+
serializer = TransferOwnershipSerializer(data=request.data)
|
|
1307
|
+
serializer.is_valid(raise_exception=True)
|
|
1308
|
+
new_owner_id = serializer.validated_data["new_owner_user_id"]
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
new_owner = User.objects.get(pk=new_owner_id)
|
|
1312
|
+
except User.DoesNotExist:
|
|
1313
|
+
return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND)
|
|
1314
|
+
|
|
1315
|
+
try:
|
|
1316
|
+
services.transfer_workspace_ownership(new_owner=new_owner, actor=request.user)
|
|
1317
|
+
except DjangoValidationError as exc:
|
|
1318
|
+
detail = exc.messages[0] if exc.messages else str(exc)
|
|
1319
|
+
# codeql[py/stack-trace-exposure] -- intentional user-facing validation message
|
|
1320
|
+
return Response({"detail": detail}, status=status.HTTP_400_BAD_REQUEST)
|
|
1321
|
+
|
|
1322
|
+
return Response(
|
|
1323
|
+
{"detail": "Workspace ownership transferred.", "new_owner_user_id": new_owner_id}
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
class WorkspaceExportView(IdempotencyMixin, APIView):
|
|
1328
|
+
"""GET lists recent export jobs; POST queues a new full export (#641). Owner only."""
|
|
1329
|
+
|
|
1330
|
+
permission_classes = [IsAuthenticated, IsWorkspaceOwner]
|
|
1331
|
+
# Standard page-number envelope so the job list matches every other list
|
|
1332
|
+
# endpoint's {count,next,previous,results} shape — the previous bare ``[:20]``
|
|
1333
|
+
# slice gave an integrator no way to tell a truncated list from a complete one
|
|
1334
|
+
# (#1355). Bounded in practice by the export-retention purge.
|
|
1335
|
+
pagination_class = PageNumberPagination
|
|
1336
|
+
|
|
1337
|
+
@extend_schema(responses={200: WorkspaceExportJobSerializer(many=True)})
|
|
1338
|
+
def get(self, request: Request) -> Response:
|
|
1339
|
+
jobs = WorkspaceExportJob.objects.all()
|
|
1340
|
+
paginator = self.pagination_class()
|
|
1341
|
+
page = paginator.paginate_queryset(jobs, request, view=self)
|
|
1342
|
+
data = WorkspaceExportJobSerializer(page if page is not None else jobs, many=True).data
|
|
1343
|
+
return paginator.get_paginated_response(data)
|
|
1344
|
+
|
|
1345
|
+
@extend_schema(request=None, responses={202: WorkspaceExportJobSerializer})
|
|
1346
|
+
def post(self, request: Request) -> Response:
|
|
1347
|
+
job = services.enqueue_workspace_export(requested_by=request.user)
|
|
1348
|
+
return Response(WorkspaceExportJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
class WorkspaceExportDetailView(APIView):
|
|
1352
|
+
"""GET /api/v1/workspace/export/{job_id}/ — poll an export's status (#641). Owner only."""
|
|
1353
|
+
|
|
1354
|
+
permission_classes = [IsAuthenticated, IsWorkspaceOwner]
|
|
1355
|
+
|
|
1356
|
+
@extend_schema(responses={200: WorkspaceExportJobSerializer})
|
|
1357
|
+
def get(self, request: Request, job_id: str) -> Response:
|
|
1358
|
+
job = get_object_or_404(WorkspaceExportJob, pk=job_id)
|
|
1359
|
+
return Response(WorkspaceExportJobSerializer(job).data)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
class WorkspaceExportDownloadView(APIView):
|
|
1363
|
+
"""GET /api/v1/workspace/export/{job_id}/download/ — stream the archive (#641).
|
|
1364
|
+
|
|
1365
|
+
Owner only and authenticated — the archive contains every project's data, so it
|
|
1366
|
+
is never served from a raw, unauthenticated storage URL. ``409`` if not ready,
|
|
1367
|
+
``410 Gone`` once the link has expired.
|
|
1368
|
+
"""
|
|
1369
|
+
|
|
1370
|
+
permission_classes = [IsAuthenticated, IsWorkspaceOwner]
|
|
1371
|
+
|
|
1372
|
+
@extend_schema(
|
|
1373
|
+
responses={
|
|
1374
|
+
200: OpenApiResponse(description="The archive bytes (``application/gzip``)."),
|
|
1375
|
+
409: OpenApiResponse(description="Export is not ready yet (still queued/running)."),
|
|
1376
|
+
410: OpenApiResponse(description="This export has expired. Request a new one."),
|
|
1377
|
+
},
|
|
1378
|
+
)
|
|
1379
|
+
def get(self, request: Request, job_id: str) -> Any:
|
|
1380
|
+
job = get_object_or_404(WorkspaceExportJob, pk=job_id)
|
|
1381
|
+
return stream_export_job_or_error(
|
|
1382
|
+
job,
|
|
1383
|
+
success_status=ExportJobStatus.SUCCESS,
|
|
1384
|
+
filename=f"workspace-export-{job.id}.tar.gz",
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
class AuditEventCursorPagination(CursorPagination):
|
|
1389
|
+
"""Stable, depth-independent pagination for the unbounded audit log (ADR-0157).
|
|
1390
|
+
|
|
1391
|
+
Cursor (not page-number) pagination because the log grows without bound and is
|
|
1392
|
+
appended to concurrently — a cursor keyed on ``created_at`` stays correct under
|
|
1393
|
+
inserts and is O(1) regardless of how deep the reader scrolls.
|
|
1394
|
+
"""
|
|
1395
|
+
|
|
1396
|
+
ordering = "-created_at"
|
|
1397
|
+
page_size = 50
|
|
1398
|
+
page_size_query_param = "page_size"
|
|
1399
|
+
max_page_size = 200
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _parse_audit_bound(value: str) -> Any:
|
|
1403
|
+
"""Parse a ``since``/``until`` filter into an aware datetime, or raise 400.
|
|
1404
|
+
|
|
1405
|
+
Accepts an ISO-8601 datetime or a bare date (interpreted as midnight). A naive
|
|
1406
|
+
value is made timezone-aware in the active timezone.
|
|
1407
|
+
"""
|
|
1408
|
+
import datetime as _datetime
|
|
1409
|
+
|
|
1410
|
+
parsed = parse_datetime(value)
|
|
1411
|
+
if parsed is None:
|
|
1412
|
+
as_date = parse_date(value)
|
|
1413
|
+
if as_date is None:
|
|
1414
|
+
raise ValidationError({"detail": f"Invalid date/time value: {value!r}."})
|
|
1415
|
+
parsed = _datetime.datetime.combine(as_date, _datetime.time.min)
|
|
1416
|
+
if timezone.is_naive(parsed):
|
|
1417
|
+
parsed = timezone.make_aware(parsed)
|
|
1418
|
+
return parsed
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
class WorkspaceAuditEventListView(APIView):
|
|
1422
|
+
"""GET /api/v1/workspace/audit-events/ — operational audit log (ADR-0157, #859).
|
|
1423
|
+
|
|
1424
|
+
Owner/Admin only (``IsWorkspaceAuditViewer``). Cursor-paginated, newest first.
|
|
1425
|
+
Optional filters, all ANDed: ``?event_type=`` (an ``AuditEventType`` value),
|
|
1426
|
+
``?actor=`` (user id), ``?since=`` / ``?until=`` (ISO-8601 on ``created_at``).
|
|
1427
|
+
"""
|
|
1428
|
+
|
|
1429
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAuditViewer]
|
|
1430
|
+
|
|
1431
|
+
@extend_schema(
|
|
1432
|
+
parameters=[
|
|
1433
|
+
OpenApiParameter(
|
|
1434
|
+
"event_type",
|
|
1435
|
+
str,
|
|
1436
|
+
description="Filter to a single AuditEventType value.",
|
|
1437
|
+
),
|
|
1438
|
+
OpenApiParameter("actor", int, description="Filter by actor user id."),
|
|
1439
|
+
OpenApiParameter(
|
|
1440
|
+
"since", str, description="Only events at/after this ISO-8601 instant."
|
|
1441
|
+
),
|
|
1442
|
+
OpenApiParameter(
|
|
1443
|
+
"until", str, description="Only events at/before this ISO-8601 instant."
|
|
1444
|
+
),
|
|
1445
|
+
],
|
|
1446
|
+
# The handler paginates (``AuditEventCursorPagination.get_paginated_response``),
|
|
1447
|
+
# so the 200 body is a cursor envelope, NOT a bare array. drf-spectacular's
|
|
1448
|
+
# auto-wrap only fires for a view with a class-level ``pagination_class``;
|
|
1449
|
+
# this is a plain APIView that instantiates its paginator inline, so the
|
|
1450
|
+
# heuristic never saw it and ``AuditEventSerializer(many=True)`` published a
|
|
1451
|
+
# ``type: array`` no response can satisfy — a generated SDK iterating the
|
|
1452
|
+
# result broke on the first call (#2583). Declare the envelope explicitly.
|
|
1453
|
+
# Cursor pagination carries no ``count`` (that is the point of a cursor).
|
|
1454
|
+
responses={
|
|
1455
|
+
200: inline_serializer(
|
|
1456
|
+
name="AuditEventCursorPage",
|
|
1457
|
+
fields={
|
|
1458
|
+
"next": serializers.URLField(allow_null=True),
|
|
1459
|
+
"previous": serializers.URLField(allow_null=True),
|
|
1460
|
+
"results": AuditEventSerializer(many=True),
|
|
1461
|
+
},
|
|
1462
|
+
)
|
|
1463
|
+
},
|
|
1464
|
+
# Pinned: drf-spectacular derives the operationId's `_list`/`_retrieve`
|
|
1465
|
+
# suffix from whether the 200 is a ``many=True`` serializer, so replacing
|
|
1466
|
+
# the (wrong) array with the envelope silently renamed
|
|
1467
|
+
# ``v1_workspace_audit_events_list`` to ``…_retrieve``. operationId is the
|
|
1468
|
+
# method name in a generated client, so that rename would break every
|
|
1469
|
+
# existing caller — trading the bug this fixes for the same bug in a new
|
|
1470
|
+
# place. Also keeps the summary honest: this lists events, it does not
|
|
1471
|
+
# retrieve one.
|
|
1472
|
+
operation_id="v1_workspace_audit_events_list",
|
|
1473
|
+
summary="List workspace audit events",
|
|
1474
|
+
)
|
|
1475
|
+
def get(self, request: Request) -> Response:
|
|
1476
|
+
qs = AuditEvent.objects.select_related("actor").all()
|
|
1477
|
+
|
|
1478
|
+
event_type = request.query_params.get("event_type")
|
|
1479
|
+
if event_type:
|
|
1480
|
+
if event_type not in AuditEventType.values:
|
|
1481
|
+
raise ValidationError({"detail": f"Unknown event_type: {event_type!r}."})
|
|
1482
|
+
qs = qs.filter(event_type=event_type)
|
|
1483
|
+
|
|
1484
|
+
actor = request.query_params.get("actor")
|
|
1485
|
+
if actor:
|
|
1486
|
+
try:
|
|
1487
|
+
actor_id = int(actor)
|
|
1488
|
+
except (TypeError, ValueError) as exc:
|
|
1489
|
+
raise ValidationError({"detail": "actor must be an integer user id."}) from exc
|
|
1490
|
+
qs = qs.filter(actor_id=actor_id)
|
|
1491
|
+
|
|
1492
|
+
since = request.query_params.get("since")
|
|
1493
|
+
if since:
|
|
1494
|
+
qs = qs.filter(created_at__gte=_parse_audit_bound(since))
|
|
1495
|
+
until = request.query_params.get("until")
|
|
1496
|
+
if until:
|
|
1497
|
+
qs = qs.filter(created_at__lte=_parse_audit_bound(until))
|
|
1498
|
+
|
|
1499
|
+
paginator = AuditEventCursorPagination()
|
|
1500
|
+
page = paginator.paginate_queryset(qs, request, view=self)
|
|
1501
|
+
data = AuditEventSerializer(page, many=True).data
|
|
1502
|
+
return paginator.get_paginated_response(data)
|