trueppm-api 0.4.0__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 +27 -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 +346 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -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_squashed_0019_release_0_4.py +302 -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 +143 -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/minter_role.py +38 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +3263 -0
- trueppm_api/apps/access/serializers.py +1007 -0
- trueppm_api/apps/access/services.py +878 -0
- trueppm_api/apps/access/signals.py +171 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +96 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1965 -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/0001_squashed_0003_release_0_4.py +344 -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 +109 -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/0001_squashed_0003_release_0_4.py +90 -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 +459 -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 +178 -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 +863 -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 +597 -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_squashed_0014_release_0_4.py +225 -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/0014_tasklink_created_by.py +27 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +635 -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 +528 -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 +734 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1158 -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 +280 -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 +551 -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 +1276 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +439 -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 +462 -0
- trueppm_api/apps/notifications/email_backend.py +350 -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 +299 -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_squashed_0011_release_0_4.py +204 -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 +1148 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +1094 -0
- trueppm_api/apps/notifications/services.py +1489 -0
- trueppm_api/apps/notifications/tasks.py +762 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +919 -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 +566 -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 +993 -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 +537 -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_squashed_0009_release_0_4.py +128 -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 +541 -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 +458 -0
- trueppm_api/apps/projects/backfill.py +459 -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 +212 -0
- trueppm_api/apps/projects/config_notice.py +1330 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +132 -0
- trueppm_api/apps/projects/decisions_views.py +97 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +423 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -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/key_views.py +468 -0
- trueppm_api/apps/projects/keys.py +205 -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 +514 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -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/migration_helpers.py +75 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -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 +59 -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 +75 -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 +76 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -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/0154_object_keys.py +184 -0
- trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
- trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
- trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +9209 -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 +903 -0
- trueppm_api/apps/projects/program_schedule.py +767 -0
- trueppm_api/apps/projects/program_views.py +3445 -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 +492 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
- trueppm_api/apps/projects/seed/exporter.py +1717 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2764 -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 +128 -0
- trueppm_api/apps/projects/seed/replay.py +1695 -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 +1169 -0
- trueppm_api/apps/projects/serializers.py +12904 -0
- trueppm_api/apps/projects/services.py +7525 -0
- trueppm_api/apps/projects/share_serializers.py +142 -0
- trueppm_api/apps/projects/share_services.py +373 -0
- trueppm_api/apps/projects/share_views.py +395 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +910 -0
- trueppm_api/apps/projects/signal_privacy_views.py +485 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +288 -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 +1244 -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 +1979 -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 +261 -0
- trueppm_api/apps/projects/template_views.py +829 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1016 -0
- trueppm_api/apps/projects/utilization.py +909 -0
- trueppm_api/apps/projects/views.py +21225 -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/0014_squashed_0015_release_0_4.py +47 -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 +1752 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +144 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/finish_reading.py +198 -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/mc_admission.py +181 -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/0010_squashed_0016_release_0_4.py +106 -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/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +542 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +334 -0
- trueppm_api/apps/scheduling/serializers.py +723 -0
- trueppm_api/apps/scheduling/services.py +1251 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +2248 -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 +2908 -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 +446 -0
- trueppm_api/apps/sso/services.py +1473 -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 +369 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +548 -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/0003_squashed_0005_release_0_4.py +77 -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/0006_boardevent_per_project_seq.py +77 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +272 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +488 -0
- trueppm_api/apps/sync/serializers.py +688 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +556 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +957 -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 +159 -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/0004_backfill_default_teams_on_upgrade.py +49 -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 +440 -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/0001_squashed_0004_release_0_4.py +167 -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 +211 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +597 -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 +279 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -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 +100 -0
- trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
- trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +437 -0
- trueppm_api/apps/webhooks/serializers.py +326 -0
- trueppm_api/apps/webhooks/tasks.py +680 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +533 -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 +371 -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 +254 -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_squashed_0027_release_0_4.py +199 -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 +967 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +616 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1581 -0
- trueppm_api/asgi.py +49 -0
- trueppm_api/celery.py +40 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +934 -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/demo_read_only.py +314 -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 +1221 -0
- trueppm_api/core/password_reset.py +513 -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 +1034 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +514 -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/core/worker_selfheal.py +205 -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 +2605 -0
- trueppm_api/settings/dev.py +296 -0
- trueppm_api/settings/prod.py +265 -0
- trueppm_api/urls.py +217 -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.0.dist-info/METADATA +273 -0
- trueppm_api-0.4.0.dist-info/RECORD +799 -0
- trueppm_api-0.4.0.dist-info/WHEEL +4 -0
- trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
|
@@ -0,0 +1,1752 @@
|
|
|
1
|
+
"""DRF ViewSets for the resources app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from django.db import models, transaction
|
|
10
|
+
from django.db.models import DateField, ProtectedError, QuerySet
|
|
11
|
+
from django.db.models.functions import Coalesce
|
|
12
|
+
from drf_spectacular.types import OpenApiTypes
|
|
13
|
+
from drf_spectacular.utils import (
|
|
14
|
+
OpenApiParameter,
|
|
15
|
+
OpenApiResponse,
|
|
16
|
+
extend_schema,
|
|
17
|
+
extend_schema_view,
|
|
18
|
+
)
|
|
19
|
+
from rest_framework import filters, status, viewsets
|
|
20
|
+
from rest_framework.decorators import action
|
|
21
|
+
from rest_framework.exceptions import PermissionDenied, ValidationError
|
|
22
|
+
from rest_framework.permissions import BasePermission, IsAuthenticated
|
|
23
|
+
from rest_framework.request import Request
|
|
24
|
+
from rest_framework.response import Response
|
|
25
|
+
from rest_framework.serializers import BaseSerializer
|
|
26
|
+
from rest_framework.throttling import UserRateThrottle
|
|
27
|
+
|
|
28
|
+
from trueppm_api.apps.access.models import ProjectMembership, Role
|
|
29
|
+
from trueppm_api.apps.access.permissions import (
|
|
30
|
+
CanAssignResource,
|
|
31
|
+
IsOrgAdmin,
|
|
32
|
+
IsOrgScheduler,
|
|
33
|
+
IsProjectMember,
|
|
34
|
+
IsProjectNotArchived,
|
|
35
|
+
ProjectScopedViewSet,
|
|
36
|
+
_membership_role,
|
|
37
|
+
assert_project_not_archived,
|
|
38
|
+
)
|
|
39
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
40
|
+
from trueppm_api.apps.projects.models import Project, Task, TaskActivityEventType
|
|
41
|
+
from trueppm_api.apps.projects.utilization import (
|
|
42
|
+
Allocation,
|
|
43
|
+
peak_concurrent_units,
|
|
44
|
+
resolve_working_calendar,
|
|
45
|
+
)
|
|
46
|
+
from trueppm_api.apps.resources.capacity import resource_effective_units
|
|
47
|
+
from trueppm_api.apps.resources.models import (
|
|
48
|
+
Proficiency,
|
|
49
|
+
ProjectResource,
|
|
50
|
+
Resource,
|
|
51
|
+
ResourceSkill,
|
|
52
|
+
Skill,
|
|
53
|
+
TaskResource,
|
|
54
|
+
TaskSkillRequirement,
|
|
55
|
+
)
|
|
56
|
+
from trueppm_api.apps.resources.serializers import (
|
|
57
|
+
ProjectResourceSerializer,
|
|
58
|
+
ResourceAssignmentSerializer,
|
|
59
|
+
ResourceSerializer,
|
|
60
|
+
ResourceSkillSerializer,
|
|
61
|
+
SkillSerializer,
|
|
62
|
+
TaskResourceSerializer,
|
|
63
|
+
TaskSkillRequirementSerializer,
|
|
64
|
+
)
|
|
65
|
+
from trueppm_api.apps.resources.services import (
|
|
66
|
+
ensure_project_resource,
|
|
67
|
+
record_assignment_event,
|
|
68
|
+
task_is_summary,
|
|
69
|
+
)
|
|
70
|
+
from trueppm_api.apps.scheduling.services import enqueue_recalculate as _enqueue_recalculate
|
|
71
|
+
from trueppm_api.apps.workspace.permissions import IsWorkspaceAdminStrict, is_workspace_admin
|
|
72
|
+
from trueppm_api.core.protect_conflict import protected_error_response
|
|
73
|
+
|
|
74
|
+
# Shared 404 detail — matches DRF's default NotFound detail so a missing or
|
|
75
|
+
# soft-deleted lookup reads identically across the resource actions below.
|
|
76
|
+
_NOT_FOUND_DETAIL = "Not found."
|
|
77
|
+
|
|
78
|
+
#: Refusal shipped when a skill is still tagged on a resource or required by a task.
|
|
79
|
+
#: Both FKs into ``Skill`` are ``on_delete=PROTECT`` (``ResourceSkill.skill``,
|
|
80
|
+
#: ``TaskSkillRequirement.skill``) so deleting a catalog entry cannot silently strip
|
|
81
|
+
#: the skill from every resource that carries it or every task that requires it.
|
|
82
|
+
#: Django reports the refusal as ``ProtectedError``, which is not an ``APIException``
|
|
83
|
+
#: and escaped as an unhandled 500 until #2364.
|
|
84
|
+
_SKILL_IN_USE_DETAIL = "This skill is still in use and cannot be deleted."
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Skill catalog
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _skill_reference_describer(user: Any) -> Callable[[models.Model], dict[str, str]]:
|
|
92
|
+
"""Build a describer that names a skill's blockers without leaking task names.
|
|
93
|
+
|
|
94
|
+
``ResourceSkill`` and ``TaskSkillRequirement`` are join tables the user never
|
|
95
|
+
sees and which have no ``name`` of their own; echoing them back would produce a
|
|
96
|
+
list of opaque UUIDs. What the caller can act on is the resource to retag or the
|
|
97
|
+
task whose requirement to drop.
|
|
98
|
+
|
|
99
|
+
**Task names are membership-scoped, and this endpoint's gate is not.**
|
|
100
|
+
``IsOrgScheduler`` only proves the caller holds SCHEDULER+ on *some* project, so
|
|
101
|
+
a naive describer would let anyone with one project turn repeated delete attempts
|
|
102
|
+
into an oracle for task names across every project in the install — and a task
|
|
103
|
+
name describes actual work, unlike a resource name. Tasks in projects the caller
|
|
104
|
+
is not a member of are therefore reported by type and id only, with the name
|
|
105
|
+
withheld; ``reference_count`` still reflects the true total, so the refusal stays
|
|
106
|
+
honest about how much is blocking even when it cannot say what.
|
|
107
|
+
|
|
108
|
+
Resource names are *not* filtered: the resource catalog is org-shared and already
|
|
109
|
+
readable by any authenticated user (see :class:`ResourceViewSet`), so naming one
|
|
110
|
+
here discloses nothing the caller could not list directly.
|
|
111
|
+
"""
|
|
112
|
+
# project__is_deleted (#4577): a trashed project confers no read, so its names
|
|
113
|
+
# are withheld from the refusal like any other project the caller cannot see.
|
|
114
|
+
visible_project_ids = set(
|
|
115
|
+
ProjectMembership.objects.filter(
|
|
116
|
+
user=user, is_deleted=False, project__is_deleted=False
|
|
117
|
+
).values_list("project_id", flat=True)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def describe(obj: models.Model) -> dict[str, str]:
|
|
121
|
+
if isinstance(obj, ResourceSkill):
|
|
122
|
+
return {"type": "resource", "id": str(obj.resource_id), "name": obj.resource.name}
|
|
123
|
+
if isinstance(obj, TaskSkillRequirement):
|
|
124
|
+
entry = {"type": "task", "id": str(obj.task_id)}
|
|
125
|
+
if obj.task.project_id in visible_project_ids:
|
|
126
|
+
entry["name"] = obj.task.name
|
|
127
|
+
return entry
|
|
128
|
+
return {"type": obj._meta.model_name or "", "id": str(obj.pk), "name": str(obj)}
|
|
129
|
+
|
|
130
|
+
return describe
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@extend_schema_view(
|
|
134
|
+
create=extend_schema(
|
|
135
|
+
summary="Create a skill, or return the existing one",
|
|
136
|
+
description=(
|
|
137
|
+
"Adds a skill to the org-level catalog.\n\n"
|
|
138
|
+
"Names are de-duplicated case-insensitively on `normalized_name`, so "
|
|
139
|
+
"posting a name that already exists is not an error: the existing row is "
|
|
140
|
+
"returned with `200` instead of `201`. Callers that need to know whether "
|
|
141
|
+
"they added the skill must read the status code — the body is identical "
|
|
142
|
+
"either way."
|
|
143
|
+
),
|
|
144
|
+
responses={
|
|
145
|
+
200: OpenApiResponse(
|
|
146
|
+
response=SkillSerializer,
|
|
147
|
+
description=(
|
|
148
|
+
"A skill with this normalized name already existed; it is returned unchanged."
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
201: OpenApiResponse(
|
|
152
|
+
response=SkillSerializer,
|
|
153
|
+
description="Skill created.",
|
|
154
|
+
),
|
|
155
|
+
},
|
|
156
|
+
),
|
|
157
|
+
destroy=extend_schema(
|
|
158
|
+
summary="Delete a skill",
|
|
159
|
+
description=(
|
|
160
|
+
"Deletes a skill from the org-level catalog.\n\n"
|
|
161
|
+
"A skill that is still tagged on a resource or required by a task cannot "
|
|
162
|
+
"be deleted — removing it would silently strip the tag from every resource "
|
|
163
|
+
"that carries it and drop the requirement from every task that needs it. "
|
|
164
|
+
"That is refused with `409`, and the body names the resources and tasks "
|
|
165
|
+
"still using it so the caller can detach them first."
|
|
166
|
+
),
|
|
167
|
+
responses={
|
|
168
|
+
204: OpenApiResponse(description="Skill deleted."),
|
|
169
|
+
409: OpenApiResponse(
|
|
170
|
+
response=OpenApiTypes.OBJECT,
|
|
171
|
+
description=(
|
|
172
|
+
"Skill is still tagged on a resource or required by a task. Body "
|
|
173
|
+
"carries `reference_count` and a `references` sample naming the "
|
|
174
|
+
"resources and tasks to detach first."
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
},
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
class SkillViewSet(IdempotencyMixin, viewsets.ModelViewSet[Skill]):
|
|
181
|
+
"""CRUD for the org-level skill catalog.
|
|
182
|
+
|
|
183
|
+
Any authenticated user may read. Writes require ``SCHEDULER+`` on at least
|
|
184
|
+
one project (#254 — IsProjectMember alone would not gate writes here
|
|
185
|
+
because the route is not project-nested). Skill creation normalises the
|
|
186
|
+
name and returns the existing row if the normalised name already exists
|
|
187
|
+
(de-dup by normalized_name unique constraint).
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
queryset = Skill.objects.filter(is_deleted=False).order_by("name")
|
|
191
|
+
serializer_class = SkillSerializer
|
|
192
|
+
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
|
193
|
+
search_fields = ["name", "category"]
|
|
194
|
+
ordering_fields = ["name"]
|
|
195
|
+
|
|
196
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
197
|
+
"""Read open to any authenticated user; writes require SCHEDULER+ on any project."""
|
|
198
|
+
if self.request.method in ("GET", "HEAD", "OPTIONS"):
|
|
199
|
+
return [IsAuthenticated()]
|
|
200
|
+
return [IsAuthenticated(), IsOrgScheduler()]
|
|
201
|
+
|
|
202
|
+
def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
203
|
+
"""Delete the skill, or 409 when a resource or task still uses it.
|
|
204
|
+
|
|
205
|
+
Overrides ``destroy`` rather than ``perform_destroy`` because the refusal body
|
|
206
|
+
carries an integer ``reference_count``, and DRF coerces every value in an
|
|
207
|
+
``APIException`` detail to ``ErrorDetail`` (a ``str`` subclass) — raising would
|
|
208
|
+
ship the count as ``"3"``. Returning is safe: ``ProtectedError`` is raised
|
|
209
|
+
while the collector walks the FKs, *before* any DELETE is issued, so there is
|
|
210
|
+
no partial state for ``ATOMIC_REQUESTS`` to roll back.
|
|
211
|
+
"""
|
|
212
|
+
instance = self.get_object()
|
|
213
|
+
try:
|
|
214
|
+
instance.delete()
|
|
215
|
+
except ProtectedError as exc:
|
|
216
|
+
return protected_error_response(
|
|
217
|
+
exc.protected_objects,
|
|
218
|
+
detail=_SKILL_IN_USE_DETAIL,
|
|
219
|
+
code="skill_in_use",
|
|
220
|
+
describe=_skill_reference_describer(request.user),
|
|
221
|
+
)
|
|
222
|
+
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
223
|
+
|
|
224
|
+
def create(self, request: Request, *args: object, **kwargs: object) -> Response:
|
|
225
|
+
"""Create the skill (201), or return the existing row on a de-dup hit (200).
|
|
226
|
+
|
|
227
|
+
The status comes from the ``get_or_create`` flag the serializer records,
|
|
228
|
+
not from an inspection of the saved row. Nothing on a saved ``Skill``
|
|
229
|
+
distinguishes a fresh insert from a de-dup hit, so any re-derived flag is
|
|
230
|
+
a guess: this used to probe ``server_version=0``, which
|
|
231
|
+
``VersionedModel.save`` never leaves behind (it stamps 1 on insert), so
|
|
232
|
+
the endpoint answered 200 for every create (#3573).
|
|
233
|
+
"""
|
|
234
|
+
# ``get_serializer`` is typed as ``BaseSerializer[Skill]``; this viewset
|
|
235
|
+
# never swaps ``serializer_class``, so the concrete type is exact.
|
|
236
|
+
serializer = cast("SkillSerializer", self.get_serializer(data=request.data))
|
|
237
|
+
serializer.is_valid(raise_exception=True)
|
|
238
|
+
skill = serializer.save()
|
|
239
|
+
http_status = status.HTTP_201_CREATED if serializer.created else status.HTTP_200_OK
|
|
240
|
+
return Response(
|
|
241
|
+
self.get_serializer(skill).data,
|
|
242
|
+
status=http_status,
|
|
243
|
+
headers=self.get_success_headers(serializer.data),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Resource skills
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@extend_schema_view(
|
|
253
|
+
list=extend_schema(
|
|
254
|
+
parameters=[
|
|
255
|
+
OpenApiParameter(
|
|
256
|
+
name="resource",
|
|
257
|
+
type=OpenApiTypes.UUID,
|
|
258
|
+
location=OpenApiParameter.QUERY,
|
|
259
|
+
required=False,
|
|
260
|
+
description="Filter to skill tags for this resource.",
|
|
261
|
+
),
|
|
262
|
+
],
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
class ResourceSkillViewSet(IdempotencyMixin, viewsets.ModelViewSet[ResourceSkill]):
|
|
266
|
+
"""CRUD for skill tags on resources.
|
|
267
|
+
|
|
268
|
+
Read: any authenticated user (resource catalog is org-shared).
|
|
269
|
+
Write: SCHEDULER+ on at least one active project (``IsOrgScheduler``). The
|
|
270
|
+
route is not project-nested so ``IsProjectMember`` alone would not gate writes.
|
|
271
|
+
The docstring here claimed an ADMIN+ floor via ``IsOrgAdmin``; the code has
|
|
272
|
+
returned ``IsOrgScheduler`` since #254. Corrected to the code, not the reverse
|
|
273
|
+
— skill tagging is curation, which is what the SCHEDULER floor is for.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
serializer_class = ResourceSkillSerializer
|
|
277
|
+
filter_backends = [filters.OrderingFilter]
|
|
278
|
+
queryset = ResourceSkill.objects.active().select_related("skill").filter(is_deleted=False)
|
|
279
|
+
|
|
280
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
281
|
+
if self.request.method in ("GET", "HEAD", "OPTIONS"):
|
|
282
|
+
return [IsAuthenticated()]
|
|
283
|
+
return [IsAuthenticated(), IsOrgScheduler()]
|
|
284
|
+
|
|
285
|
+
def get_queryset(self) -> QuerySet[ResourceSkill]:
|
|
286
|
+
# ``.active()`` (#3572): the resource row itself is admin-only once
|
|
287
|
+
# deactivated (``?include_deleted=true`` on the catalog), so listing its
|
|
288
|
+
# skill tags to every authenticated user leaked the tail of a record the
|
|
289
|
+
# catalog had already withdrawn. Admins reach a deactivated resource's
|
|
290
|
+
# skills through the catalog's expanded ``skills`` instead.
|
|
291
|
+
qs = ResourceSkill.objects.active().select_related("skill").filter(is_deleted=False)
|
|
292
|
+
resource_id = self.request.query_params.get("resource")
|
|
293
|
+
if resource_id:
|
|
294
|
+
qs = qs.filter(resource_id=resource_id)
|
|
295
|
+
return qs
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Project roster
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@extend_schema_view(
|
|
304
|
+
list=extend_schema(
|
|
305
|
+
parameters=[
|
|
306
|
+
OpenApiParameter(
|
|
307
|
+
name="project",
|
|
308
|
+
type=OpenApiTypes.UUID,
|
|
309
|
+
location=OpenApiParameter.QUERY,
|
|
310
|
+
required=False,
|
|
311
|
+
description="Filter the roster to this project.",
|
|
312
|
+
),
|
|
313
|
+
],
|
|
314
|
+
),
|
|
315
|
+
create=extend_schema(
|
|
316
|
+
responses={
|
|
317
|
+
201: ProjectResourceSerializer,
|
|
318
|
+
403: OpenApiResponse(
|
|
319
|
+
description=(
|
|
320
|
+
"Caller lacks the Resource Manager role on the target project, or that "
|
|
321
|
+
"project is archived (#3414). The project is named only in the request "
|
|
322
|
+
"body here, so the refusal comes from the view rather than a permission "
|
|
323
|
+
"class — the status and body are the same either way."
|
|
324
|
+
)
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
),
|
|
328
|
+
destroy=extend_schema(
|
|
329
|
+
parameters=[
|
|
330
|
+
OpenApiParameter(
|
|
331
|
+
name="force",
|
|
332
|
+
type=OpenApiTypes.BOOL,
|
|
333
|
+
location=OpenApiParameter.QUERY,
|
|
334
|
+
required=False,
|
|
335
|
+
description=(
|
|
336
|
+
"When 'true', cascade-delete the resource's TaskResource rows on "
|
|
337
|
+
"this project and trigger a CPM recalculation. Without it, a 409 "
|
|
338
|
+
"is returned if the resource has live task assignments."
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
],
|
|
342
|
+
responses={
|
|
343
|
+
200: OpenApiResponse(
|
|
344
|
+
response=OpenApiTypes.OBJECT,
|
|
345
|
+
description=(
|
|
346
|
+
"Resource removed from the roster. Body carries "
|
|
347
|
+
"`cascaded_assignment_count` — the number of TaskResource rows "
|
|
348
|
+
"severed, `0` when the resource had no live assignments."
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
409: OpenApiResponse(
|
|
352
|
+
response=OpenApiTypes.OBJECT,
|
|
353
|
+
description=(
|
|
354
|
+
"The resource still has live task assignments on this project and "
|
|
355
|
+
"`force=true` was not passed. Body carries `code: "
|
|
356
|
+
'"roster_has_assignments"`, `affected_tasks`, `task_names`, and '
|
|
357
|
+
"`assignment_count`. Distinct from the task-restructure "
|
|
358
|
+
"`has_assignments` warning documented on the errors page — this is "
|
|
359
|
+
"the roster-removal refusal."
|
|
360
|
+
),
|
|
361
|
+
),
|
|
362
|
+
},
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
class ProjectResourceViewSet(ProjectScopedViewSet, viewsets.ModelViewSet[ProjectResource]):
|
|
366
|
+
"""CRUD for a project's resource roster.
|
|
367
|
+
|
|
368
|
+
Read: any project member (VIEWER+).
|
|
369
|
+
Write: SCHEDULER+ on the project.
|
|
370
|
+
Delete: SCHEDULER+; with ?force=true cascades to TaskResource rows and
|
|
371
|
+
triggers CPM recalculation (ADR-0027). Without force=true returns 409 if
|
|
372
|
+
live task assignments exist.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
permission_classes = [IsAuthenticated, IsProjectMember, CanAssignResource, IsProjectNotArchived]
|
|
376
|
+
resolves_scope_in_body = {
|
|
377
|
+
"create": (
|
|
378
|
+
"Flat route: the project arrives in the body, so `CanAssignResource."
|
|
379
|
+
"has_permission` cannot scope the write and `has_object_permission` does "
|
|
380
|
+
"not run on a create. `perform_create` resolves the body's project and "
|
|
381
|
+
"applies the same Scheduler+ floor before saving (#3767)."
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
serializer_class = ProjectResourceSerializer
|
|
385
|
+
filter_backends = [filters.OrderingFilter]
|
|
386
|
+
queryset = (
|
|
387
|
+
ProjectResource.objects.active()
|
|
388
|
+
.select_related("resource", "resource__calendar")
|
|
389
|
+
.prefetch_related("resource__skills__skill")
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def get_queryset(self) -> QuerySet[ProjectResource]:
|
|
393
|
+
user_pk = self.request.user.pk
|
|
394
|
+
assert user_pk is not None
|
|
395
|
+
member_project_ids = ProjectMembership.objects.filter(
|
|
396
|
+
user_id=user_pk, is_deleted=False, project__is_deleted=False
|
|
397
|
+
).values_list("project_id", flat=True)
|
|
398
|
+
# ``.active()`` (#3572) is ``is_deleted=False`` on the roster row AND on the
|
|
399
|
+
# resource behind it. The roster row is the surface an off-boarding is
|
|
400
|
+
# measured on: a deactivated person left the catalog but kept a full
|
|
401
|
+
# ``resource_detail`` on every project's Team tab.
|
|
402
|
+
qs = (
|
|
403
|
+
ProjectResource.objects.active()
|
|
404
|
+
.select_related("resource", "resource__calendar")
|
|
405
|
+
.prefetch_related("resource__skills__skill")
|
|
406
|
+
.filter(project_id__in=member_project_ids)
|
|
407
|
+
.order_by("resource__name")
|
|
408
|
+
)
|
|
409
|
+
project_id = self.request.query_params.get("project")
|
|
410
|
+
if project_id:
|
|
411
|
+
qs = qs.filter(project_id=project_id)
|
|
412
|
+
return qs
|
|
413
|
+
|
|
414
|
+
def perform_create(self, serializer: BaseSerializer[ProjectResource]) -> None:
|
|
415
|
+
"""Verify SCHEDULER+ role and a live project before adding to roster.
|
|
416
|
+
|
|
417
|
+
The archived check is here, not left to ``IsProjectNotArchived`` on the class,
|
|
418
|
+
because this route is top-level (``POST /api/v1/project-resources/``) and names
|
|
419
|
+
the project only in the request BODY (#3414). ``has_permission`` finds no
|
|
420
|
+
``project_pk`` kwarg and stands down, and DRF never calls
|
|
421
|
+
``has_object_permission`` on a create — there is no object yet. The declared
|
|
422
|
+
permission class therefore reads as gating in review and enforces nothing on
|
|
423
|
+
exactly the request that creates a row, which is how roster adds (and the CPM
|
|
424
|
+
recalculation they enqueue) reached archived projects.
|
|
425
|
+
"""
|
|
426
|
+
project = serializer.validated_data.get("project")
|
|
427
|
+
if project:
|
|
428
|
+
role = _membership_role(self.request, str(project.pk))
|
|
429
|
+
if role is None or role < Role.SCHEDULER:
|
|
430
|
+
raise PermissionDenied(
|
|
431
|
+
"You need at least Resource Manager role to manage the roster."
|
|
432
|
+
)
|
|
433
|
+
assert_project_not_archived(project.pk)
|
|
434
|
+
instance = serializer.save()
|
|
435
|
+
|
|
436
|
+
# Mirror the destroy() path: a roster add is invisible to connected
|
|
437
|
+
# clients until a refetch without this broadcast.
|
|
438
|
+
project_id = str(instance.project_id)
|
|
439
|
+
resource_id = str(instance.resource_id)
|
|
440
|
+
|
|
441
|
+
def _on_commit() -> None:
|
|
442
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
443
|
+
|
|
444
|
+
broadcast_board_event(
|
|
445
|
+
project_id,
|
|
446
|
+
"roster_changed",
|
|
447
|
+
{"resource_id": resource_id},
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
transaction.on_commit(_on_commit)
|
|
451
|
+
|
|
452
|
+
def perform_update(self, serializer: BaseSerializer[ProjectResource]) -> None:
|
|
453
|
+
"""Persist an allocation/notes edit and broadcast the roster change.
|
|
454
|
+
|
|
455
|
+
The SCHEDULER+ floor for detail mutations is enforced declaratively by
|
|
456
|
+
``CanAssignResource.has_object_permission``; this hook only adds the
|
|
457
|
+
missing real-time signal (#1359) so peers see an allocation or note
|
|
458
|
+
change without a manual refetch — mirroring perform_create/destroy.
|
|
459
|
+
"""
|
|
460
|
+
instance = serializer.save()
|
|
461
|
+
|
|
462
|
+
project_id = str(instance.project_id)
|
|
463
|
+
resource_id = str(instance.resource_id)
|
|
464
|
+
|
|
465
|
+
def _on_commit() -> None:
|
|
466
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
467
|
+
|
|
468
|
+
broadcast_board_event(
|
|
469
|
+
project_id,
|
|
470
|
+
"roster_changed",
|
|
471
|
+
{"resource_id": resource_id},
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
transaction.on_commit(_on_commit)
|
|
475
|
+
|
|
476
|
+
def destroy(self, request: Request, *args: object, **kwargs: object) -> Response:
|
|
477
|
+
"""Remove a resource from the roster.
|
|
478
|
+
|
|
479
|
+
If the resource has live TaskResource rows on this project and
|
|
480
|
+
force=true is not passed, returns 409 with code "roster_has_assignments".
|
|
481
|
+
With force=true, cascades the deletion and triggers CPM recalculation for
|
|
482
|
+
affected tasks. The code is distinct from the "has_assignments" 2xx
|
|
483
|
+
warning that task-restructure endpoints (indent/outdent/reparent/group)
|
|
484
|
+
return — that is an advisory on a write that succeeded; this is a hard
|
|
485
|
+
refusal of the roster removal itself.
|
|
486
|
+
"""
|
|
487
|
+
instance: ProjectResource = self.get_object()
|
|
488
|
+
project_id = str(instance.project_id)
|
|
489
|
+
resource_id = instance.resource_id
|
|
490
|
+
|
|
491
|
+
live_assignments = list(
|
|
492
|
+
TaskResource.objects.filter(
|
|
493
|
+
resource_id=resource_id,
|
|
494
|
+
task__project_id=project_id,
|
|
495
|
+
task__is_deleted=False,
|
|
496
|
+
).select_related("task")
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if live_assignments and request.query_params.get("force") != "true":
|
|
500
|
+
task_names = [a.task.name for a in live_assignments[:5]]
|
|
501
|
+
return Response(
|
|
502
|
+
{
|
|
503
|
+
"code": "roster_has_assignments",
|
|
504
|
+
"detail": (
|
|
505
|
+
f"{instance.resource.name} is assigned to "
|
|
506
|
+
f"{len(live_assignments)} task(s) in this project. "
|
|
507
|
+
f"Pass ?force=true to remove and unassign."
|
|
508
|
+
),
|
|
509
|
+
"affected_tasks": [
|
|
510
|
+
{"id": str(a.task_id), "name": a.task.name} for a in live_assignments[:10]
|
|
511
|
+
],
|
|
512
|
+
"task_names": task_names,
|
|
513
|
+
"assignment_count": len(live_assignments),
|
|
514
|
+
},
|
|
515
|
+
status=status.HTTP_409_CONFLICT,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
affected_task_ids = [str(a.task_id) for a in live_assignments]
|
|
519
|
+
|
|
520
|
+
# Cascade: delete TaskResource rows, then the ProjectResource row.
|
|
521
|
+
# Wrapped in atomic() so a mid-sequence failure cannot leave orphaned
|
|
522
|
+
# TaskResource rows without a corresponding ProjectResource (🔴-8).
|
|
523
|
+
with transaction.atomic():
|
|
524
|
+
TaskResource.objects.filter(
|
|
525
|
+
resource_id=resource_id,
|
|
526
|
+
task__project_id=project_id,
|
|
527
|
+
).delete()
|
|
528
|
+
instance.delete()
|
|
529
|
+
|
|
530
|
+
# The roster shrank regardless of whether the removed resource had any
|
|
531
|
+
# live assignments — a zero-assignment removal is the most common case
|
|
532
|
+
# (#1359). Broadcast roster_changed unconditionally so connected clients
|
|
533
|
+
# refetch the roster; only enqueue a CPM recalc when assignments were
|
|
534
|
+
# actually severed (no schedule input changed otherwise).
|
|
535
|
+
def _on_commit() -> None:
|
|
536
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
537
|
+
|
|
538
|
+
if affected_task_ids:
|
|
539
|
+
_enqueue_recalculate(project_id)
|
|
540
|
+
broadcast_board_event(
|
|
541
|
+
project_id,
|
|
542
|
+
"roster_changed",
|
|
543
|
+
{"resource_id": str(resource_id)},
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
transaction.on_commit(_on_commit)
|
|
547
|
+
|
|
548
|
+
return Response(
|
|
549
|
+
{
|
|
550
|
+
"detail": "Resource removed from project.",
|
|
551
|
+
"cascaded_assignment_count": len(live_assignments),
|
|
552
|
+
},
|
|
553
|
+
status=status.HTTP_200_OK,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
# Task skill requirements
|
|
559
|
+
# ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@extend_schema_view(
|
|
563
|
+
list=extend_schema(
|
|
564
|
+
parameters=[
|
|
565
|
+
OpenApiParameter(
|
|
566
|
+
name="task",
|
|
567
|
+
type=OpenApiTypes.UUID,
|
|
568
|
+
location=OpenApiParameter.QUERY,
|
|
569
|
+
required=False,
|
|
570
|
+
description="Filter to skill requirements for this task.",
|
|
571
|
+
),
|
|
572
|
+
],
|
|
573
|
+
),
|
|
574
|
+
create=extend_schema(
|
|
575
|
+
responses={
|
|
576
|
+
201: TaskSkillRequirementSerializer,
|
|
577
|
+
403: OpenApiResponse(
|
|
578
|
+
description=(
|
|
579
|
+
"Caller lacks the Resource Manager role on the target task's project, "
|
|
580
|
+
"or that project is archived (#3570). The project is reached only "
|
|
581
|
+
"through the body's task here, so the refusal comes from the view "
|
|
582
|
+
"rather than a permission class — the status and body are the same "
|
|
583
|
+
"either way."
|
|
584
|
+
)
|
|
585
|
+
),
|
|
586
|
+
},
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
class TaskSkillRequirementViewSet(IdempotencyMixin, viewsets.ModelViewSet[TaskSkillRequirement]):
|
|
590
|
+
"""CRUD for skill requirements on tasks.
|
|
591
|
+
|
|
592
|
+
Read: authenticated users scoped to their member projects.
|
|
593
|
+
Write: SCHEDULER+ **on the target task's own project**, and the target task's
|
|
594
|
+
project must not be archived — both enforced per-write by
|
|
595
|
+
:meth:`_require_scheduler_on_task`.
|
|
596
|
+
|
|
597
|
+
This surface is project-scoped, not org-global: every row belongs to exactly
|
|
598
|
+
one task, which belongs to exactly one project. It carried ``IsOrgScheduler``
|
|
599
|
+
as a class gate, which was strictly redundant — anyone passing the real
|
|
600
|
+
per-target check necessarily holds SCHEDULER+ on some project and so passed
|
|
601
|
+
the org gate too. Redundant is not free: it kept a project-scoped endpoint
|
|
602
|
+
inside the blast radius of the self-grantable org derivation (#3569), and it
|
|
603
|
+
made the endpoint's true rule harder to read. Dropped in favour of the check
|
|
604
|
+
that was already doing the work. The archived-project floor (#3570) is the
|
|
605
|
+
same story: enforced inline against the *target* task's project, not via a
|
|
606
|
+
class-level ``IsProjectNotArchived`` that could only ever check the object
|
|
607
|
+
DRF already resolved.
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
serializer_class = TaskSkillRequirementSerializer
|
|
611
|
+
filter_backends = [filters.OrderingFilter]
|
|
612
|
+
queryset = TaskSkillRequirement.objects.select_related("skill", "task").filter(is_deleted=False)
|
|
613
|
+
|
|
614
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
615
|
+
# Writes are authorized against the *target task's* project — both role
|
|
616
|
+
# and archived-state — in perform_create / perform_update /
|
|
617
|
+
# perform_destroy; see _require_scheduler_on_task. No class-level org or
|
|
618
|
+
# archived gate: either could only ever admit a superset of what that
|
|
619
|
+
# check admits.
|
|
620
|
+
return [IsAuthenticated()]
|
|
621
|
+
|
|
622
|
+
def _require_scheduler_on_task(self, task: Task) -> None:
|
|
623
|
+
# This is the *only* authorization on writes (#3569 removed the redundant
|
|
624
|
+
# IsOrgScheduler class gate above it). DRF never runs
|
|
625
|
+
# has_object_permission on create, so without a per-target check a
|
|
626
|
+
# SCHEDULER on project A could annotate a task in project B they've never
|
|
627
|
+
# joined (IDOR, #995). The membership-scoped get_queryset closes the
|
|
628
|
+
# non-member case on update/delete, but a low-role (Member/Viewer) member
|
|
629
|
+
# of the target project would still be a member — so the SCHEDULER floor
|
|
630
|
+
# is enforced on every write, against the *target task's* project.
|
|
631
|
+
role = _membership_role(self.request, str(task.project_id))
|
|
632
|
+
if role is None or role < Role.SCHEDULER:
|
|
633
|
+
# "Resource Manager" is the label for ``Role.SCHEDULER``; "Scheduler"
|
|
634
|
+
# is the code name and appears on no surface, so a refusal naming it
|
|
635
|
+
# sends the reader looking for a role that does not exist (#3503).
|
|
636
|
+
# Matches ``IsProjectScheduler.message``.
|
|
637
|
+
raise PermissionDenied(
|
|
638
|
+
"You need at least Resource Manager role on the target task's project."
|
|
639
|
+
)
|
|
640
|
+
# Archived is lifecycle state, not authority, so it is checked after the role
|
|
641
|
+
# floor and against the same target project. There is no class-level
|
|
642
|
+
# ``IsProjectNotArchived`` here (#3569 dropped the class-level org gate this
|
|
643
|
+
# method sits beside, for the same reason): this route is top-level, so on a
|
|
644
|
+
# create there is no object for ``has_object_permission`` to resolve, and on an
|
|
645
|
+
# update that repoints ``task`` the object DRF would check is the OLD row,
|
|
646
|
+
# whose project may be live while the destination is archived (#3570).
|
|
647
|
+
assert_project_not_archived(task.project_id)
|
|
648
|
+
|
|
649
|
+
def perform_create(self, serializer: BaseSerializer[TaskSkillRequirement]) -> None:
|
|
650
|
+
self._require_scheduler_on_task(serializer.validated_data["task"])
|
|
651
|
+
super().perform_create(serializer)
|
|
652
|
+
|
|
653
|
+
def perform_update(self, serializer: BaseSerializer[TaskSkillRequirement]) -> None:
|
|
654
|
+
# Guard both the current row's project and any repointed ``task`` so an
|
|
655
|
+
# update can neither mutate a row on a project the actor lacks SCHEDULER
|
|
656
|
+
# on, nor move a requirement onto a task in such a project.
|
|
657
|
+
instance = serializer.instance
|
|
658
|
+
if instance is not None:
|
|
659
|
+
self._require_scheduler_on_task(instance.task)
|
|
660
|
+
new_task = serializer.validated_data.get("task")
|
|
661
|
+
if new_task is not None:
|
|
662
|
+
self._require_scheduler_on_task(new_task)
|
|
663
|
+
super().perform_update(serializer)
|
|
664
|
+
|
|
665
|
+
def perform_destroy(self, instance: TaskSkillRequirement) -> None:
|
|
666
|
+
self._require_scheduler_on_task(instance.task)
|
|
667
|
+
super().perform_destroy(instance)
|
|
668
|
+
|
|
669
|
+
def get_queryset(self) -> QuerySet[TaskSkillRequirement]:
|
|
670
|
+
# Scope to tasks in projects where the requesting user is a member.
|
|
671
|
+
from typing import cast
|
|
672
|
+
|
|
673
|
+
from django.contrib.auth.models import User
|
|
674
|
+
|
|
675
|
+
user = cast(User, self.request.user)
|
|
676
|
+
member_project_ids = ProjectMembership.objects.filter(
|
|
677
|
+
user=user, is_deleted=False, project__is_deleted=False
|
|
678
|
+
).values_list("project_id", flat=True)
|
|
679
|
+
# select_related("task") avoids N+1 queries in perform_update and
|
|
680
|
+
# perform_destroy, both of which call _require_scheduler_on_task(instance.task)
|
|
681
|
+
# and therefore load task.project_id (R1).
|
|
682
|
+
qs = TaskSkillRequirement.objects.select_related("skill", "task").filter(
|
|
683
|
+
is_deleted=False,
|
|
684
|
+
task__project_id__in=member_project_ids,
|
|
685
|
+
)
|
|
686
|
+
task_id = self.request.query_params.get("task")
|
|
687
|
+
if task_id:
|
|
688
|
+
qs = qs.filter(task_id=task_id)
|
|
689
|
+
return qs
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
# ---------------------------------------------------------------------------
|
|
693
|
+
# Resource with skill-fit annotation
|
|
694
|
+
# ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
class _WorkspaceAdminEmailSearchFilter(filters.SearchFilter):
|
|
698
|
+
"""SearchFilter that only lets a workspace admin search the catalog by email (#892).
|
|
699
|
+
|
|
700
|
+
The catalog is readable by any authenticated user, and email is stripped from
|
|
701
|
+
payloads below workspace ADMIN (#891). But a static
|
|
702
|
+
``search_fields = ["name", "email"]`` still let anyone else probe email existence
|
|
703
|
+
via ``?search=<email-substring>`` (a hit narrows the candidate set even though the
|
|
704
|
+
value is never echoed). This backend gates the searchable fields on the same check
|
|
705
|
+
the serializer uses: workspace admins search name + email; everyone else searches
|
|
706
|
+
name only.
|
|
707
|
+
|
|
708
|
+
Raised from the org-admin derivation to the stored workspace role in #3569 — the
|
|
709
|
+
org-admin check it used to call was reachable by any account that created a
|
|
710
|
+
throwaway project, which made the #891 harvest control decorative.
|
|
711
|
+
"""
|
|
712
|
+
|
|
713
|
+
def get_search_fields(self, view: object, request: Request) -> list[str]:
|
|
714
|
+
if _request_is_workspace_admin(request):
|
|
715
|
+
return ["name", "email"]
|
|
716
|
+
return ["name"]
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _request_is_workspace_admin(request: Request) -> bool:
|
|
720
|
+
"""Return True if the requesting user holds workspace ADMIN or above.
|
|
721
|
+
|
|
722
|
+
A request-shaped wrapper over
|
|
723
|
+
:func:`~trueppm_api.apps.workspace.permissions.is_workspace_admin`, which is the
|
|
724
|
+
single definition — do not re-derive the role test here. This one exists because
|
|
725
|
+
a ``SearchFilter`` backend has no permission class to hang the check on. Gates
|
|
726
|
+
email visibility in search (#892) and the ``?include_deleted=true`` deactivated
|
|
727
|
+
pool (#1374), matching :class:`IsWorkspaceAdminStrict` on the sibling actions.
|
|
728
|
+
|
|
729
|
+
This used to derive admin authority from holding ADMIN+ on any project, in
|
|
730
|
+
lockstep with ``IsOrgAdmin``. That derivation is self-grantable in two requests
|
|
731
|
+
(#3569): nothing gates ``POST /projects/`` and ``perform_create`` makes the caller
|
|
732
|
+
Owner. ``WorkspaceMembership`` is granted only by an existing workspace admin or
|
|
733
|
+
by SSO provisioning, so it is not reachable that way.
|
|
734
|
+
"""
|
|
735
|
+
return is_workspace_admin(getattr(request, "user", None))
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _caller_is_project_member(request: Request, project_id: Any) -> bool:
|
|
739
|
+
"""Return True when the caller holds an active membership on ``project_id``.
|
|
740
|
+
|
|
741
|
+
Gates the catalog list's project-scoped query parameters (#3571). The resource
|
|
742
|
+
catalog is readable by any authenticated user (ADR-0034), so a parameter that
|
|
743
|
+
reaches *through* that open read into one project's data — its roster, its
|
|
744
|
+
tasks' skill requirements — has to carry its own membership check; the
|
|
745
|
+
endpoint's permission class cannot supply one, because the endpoint is not
|
|
746
|
+
project-scoped.
|
|
747
|
+
|
|
748
|
+
A malformed id is deliberately *not* pre-validated here: letting it reach the
|
|
749
|
+
UUID column keeps the install-wide malformed-uuid contract
|
|
750
|
+
(``core.exception_handlers`` maps it to 400 for a query param), rather than
|
|
751
|
+
quietly downgrading a typo to an ignored parameter.
|
|
752
|
+
"""
|
|
753
|
+
return _membership_role(request, project_id) is not None
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _caller_may_read_task(request: Request, task_id: Any) -> bool:
|
|
757
|
+
"""Return True when the caller is a member of the project owning ``task_id``.
|
|
758
|
+
|
|
759
|
+
The ``?task=`` skill-fit annotation echoes a task's full requirement set back
|
|
760
|
+
through the open catalog read, so those rows need the same project scoping
|
|
761
|
+
:meth:`TaskSkillRequirementViewSet.get_queryset` already applies to them
|
|
762
|
+
(#3571). An unknown task id and a foreign one are equally un-annotated, so the
|
|
763
|
+
parameter cannot be used to confirm that a task id exists. A malformed id is
|
|
764
|
+
left to the install-wide malformed-uuid contract (400), as above.
|
|
765
|
+
"""
|
|
766
|
+
project_id = Task.objects.filter(pk=task_id).values_list("project_id", flat=True).first()
|
|
767
|
+
if project_id is None:
|
|
768
|
+
return False
|
|
769
|
+
return _membership_role(request, project_id) is not None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class ResourceCatalogThrottle(UserRateThrottle):
|
|
773
|
+
"""Per-user rate limit on the org-wide resource catalog (#891).
|
|
774
|
+
|
|
775
|
+
Mirrors the ``user_search`` throttle added for UserSearchView in #815: the
|
|
776
|
+
catalog is readable by any authenticated user, so even with email stripped a
|
|
777
|
+
single account could still page through it to enumerate the workforce. A
|
|
778
|
+
per-user 60/min cap bounds bulk scraping while staying well above any
|
|
779
|
+
interactive picker (AddToRosterCombobox) usage. The rate is set inline so no
|
|
780
|
+
settings entry is required.
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
scope = "resource_catalog"
|
|
784
|
+
rate = "60/min"
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _compute_skill_fit(
|
|
788
|
+
resource: Resource, requirements: list[TaskSkillRequirement]
|
|
789
|
+
) -> tuple[str, list[dict[str, object]]]:
|
|
790
|
+
"""Return (skill_fit, missing_skills) for a resource against task requirements.
|
|
791
|
+
|
|
792
|
+
skill_fit is 'exact' | 'partial' | 'missing'.
|
|
793
|
+
missing_skills is a list of dicts with skill_id, skill_name, required, actual.
|
|
794
|
+
"""
|
|
795
|
+
if not requirements:
|
|
796
|
+
return "exact", []
|
|
797
|
+
|
|
798
|
+
resource_skills: dict[str, int] = {
|
|
799
|
+
str(rs.skill_id): rs.proficiency for rs in resource.skills.all()
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
matched = 0
|
|
803
|
+
missing = []
|
|
804
|
+
for req in requirements:
|
|
805
|
+
sid = str(req.skill_id)
|
|
806
|
+
actual = resource_skills.get(sid, 0)
|
|
807
|
+
if actual >= req.min_proficiency:
|
|
808
|
+
matched += 1
|
|
809
|
+
else:
|
|
810
|
+
missing.append(
|
|
811
|
+
{
|
|
812
|
+
"skill_id": sid,
|
|
813
|
+
"skill_name": req.skill.name,
|
|
814
|
+
"required": req.min_proficiency,
|
|
815
|
+
"required_label": req.get_min_proficiency_display(),
|
|
816
|
+
"actual": actual,
|
|
817
|
+
"actual_label": Proficiency(actual).label if actual else "not tagged",
|
|
818
|
+
}
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
if not missing:
|
|
822
|
+
return "exact", []
|
|
823
|
+
if matched > 0:
|
|
824
|
+
return "partial", missing
|
|
825
|
+
return "missing", missing
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# Default fractional allocation assumed for a bare-assignee task with no
|
|
829
|
+
# TaskResource row (see the fallback in _check_overallocation below). Mirrors
|
|
830
|
+
# TaskResource.units' own model default — "assigned" without a tracked
|
|
831
|
+
# fraction is treated as full-time, the same assumption Task.assignee already
|
|
832
|
+
# encodes everywhere else it is read as a "who owns this" signal.
|
|
833
|
+
_BARE_ASSIGNEE_DEFAULT_UNITS = Decimal("1.0")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _check_overallocation(resource: Resource, project_id: str) -> list[dict[str, str]]:
|
|
837
|
+
"""Return a warnings list if the resource is overallocated on active tasks.
|
|
838
|
+
|
|
839
|
+
Overallocation is the **peak units the resource holds on any single working
|
|
840
|
+
day** across non-COMPLETE, committed tasks in the project, compared against the
|
|
841
|
+
resource's capacity **on this project** — the roster's ``units_override`` when
|
|
842
|
+
one is set, else ``resource.max_units`` (#3574). Comparing against the raw
|
|
843
|
+
default told a half-time person they were fine at 80% while the heat map one
|
|
844
|
+
click away read 160%. If the peak exceeds capacity a single warning entry is
|
|
845
|
+
returned so the caller can include it in the 201 response without blocking the
|
|
846
|
+
save (ADR-0028 — soft warning, not a hard error).
|
|
847
|
+
|
|
848
|
+
This was previously a project-lifetime ``Sum(units)`` with no date window at
|
|
849
|
+
all, so three 0.8-unit tasks that never share a calendar day reported as 240%
|
|
850
|
+
allocated — 18 of 41 real (resource, project) pairs on the seeded workspace
|
|
851
|
+
produced a warning with no calendar-day conflict anywhere (#3534). Every
|
|
852
|
+
sibling read — the heat map (``projects.utilization``), the board badge, the
|
|
853
|
+
weekly digest — already windows by day, so the write-time warning was the one
|
|
854
|
+
place in the product that did not ask whether the work overlaps. It now shares
|
|
855
|
+
the engine's own calendar resolution and ``peak_concurrent_units``, which means
|
|
856
|
+
"resource calendar wins" and non-working days are not conflicts here either.
|
|
857
|
+
|
|
858
|
+
A task whose only assignment signal is a bare ``Task.assignee`` (no
|
|
859
|
+
TaskResource row) previously contributed **zero** load here, silently
|
|
860
|
+
understating this resource's real allocation (#3047 — the read-side half
|
|
861
|
+
of the #2718/#2900 write/seed fixes). When ``resource.user`` links the
|
|
862
|
+
Resource to the User account ``Task.assignee`` points at, those tasks are
|
|
863
|
+
now folded in at ``_BARE_ASSIGNEE_DEFAULT_UNITS`` over their own span and
|
|
864
|
+
flagged with a separate ``assignment_not_unit_tracked`` warning, so the caller
|
|
865
|
+
knows the peak includes an estimate rather than a real ``TaskResource.units``
|
|
866
|
+
figure. Resources with no linked user account (teams, equipment, or legacy
|
|
867
|
+
rows predating the FK) cannot be correlated to ``Task.assignee`` and are
|
|
868
|
+
unaffected — same as before this fix.
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
resource: The Resource being assigned.
|
|
872
|
+
project_id: The project UUID to scope the utilisation peak.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
A list of warning dicts (overallocation and/or unit-tracking caveat),
|
|
876
|
+
or an empty list.
|
|
877
|
+
"""
|
|
878
|
+
# Capacity check counts only committed delivery — BACKLOG and COMPLETE are
|
|
879
|
+
# excluded. BACKLOG via Task.committed (ADR-0057), COMPLETE via the
|
|
880
|
+
# historical exclude (units are no longer demanding capacity).
|
|
881
|
+
committed_tasks = Task.committed.filter(project_id=project_id).exclude(status="COMPLETE")
|
|
882
|
+
|
|
883
|
+
# Span, not remaining-work window: Coalesce(scheduled_start, early_start)
|
|
884
|
+
# .. early_finish, identical to the utilization engine (ADR-0752 / #2623).
|
|
885
|
+
# A task the CPM has never dated comes back as (None, None) and is folded in
|
|
886
|
+
# by peak_concurrent_units as an every-day baseline rather than dropped.
|
|
887
|
+
assignment_rows = list(
|
|
888
|
+
TaskResource.objects.filter(
|
|
889
|
+
resource=resource,
|
|
890
|
+
task_id__in=committed_tasks.values_list("pk", flat=True),
|
|
891
|
+
)
|
|
892
|
+
.annotate(
|
|
893
|
+
_span_start=Coalesce(
|
|
894
|
+
"task__scheduled_start", "task__early_start", output_field=DateField()
|
|
895
|
+
)
|
|
896
|
+
)
|
|
897
|
+
.values_list("task_id", "units", "_span_start", "task__early_finish")
|
|
898
|
+
)
|
|
899
|
+
allocations = [
|
|
900
|
+
Allocation(units=units, start=span_start, end=span_end)
|
|
901
|
+
for _task_id, units, span_start, span_end in assignment_rows
|
|
902
|
+
]
|
|
903
|
+
|
|
904
|
+
warnings: list[dict[str, str]] = []
|
|
905
|
+
|
|
906
|
+
# Bare-assignee fallback: only resources linked to a user account can be
|
|
907
|
+
# correlated to Task.assignee at all.
|
|
908
|
+
bare_assignee_rows: list[tuple[object, object, object]] = []
|
|
909
|
+
if resource.user_id is not None:
|
|
910
|
+
unit_tracked_task_ids = {row[0] for row in assignment_rows}
|
|
911
|
+
bare_assignee_rows = list(
|
|
912
|
+
committed_tasks.filter(assignee_id=resource.user_id)
|
|
913
|
+
.exclude(pk__in=unit_tracked_task_ids)
|
|
914
|
+
.annotate(
|
|
915
|
+
_span_start=Coalesce("scheduled_start", "early_start", output_field=DateField())
|
|
916
|
+
)
|
|
917
|
+
.values_list("pk", "_span_start", "early_finish")
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
if bare_assignee_rows:
|
|
921
|
+
allocations.extend(
|
|
922
|
+
Allocation(
|
|
923
|
+
units=_BARE_ASSIGNEE_DEFAULT_UNITS,
|
|
924
|
+
start=span_start, # type: ignore[arg-type]
|
|
925
|
+
end=span_end, # type: ignore[arg-type]
|
|
926
|
+
)
|
|
927
|
+
for _pk, span_start, span_end in bare_assignee_rows
|
|
928
|
+
)
|
|
929
|
+
warnings.append(
|
|
930
|
+
{
|
|
931
|
+
"code": "assignment_not_unit_tracked",
|
|
932
|
+
"resource_id": str(resource.pk),
|
|
933
|
+
"resource_name": resource.name,
|
|
934
|
+
"detail": (
|
|
935
|
+
f"{resource.name} is assigned to {len(bare_assignee_rows)} "
|
|
936
|
+
"task(s) with no tracked allocation units; the capacity "
|
|
937
|
+
"figures below assume full-time allocation for those tasks."
|
|
938
|
+
),
|
|
939
|
+
"task_ids": ",".join(str(pk) for pk, _s, _e in bare_assignee_rows),
|
|
940
|
+
}
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
project = (
|
|
944
|
+
Project.objects.select_related("calendar")
|
|
945
|
+
.prefetch_related("calendar__exceptions")
|
|
946
|
+
.filter(pk=project_id)
|
|
947
|
+
.first()
|
|
948
|
+
)
|
|
949
|
+
mask, exception_ranges = resolve_working_calendar(resource, project)
|
|
950
|
+
peak, peak_day = peak_concurrent_units(allocations, mask, exception_ranges)
|
|
951
|
+
|
|
952
|
+
capacity = resource_effective_units(project_id, resource)
|
|
953
|
+
|
|
954
|
+
if peak > capacity:
|
|
955
|
+
# Name the day when there is one. A peak carried entirely by undated
|
|
956
|
+
# tasks has no day to point at, only a floor that applies to every day.
|
|
957
|
+
when = f"on {peak_day.isoformat()}" if peak_day is not None else "on their busiest day"
|
|
958
|
+
warnings.append(
|
|
959
|
+
{
|
|
960
|
+
"code": "resource_overallocated",
|
|
961
|
+
"resource_id": str(resource.pk),
|
|
962
|
+
"resource_name": resource.name,
|
|
963
|
+
"detail": (
|
|
964
|
+
f"{resource.name} is allocated {peak:.0%} {when} (capacity: {capacity:.0%})."
|
|
965
|
+
),
|
|
966
|
+
}
|
|
967
|
+
)
|
|
968
|
+
return warnings
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
@extend_schema_view(
|
|
972
|
+
list=extend_schema(
|
|
973
|
+
parameters=[
|
|
974
|
+
OpenApiParameter(
|
|
975
|
+
name="exclude_project",
|
|
976
|
+
type=OpenApiTypes.UUID,
|
|
977
|
+
location=OpenApiParameter.QUERY,
|
|
978
|
+
required=False,
|
|
979
|
+
description=(
|
|
980
|
+
"Exclude resources already in this project's roster. Only "
|
|
981
|
+
"honoured for members of that project; ignored otherwise."
|
|
982
|
+
),
|
|
983
|
+
),
|
|
984
|
+
OpenApiParameter(
|
|
985
|
+
name="task",
|
|
986
|
+
type=OpenApiTypes.UUID,
|
|
987
|
+
location=OpenApiParameter.QUERY,
|
|
988
|
+
required=False,
|
|
989
|
+
description=(
|
|
990
|
+
"Annotate each resource with skill_fit (exact/partial/missing) "
|
|
991
|
+
"against this task's skill requirements and group results "
|
|
992
|
+
"accordingly. Only honoured for members of the task's project; "
|
|
993
|
+
"ignored otherwise."
|
|
994
|
+
),
|
|
995
|
+
),
|
|
996
|
+
OpenApiParameter(
|
|
997
|
+
name="include_deleted",
|
|
998
|
+
type=OpenApiTypes.BOOL,
|
|
999
|
+
location=OpenApiParameter.QUERY,
|
|
1000
|
+
required=False,
|
|
1001
|
+
description=(
|
|
1002
|
+
"When 'true', include soft-deleted (deactivated) resources. Only "
|
|
1003
|
+
"honoured for callers holding workspace Admin or above; silently ignored "
|
|
1004
|
+
"for everyone else."
|
|
1005
|
+
),
|
|
1006
|
+
),
|
|
1007
|
+
],
|
|
1008
|
+
),
|
|
1009
|
+
)
|
|
1010
|
+
class ResourceViewSet(IdempotencyMixin, viewsets.ModelViewSet[Resource]):
|
|
1011
|
+
"""CRUD for the org-level resource catalog (issue #155).
|
|
1012
|
+
|
|
1013
|
+
Permission model (ADR-0034):
|
|
1014
|
+
Read (GET/HEAD/OPTIONS): any authenticated user — supports self-view
|
|
1015
|
+
for team members and the AddToRosterCombobox picker.
|
|
1016
|
+
Write (POST/PATCH/PUT): IsOrgAdmin — any user with PM (ADMIN) or Owner
|
|
1017
|
+
role on at least one *active* project (#3569). ``email`` is an exception:
|
|
1018
|
+
``ResourceSerializer.validate`` rejects it below the stored workspace ADMIN
|
|
1019
|
+
role (#3625) — this view-level gate alone was self-grantable and left
|
|
1020
|
+
writing the field wide open even after #3569 raised reading it.
|
|
1021
|
+
DELETE, ``restore``, ``assignments``, and ``?include_deleted=true``:
|
|
1022
|
+
IsWorkspaceAdminStrict (stored workspace ADMIN role). Raised in #3569 —
|
|
1023
|
+
these reach across every project in the install, and the org-admin
|
|
1024
|
+
derivation they used to sit behind is self-grantable by creating a
|
|
1025
|
+
throwaway project.
|
|
1026
|
+
|
|
1027
|
+
DELETE is a soft-delete ("deactivate"): it sets is_deleted=True, soft-deletes
|
|
1028
|
+
the resource's live ProjectResource roster row in every project, and enqueues a
|
|
1029
|
+
schedule recalculation for every project that has open TaskResource rows for the
|
|
1030
|
+
deactivated resource. TaskResource assignment rows are deliberately retained so
|
|
1031
|
+
assignment history survives an off-boarding; every roster, capacity, and skill
|
|
1032
|
+
read filters the deactivated resource out instead. The resource record is never
|
|
1033
|
+
hard-deleted, and POST /resources/{id}/restore/ reverses both halves — including
|
|
1034
|
+
the roster rows, but not a membership that was already removed by hand.
|
|
1035
|
+
|
|
1036
|
+
Query params:
|
|
1037
|
+
?search= — filter by name/email (DRF SearchFilter); email is
|
|
1038
|
+
searchable only for org admins (#892)
|
|
1039
|
+
?exclude_project= — exclude resources already in a project's roster;
|
|
1040
|
+
only honoured for members of that project (#3571)
|
|
1041
|
+
?task= — annotate with skill_fit against the task's skill
|
|
1042
|
+
requirements; groups results into exact/partial/missing.
|
|
1043
|
+
Only honoured for members of the task's project (#3571)
|
|
1044
|
+
?include_deleted=true — include soft-deleted (deactivated) resources;
|
|
1045
|
+
only honoured for workspace Admin or above
|
|
1046
|
+
?ordering= — order by name (DRF OrderingFilter)
|
|
1047
|
+
|
|
1048
|
+
Every parameter that names a project- or task-scoped id is gated on the
|
|
1049
|
+
caller's membership of that project. The catalog read itself is open, so a
|
|
1050
|
+
parameter is the only place a project boundary can be crossed here; an
|
|
1051
|
+
unauthorized id is treated as absent rather than refused, so no parameter
|
|
1052
|
+
doubles as an existence oracle.
|
|
1053
|
+
"""
|
|
1054
|
+
|
|
1055
|
+
queryset = (
|
|
1056
|
+
Resource.objects.select_related("calendar")
|
|
1057
|
+
.prefetch_related("skills__skill")
|
|
1058
|
+
.order_by("name")
|
|
1059
|
+
)
|
|
1060
|
+
serializer_class = ResourceSerializer
|
|
1061
|
+
# Email search is gated on the stored workspace ADMIN role via the custom backend
|
|
1062
|
+
# (#892, raised from org-admin in #3569): everyone else searches by name only, so
|
|
1063
|
+
# they cannot probe email existence with ?search=.
|
|
1064
|
+
filter_backends = [_WorkspaceAdminEmailSearchFilter, filters.OrderingFilter]
|
|
1065
|
+
search_fields = ["name", "email"] # workspace admins; backend narrows to ["name"]
|
|
1066
|
+
ordering_fields = ["name"]
|
|
1067
|
+
# Per-user cap on the harvest-prone read path (#891, mirrors #815).
|
|
1068
|
+
throttle_classes = [ResourceCatalogThrottle]
|
|
1069
|
+
|
|
1070
|
+
#: Actions that reach past the catalog's own row data — into other projects'
|
|
1071
|
+
#: task names, or into the deactivated pool — and therefore take the **stored**
|
|
1072
|
+
#: workspace ADMIN role rather than the self-grantable org-admin derivation
|
|
1073
|
+
#: (#3569).
|
|
1074
|
+
#:
|
|
1075
|
+
#: * ``assignments`` — task and project names for one person across every
|
|
1076
|
+
#: project in the install (ADR-0499). Exfiltration.
|
|
1077
|
+
#: * ``destroy`` — soft-deletes a shared resource and fans a CPM recalc plus a
|
|
1078
|
+
#: ``roster_changed`` broadcast to every project holding an assignment,
|
|
1079
|
+
#: including projects the caller cannot see. Destruction.
|
|
1080
|
+
#: * ``restore`` — the inverse of ``destroy``, kept on the same floor so the
|
|
1081
|
+
#: deactivation lifecycle has one principal. Splitting it would leave an
|
|
1082
|
+
#: admin able to reactivate rows they can neither list
|
|
1083
|
+
#: (``?include_deleted=true`` is on the same floor) nor deactivate.
|
|
1084
|
+
_WORKSPACE_ADMIN_ACTIONS: frozenset[str] = frozenset({"assignments", "destroy", "restore"})
|
|
1085
|
+
|
|
1086
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
1087
|
+
"""Split read vs write permissions.
|
|
1088
|
+
|
|
1089
|
+
Safe HTTP methods open to any authenticated user; ordinary catalog writes
|
|
1090
|
+
(create, update) restricted to org admins (PM or Owner role on any *active*
|
|
1091
|
+
project). The deactivation lifecycle and the cross-project ``assignments``
|
|
1092
|
+
view require workspace ADMIN — see :attr:`_WORKSPACE_ADMIN_ACTIONS`.
|
|
1093
|
+
|
|
1094
|
+
``IsWorkspaceAdminStrict`` rather than ``IsWorkspaceOperator``: the defect in
|
|
1095
|
+
#3569 is that org authority was read out of a *self-grantable* project role,
|
|
1096
|
+
and the fix is a **stored** principal, not necessarily the install superuser.
|
|
1097
|
+
``WorkspaceMembership`` is granted only by an existing workspace admin or by
|
|
1098
|
+
SSO provisioning — creating a project grants none — so ADMIN is unreachable
|
|
1099
|
+
by the exploit path while staying an in-app role a workspace owner can hand
|
|
1100
|
+
out. Superusers keep access through ``workspace_role_for_user``'s implicit
|
|
1101
|
+
OWNER bootstrap. ``IsWorkspaceAdminStrict`` (#1724) gates ADMIN on reads as
|
|
1102
|
+
well as writes, which is what ``assignments`` needs.
|
|
1103
|
+
"""
|
|
1104
|
+
# `assignments` is a GET that must NOT inherit the base catalog read's open
|
|
1105
|
+
# IsAuthenticated gate: it carries task/project names across project
|
|
1106
|
+
# boundaries, so an open gate is a cross-project IDOR. It sits in the
|
|
1107
|
+
# workspace-admin set rather than being special-cased here.
|
|
1108
|
+
if getattr(self, "action", None) in self._WORKSPACE_ADMIN_ACTIONS:
|
|
1109
|
+
return [IsAuthenticated(), IsWorkspaceAdminStrict()]
|
|
1110
|
+
if self.request.method in ("GET", "HEAD", "OPTIONS"):
|
|
1111
|
+
return [IsAuthenticated()]
|
|
1112
|
+
return [IsAuthenticated(), IsOrgAdmin()]
|
|
1113
|
+
|
|
1114
|
+
def get_queryset(self) -> QuerySet[Resource]:
|
|
1115
|
+
qs = (
|
|
1116
|
+
Resource.objects.select_related("calendar")
|
|
1117
|
+
.prefetch_related("skills__skill")
|
|
1118
|
+
.order_by("name")
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
# Deactivated resources are hidden by default. A workspace admin may opt in
|
|
1122
|
+
# via ?include_deleted=true to manage the deactivated pool. The param is
|
|
1123
|
+
# honored only at that floor (#1374, raised from org-admin in #3569):
|
|
1124
|
+
# anyone else passing it is silently ignored so soft-deleted resource
|
|
1125
|
+
# records stay hidden — defense-in-depth for the deactivated-pool contract
|
|
1126
|
+
# the docstring already claimed, and it keeps the pool's three verbs
|
|
1127
|
+
# (list/delete/restore) on one principal.
|
|
1128
|
+
include_deleted = self.request.query_params.get(
|
|
1129
|
+
"include_deleted", ""
|
|
1130
|
+
).lower() == "true" and _request_is_workspace_admin(self.request)
|
|
1131
|
+
if not include_deleted:
|
|
1132
|
+
qs = qs.filter(is_deleted=False)
|
|
1133
|
+
|
|
1134
|
+
# ?exclude_project= reaches through the open catalog read into one
|
|
1135
|
+
# project's roster: diffing the filtered list against the bare one names
|
|
1136
|
+
# every resource on that roster. Honour it only for members of that
|
|
1137
|
+
# project (#3571). A non-member's parameter is ignored rather than
|
|
1138
|
+
# refused, which is exactly the response they get by omitting it, so the
|
|
1139
|
+
# parameter confirms nothing about the project id either — the same
|
|
1140
|
+
# treatment ?include_deleted= gets for non-admins above.
|
|
1141
|
+
exclude_project = self.request.query_params.get("exclude_project")
|
|
1142
|
+
if exclude_project and _caller_is_project_member(self.request, exclude_project):
|
|
1143
|
+
already_in = ProjectResource.objects.filter(
|
|
1144
|
+
project_id=exclude_project, is_deleted=False
|
|
1145
|
+
).values_list("resource_id", flat=True)
|
|
1146
|
+
qs = qs.exclude(pk__in=already_in)
|
|
1147
|
+
|
|
1148
|
+
return qs
|
|
1149
|
+
|
|
1150
|
+
def perform_destroy(self, instance: Resource) -> None:
|
|
1151
|
+
"""Soft-delete: hide from the catalog, drop off every roster, recalc schedules.
|
|
1152
|
+
|
|
1153
|
+
Deactivation is **catalog-only on the assignment rows** (#3572). Hard delete is
|
|
1154
|
+
intentionally unavailable from this endpoint, and the ``TaskResource`` rows are
|
|
1155
|
+
deliberately left in place: historical task assignments and capacity data
|
|
1156
|
+
reference this resource and must remain intact for audit trails — an
|
|
1157
|
+
off-boarding is precisely the moment that record matters most.
|
|
1158
|
+
|
|
1159
|
+
What deactivation *does* end is the person's presence in the team:
|
|
1160
|
+
|
|
1161
|
+
1. Every live ``ProjectResource`` roster row is soft-deleted and stamped
|
|
1162
|
+
``deactivated_with_resource`` so :meth:`restore` can put back exactly these
|
|
1163
|
+
and not a membership someone ended by hand.
|
|
1164
|
+
2. Every capacity, roster, and skill read filters the resource out through
|
|
1165
|
+
``ResourceScopedManager.active()`` — the retained assignment rows stop
|
|
1166
|
+
contributing load and stop contributing capacity to the denominator.
|
|
1167
|
+
|
|
1168
|
+
The CPM recalculation fan-out below stays coherent under (2): it is not
|
|
1169
|
+
undoing the assignments (they still hold their tasks and their units), it is
|
|
1170
|
+
re-running the schedule for projects whose *reported* team just changed, so the
|
|
1171
|
+
heat map, the allocation timeline, and the Overview utilization card agree with
|
|
1172
|
+
the roster on the very next read rather than on the next unrelated write.
|
|
1173
|
+
"""
|
|
1174
|
+
# Wrap the soft-delete save, the roster cascade, and the per-project
|
|
1175
|
+
# recalculation fan-out in a single atomic block so a crash partway through
|
|
1176
|
+
# cannot leave the resource deactivated without the roster cascade or the CPM
|
|
1177
|
+
# recalcs (R3). The _enqueue_recalculate calls use the transactional outbox
|
|
1178
|
+
# pattern (ADR-0027), so they are durable against broker failures.
|
|
1179
|
+
with transaction.atomic():
|
|
1180
|
+
instance.is_deleted = True
|
|
1181
|
+
instance.server_version = (instance.server_version or 0) + 1
|
|
1182
|
+
instance.deleted_version = instance.server_version
|
|
1183
|
+
instance.save(update_fields=["is_deleted", "server_version", "deleted_version"])
|
|
1184
|
+
|
|
1185
|
+
# Roster cascade. Only rows that are live *right now* are stamped, so any
|
|
1186
|
+
# roster row soft-deleted by some other path keeps
|
|
1187
|
+
# deactivated_with_resource=False and restore leaves it alone — see the
|
|
1188
|
+
# field's own comment for why that matters. Bulk ``update()`` rather than per-row
|
|
1189
|
+
# ``soft_delete()``: ProjectResource is outside the sync union (nothing
|
|
1190
|
+
# reads its sync_seq), so there is no tombstone to publish and no reason
|
|
1191
|
+
# to spend a query per roster row on an off-boarding.
|
|
1192
|
+
#
|
|
1193
|
+
# The F() version bump replicates what VersionedModel.soft_delete would
|
|
1194
|
+
# have recorded, following cascade_project_children_soft_delete's
|
|
1195
|
+
# precedent: within one UPDATE every F() reads the pre-update column, so
|
|
1196
|
+
# deleted_version lands equal to the new server_version. It is not
|
|
1197
|
+
# decorative — server_version is the X-Base-Version optimistic-lock token
|
|
1198
|
+
# ProjectResourceSerializer publishes, so skipping it would let a client
|
|
1199
|
+
# holding a pre-deactivation copy pass the lock check on a row that had
|
|
1200
|
+
# since been deactivated and restored.
|
|
1201
|
+
#
|
|
1202
|
+
# Deliberately NOT excluding archived projects: deactivation is an
|
|
1203
|
+
# org-wide off-boarding and must not be left partially applied because
|
|
1204
|
+
# one project happens to be archived. The roster row of an archived
|
|
1205
|
+
# project is a historical record either way, and restore's symmetric
|
|
1206
|
+
# filter keeps the two halves reversible.
|
|
1207
|
+
cascade_rows = ProjectResource.objects.filter(resource_id=instance.pk, is_deleted=False)
|
|
1208
|
+
# Snapshot the project ids before the UPDATE clears the predicate that
|
|
1209
|
+
# selects them, so the broadcast set is exactly the rows this call
|
|
1210
|
+
# cascaded — not every row a previous, never-restored deactivation left
|
|
1211
|
+
# stamped.
|
|
1212
|
+
cascaded_project_ids = list(cascade_rows.values_list("project_id", flat=True))
|
|
1213
|
+
cascade_rows.update(
|
|
1214
|
+
is_deleted=True,
|
|
1215
|
+
deactivated_with_resource=True,
|
|
1216
|
+
server_version=models.F("server_version") + 1,
|
|
1217
|
+
deleted_version=models.F("server_version") + 1,
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
# Fan out a schedule recalculation to every project with open
|
|
1221
|
+
# task assignments for this resource. Uses the transactional outbox
|
|
1222
|
+
# pattern (ADR-0027) so a broker-down event doesn't lose the request.
|
|
1223
|
+
affected_project_ids = list(
|
|
1224
|
+
TaskResource.objects.filter(resource_id=instance.pk)
|
|
1225
|
+
.values_list("task__project_id", flat=True)
|
|
1226
|
+
.distinct()
|
|
1227
|
+
)
|
|
1228
|
+
for project_id in affected_project_ids:
|
|
1229
|
+
_enqueue_recalculate(str(project_id))
|
|
1230
|
+
|
|
1231
|
+
# The roster cascade above reaches projects the assignment fan-out does not:
|
|
1232
|
+
# a resource can sit on a roster carrying no tasks at all, which is exactly
|
|
1233
|
+
# the person an off-boarding is most likely to leave behind. So the broadcast
|
|
1234
|
+
# set is the union of both, not just the projects that carried assignments.
|
|
1235
|
+
#
|
|
1236
|
+
# Fan a roster_changed broadcast out to every affected project so peers
|
|
1237
|
+
# viewing the roster see the deactivation live, not on the next poll
|
|
1238
|
+
# (#1359). Deferred to commit and snapshotted to plain strings; mirrors
|
|
1239
|
+
# the per-project recalc fan-out above.
|
|
1240
|
+
resource_id = str(instance.pk)
|
|
1241
|
+
broadcast_project_ids = sorted(
|
|
1242
|
+
{str(p) for p in affected_project_ids} | {str(p) for p in cascaded_project_ids}
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
def _on_commit() -> None:
|
|
1246
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1247
|
+
|
|
1248
|
+
for project_id in broadcast_project_ids:
|
|
1249
|
+
broadcast_board_event(
|
|
1250
|
+
project_id,
|
|
1251
|
+
"roster_changed",
|
|
1252
|
+
{"resource_id": resource_id},
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
transaction.on_commit(_on_commit)
|
|
1256
|
+
|
|
1257
|
+
# Reactivation is carried by the URL alone — the body is never read. Without
|
|
1258
|
+
# ``request=None`` the schema fell back to serializer_class and published a
|
|
1259
|
+
# *required* ResourceRequest body (the full write shape) on this no-body
|
|
1260
|
+
# endpoint, so a generated client demanded a whole resource payload to
|
|
1261
|
+
# un-deactivate one (#2840). Sibling restore actions on Task/Project already
|
|
1262
|
+
# publish no body; this one was the outlier.
|
|
1263
|
+
@extend_schema(
|
|
1264
|
+
summary="Restore a deactivated resource",
|
|
1265
|
+
request=None,
|
|
1266
|
+
responses={
|
|
1267
|
+
200: ResourceSerializer,
|
|
1268
|
+
400: OpenApiResponse(description="Resource is not deactivated."),
|
|
1269
|
+
404: OpenApiResponse(description="Resource not found."),
|
|
1270
|
+
},
|
|
1271
|
+
)
|
|
1272
|
+
@action(detail=True, methods=["post"], url_path="restore")
|
|
1273
|
+
def restore(self, request: Request, pk: str | None = None) -> Response:
|
|
1274
|
+
"""Restore a soft-deleted resource back to active status.
|
|
1275
|
+
|
|
1276
|
+
Requires IsWorkspaceAdminStrict (checked in get_permissions since this is a
|
|
1277
|
+
write action; raised from IsOrgAdmin in #3569 so the whole deactivation
|
|
1278
|
+
lifecycle sits on one principal). Fetches from the unfiltered queryset so
|
|
1279
|
+
soft-deleted records are reachable; the standard get_object() path
|
|
1280
|
+
excludes them.
|
|
1281
|
+
|
|
1282
|
+
Reverses **both** halves of :meth:`perform_destroy` (#3572). The catalog flag
|
|
1283
|
+
alone is not enough: deactivation also soft-deleted the resource's roster
|
|
1284
|
+
rows, and a one-way cascade would leave a reactivated person visible in the
|
|
1285
|
+
catalog but silently absent from every team they were on — a second bug in
|
|
1286
|
+
the shape of the first.
|
|
1287
|
+
|
|
1288
|
+
Only rows stamped ``deactivated_with_resource`` come back — restore states
|
|
1289
|
+
what it reverses rather than inferring it from ``is_deleted``. Roster removal
|
|
1290
|
+
through the API is a hard delete, so the cascade is currently the only
|
|
1291
|
+
product path that leaves a soft-deleted roster row; the stamp is what keeps
|
|
1292
|
+
that an assertion rather than an assumption, so a row soft-deleted by an
|
|
1293
|
+
import, a management command, or a data repair is never silently adopted
|
|
1294
|
+
into somebody's reactivation. See ``ProjectResource.deactivated_with_resource``.
|
|
1295
|
+
"""
|
|
1296
|
+
if pk is None:
|
|
1297
|
+
return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
|
|
1298
|
+
try:
|
|
1299
|
+
resource = Resource.objects.get(pk=pk)
|
|
1300
|
+
except Resource.DoesNotExist:
|
|
1301
|
+
return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
|
|
1302
|
+
if not resource.is_deleted:
|
|
1303
|
+
return Response(
|
|
1304
|
+
{"detail": "Resource is not deactivated."},
|
|
1305
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
# Atomic so the catalog flag and the roster rows cannot disagree: a crash
|
|
1309
|
+
# between them would leave the resource selectable but off every roster,
|
|
1310
|
+
# which is the exact asymmetry this action exists to prevent.
|
|
1311
|
+
with transaction.atomic():
|
|
1312
|
+
resource.is_deleted = False
|
|
1313
|
+
resource.deleted_version = None
|
|
1314
|
+
resource.server_version = (resource.server_version or 0) + 1
|
|
1315
|
+
resource.save(update_fields=["is_deleted", "deleted_version", "server_version"])
|
|
1316
|
+
|
|
1317
|
+
# Read the project ids before the update clears the stamp.
|
|
1318
|
+
restored_project_ids = list(
|
|
1319
|
+
ProjectResource.objects.filter(
|
|
1320
|
+
resource_id=resource.pk, deactivated_with_resource=True
|
|
1321
|
+
).values_list("project_id", flat=True)
|
|
1322
|
+
)
|
|
1323
|
+
# Symmetric to the cascade: same F() version bump, so a client holding
|
|
1324
|
+
# a pre-deactivation copy of a restored roster row cannot pass the
|
|
1325
|
+
# X-Base-Version optimistic-lock check on it.
|
|
1326
|
+
ProjectResource.objects.filter(
|
|
1327
|
+
resource_id=resource.pk, deactivated_with_resource=True
|
|
1328
|
+
).update(
|
|
1329
|
+
is_deleted=False,
|
|
1330
|
+
deleted_version=None,
|
|
1331
|
+
deactivated_with_resource=False,
|
|
1332
|
+
server_version=models.F("server_version") + 1,
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
# Reactivation puts the resource back on every roster the deactivation took
|
|
1336
|
+
# it off, plus every project it still has assignments on — broadcast
|
|
1337
|
+
# roster_changed so those projects' clients refetch the roster live (#1359),
|
|
1338
|
+
# mirroring the soft-delete fan-out.
|
|
1339
|
+
affected_project_ids = sorted(
|
|
1340
|
+
{
|
|
1341
|
+
str(p)
|
|
1342
|
+
for p in TaskResource.objects.filter(resource_id=resource.pk)
|
|
1343
|
+
.values_list("task__project_id", flat=True)
|
|
1344
|
+
.distinct()
|
|
1345
|
+
}
|
|
1346
|
+
| {str(p) for p in restored_project_ids}
|
|
1347
|
+
)
|
|
1348
|
+
resource_id = str(resource.pk)
|
|
1349
|
+
|
|
1350
|
+
def _on_commit() -> None:
|
|
1351
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1352
|
+
|
|
1353
|
+
for project_id in affected_project_ids:
|
|
1354
|
+
broadcast_board_event(
|
|
1355
|
+
project_id,
|
|
1356
|
+
"roster_changed",
|
|
1357
|
+
{"resource_id": resource_id},
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
transaction.on_commit(_on_commit)
|
|
1361
|
+
|
|
1362
|
+
serializer = self.get_serializer(resource)
|
|
1363
|
+
return Response(serializer.data)
|
|
1364
|
+
|
|
1365
|
+
@extend_schema(
|
|
1366
|
+
responses=ResourceAssignmentSerializer(many=True),
|
|
1367
|
+
description=(
|
|
1368
|
+
"Cross-project task assignments for one resource — the org catalog's "
|
|
1369
|
+
"'what is this person working on' view (#2047). Requires the workspace "
|
|
1370
|
+
"Admin role because it carries task/project names across "
|
|
1371
|
+
"project boundaries. "
|
|
1372
|
+
"Soft-deleted tasks are excluded; a deactivated resource still returns "
|
|
1373
|
+
"its assignments. Ordered by project then task."
|
|
1374
|
+
),
|
|
1375
|
+
)
|
|
1376
|
+
@action(detail=True, methods=["get"], url_path="assignments")
|
|
1377
|
+
def assignments(self, request: Request, pk: str | None = None) -> Response:
|
|
1378
|
+
"""Return every task the resource is assigned to, across all projects.
|
|
1379
|
+
|
|
1380
|
+
Gated on ``IsWorkspaceAdminStrict`` in ``get_permissions`` (this GET must not
|
|
1381
|
+
inherit the base catalog read's open gate — see the note there). Unlike
|
|
1382
|
+
``/task-resources/?resource=`` this is NOT scoped to the caller's member
|
|
1383
|
+
projects: it returns the full cross-project set.
|
|
1384
|
+
|
|
1385
|
+
ADR-0499 gated it on ``IsOrgAdmin`` and reasoned that "the RBAC gate is
|
|
1386
|
+
what makes that safe". It did not — that gate is self-grantable in two
|
|
1387
|
+
requests (#3569), so the complete picture was readable by any account. The
|
|
1388
|
+
stored workspace ADMIN role is not reachable that way. A resource manager who
|
|
1389
|
+
needs their own projects' view uses ``/task-resources/?resource=``, which is
|
|
1390
|
+
membership-scoped.
|
|
1391
|
+
|
|
1392
|
+
The resource is looked up from the unfiltered manager so a deactivated
|
|
1393
|
+
resource's detail panel still resolves (mirrors ``restore``). Soft-deleted
|
|
1394
|
+
tasks are filtered out; completed tasks are included and sectioned client
|
|
1395
|
+
side. ``select_related('task__project')`` keeps this a single query
|
|
1396
|
+
regardless of assignment count (no N+1).
|
|
1397
|
+
"""
|
|
1398
|
+
if pk is None or not Resource.objects.filter(pk=pk).exists():
|
|
1399
|
+
return Response({"detail": _NOT_FOUND_DETAIL}, status=status.HTTP_404_NOT_FOUND)
|
|
1400
|
+
|
|
1401
|
+
assignments_qs = (
|
|
1402
|
+
TaskResource.objects.filter(resource_id=pk, task__is_deleted=False)
|
|
1403
|
+
.select_related("task__project")
|
|
1404
|
+
.order_by("task__project__name", "task__name")
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
# Use the paginator directly rather than self.paginate_queryset(): the
|
|
1408
|
+
# latter is typed to the viewset's Resource model, but this action returns
|
|
1409
|
+
# TaskResource rows. The set is intrinsically bounded (one person's
|
|
1410
|
+
# assignments) — pagination is a belt-and-suspenders guard.
|
|
1411
|
+
paginator = self.paginator
|
|
1412
|
+
if paginator is not None:
|
|
1413
|
+
page = paginator.paginate_queryset(assignments_qs, request, view=self)
|
|
1414
|
+
if page is not None:
|
|
1415
|
+
serializer = ResourceAssignmentSerializer(page, many=True)
|
|
1416
|
+
return paginator.get_paginated_response(serializer.data)
|
|
1417
|
+
serializer = ResourceAssignmentSerializer(assignments_qs, many=True)
|
|
1418
|
+
return Response(serializer.data)
|
|
1419
|
+
|
|
1420
|
+
def list(self, request: Request, *args: object, **kwargs: object) -> Response:
|
|
1421
|
+
"""List resources, optionally annotated with skill_fit for a task."""
|
|
1422
|
+
task_id = request.query_params.get("task")
|
|
1423
|
+
requirements: list[TaskSkillRequirement] = []
|
|
1424
|
+
# missing_skills[] echoes skill_name/required/required_label for every
|
|
1425
|
+
# requirement on the task, so an unscoped ?task= published a foreign
|
|
1426
|
+
# project's requirement set through a read any authenticated user may
|
|
1427
|
+
# make. Annotate only for members of the task's project (#3571) — the
|
|
1428
|
+
# same scoping TaskSkillRequirementViewSet.get_queryset applies to these
|
|
1429
|
+
# rows. A non-member gets the plain, un-annotated catalog.
|
|
1430
|
+
if task_id and _caller_may_read_task(request, task_id):
|
|
1431
|
+
requirements = list(
|
|
1432
|
+
TaskSkillRequirement.objects.filter(
|
|
1433
|
+
task_id=task_id, is_deleted=False
|
|
1434
|
+
).select_related("skill")
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
queryset = self.filter_queryset(self.get_queryset())
|
|
1438
|
+
page = self.paginate_queryset(queryset)
|
|
1439
|
+
resources = page if page is not None else queryset
|
|
1440
|
+
|
|
1441
|
+
serializer = self.get_serializer(resources, many=True)
|
|
1442
|
+
data = serializer.data
|
|
1443
|
+
|
|
1444
|
+
if requirements:
|
|
1445
|
+
resource_map = {str(r.pk): r for r in resources}
|
|
1446
|
+
fit_order = {"exact": 0, "partial": 1, "missing": 2}
|
|
1447
|
+
annotated = []
|
|
1448
|
+
for item in data:
|
|
1449
|
+
resource = resource_map.get(item["id"])
|
|
1450
|
+
if resource is not None:
|
|
1451
|
+
fit, missing = _compute_skill_fit(resource, requirements)
|
|
1452
|
+
item = dict(item)
|
|
1453
|
+
item["skill_fit"] = fit
|
|
1454
|
+
item["missing_skills"] = missing
|
|
1455
|
+
annotated.append(item)
|
|
1456
|
+
annotated.sort(key=lambda x: fit_order.get(x.get("skill_fit", "missing"), 2))
|
|
1457
|
+
data = annotated
|
|
1458
|
+
|
|
1459
|
+
if page is not None:
|
|
1460
|
+
return self.get_paginated_response(data)
|
|
1461
|
+
return Response(data)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
# ---------------------------------------------------------------------------
|
|
1465
|
+
# Task-resource assignments (extended with skill_mismatch warning)
|
|
1466
|
+
# ---------------------------------------------------------------------------
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
@extend_schema_view(
|
|
1470
|
+
list=extend_schema(
|
|
1471
|
+
parameters=[
|
|
1472
|
+
OpenApiParameter(
|
|
1473
|
+
name="task",
|
|
1474
|
+
type=OpenApiTypes.UUID,
|
|
1475
|
+
location=OpenApiParameter.QUERY,
|
|
1476
|
+
required=False,
|
|
1477
|
+
description="Filter to assignments for this task.",
|
|
1478
|
+
),
|
|
1479
|
+
OpenApiParameter(
|
|
1480
|
+
name="resource",
|
|
1481
|
+
type=OpenApiTypes.UUID,
|
|
1482
|
+
location=OpenApiParameter.QUERY,
|
|
1483
|
+
required=False,
|
|
1484
|
+
description="Filter to assignments for this resource.",
|
|
1485
|
+
),
|
|
1486
|
+
],
|
|
1487
|
+
),
|
|
1488
|
+
create=extend_schema(
|
|
1489
|
+
responses={
|
|
1490
|
+
201: TaskResourceSerializer,
|
|
1491
|
+
403: OpenApiResponse(
|
|
1492
|
+
description=(
|
|
1493
|
+
"Caller lacks the Resource Manager role on the target project, or that "
|
|
1494
|
+
"project is archived (#3414). The project is named only in the request "
|
|
1495
|
+
"body here, so the refusal comes from the view rather than a permission "
|
|
1496
|
+
"class — the status and body are the same either way."
|
|
1497
|
+
)
|
|
1498
|
+
),
|
|
1499
|
+
},
|
|
1500
|
+
),
|
|
1501
|
+
)
|
|
1502
|
+
class TaskResourceViewSet(ProjectScopedViewSet, viewsets.ModelViewSet[TaskResource]):
|
|
1503
|
+
"""CRUD for task-resource assignments.
|
|
1504
|
+
|
|
1505
|
+
Permission model:
|
|
1506
|
+
- Read (GET/HEAD/OPTIONS): any project member (Viewer+) via IsProjectMember.
|
|
1507
|
+
- Write (POST/PATCH/DELETE): Resource Manager (2) or above via CanAssignResource.
|
|
1508
|
+
The create path additionally checks role in perform_create because has_object_permission
|
|
1509
|
+
is not called before the object exists.
|
|
1510
|
+
|
|
1511
|
+
IDOR protection:
|
|
1512
|
+
ProjectScopedViewSet does not recognise the task→project FK path on TaskResource,
|
|
1513
|
+
so get_queryset explicitly scopes to the user's member projects rather than relying
|
|
1514
|
+
on the mixin fallthrough.
|
|
1515
|
+
"""
|
|
1516
|
+
|
|
1517
|
+
permission_classes = [IsAuthenticated, IsProjectMember, CanAssignResource, IsProjectNotArchived]
|
|
1518
|
+
resolves_scope_in_body = {
|
|
1519
|
+
"create": (
|
|
1520
|
+
"Flat route: the project is reached only through the body's `task`, so "
|
|
1521
|
+
"`CanAssignResource.has_permission` cannot scope the write and "
|
|
1522
|
+
"`has_object_permission` does not run on a create. `perform_create` "
|
|
1523
|
+
"resolves `task.project_id` and applies the Scheduler+ floor there (#3767)."
|
|
1524
|
+
)
|
|
1525
|
+
}
|
|
1526
|
+
serializer_class = TaskResourceSerializer
|
|
1527
|
+
filter_backends = [filters.OrderingFilter]
|
|
1528
|
+
queryset = TaskResource.objects.select_related("task__project", "resource")
|
|
1529
|
+
|
|
1530
|
+
def get_queryset(self) -> QuerySet[TaskResource]:
|
|
1531
|
+
# IsAuthenticated guarantees pk is set; assert narrows the type for mypy.
|
|
1532
|
+
user_pk = self.request.user.pk
|
|
1533
|
+
assert user_pk is not None
|
|
1534
|
+
member_project_ids = ProjectMembership.objects.filter(
|
|
1535
|
+
user_id=user_pk,
|
|
1536
|
+
is_deleted=False,
|
|
1537
|
+
project__is_deleted=False, # #4577: a trashed project confers no access
|
|
1538
|
+
).values_list("project_id", flat=True)
|
|
1539
|
+
# select_related("task__project") avoids N+1 queries in perform_create
|
|
1540
|
+
# and perform_update, both of which call ensure_project_resource(obj.task.project, ...)
|
|
1541
|
+
# and therefore load the full Project object (R2).
|
|
1542
|
+
#
|
|
1543
|
+
# Deliberately NOT ``.active()`` (#3572), unlike its two siblings in this
|
|
1544
|
+
# file. This is the assignment list, not a capacity read: when somebody is
|
|
1545
|
+
# deactivated their assignment rows are kept precisely so a PM can still see
|
|
1546
|
+
# the orphaned work and reassign it. Hiding them here would make the tasks
|
|
1547
|
+
# look unowned with no way to find out who had them. The rows carry no load
|
|
1548
|
+
# anywhere else — every capacity surface filters them out.
|
|
1549
|
+
qs = (
|
|
1550
|
+
TaskResource.objects.select_related("task__project", "resource")
|
|
1551
|
+
.filter(task__project_id__in=member_project_ids)
|
|
1552
|
+
.order_by("task__project_id", "task_id", "resource__name")
|
|
1553
|
+
)
|
|
1554
|
+
task_id = self.request.query_params.get("task")
|
|
1555
|
+
if task_id:
|
|
1556
|
+
qs = qs.filter(task_id=task_id)
|
|
1557
|
+
resource_id = self.request.query_params.get("resource")
|
|
1558
|
+
if resource_id:
|
|
1559
|
+
qs = qs.filter(resource_id=resource_id)
|
|
1560
|
+
return qs
|
|
1561
|
+
|
|
1562
|
+
def create(self, request: Request, *args: object, **kwargs: object) -> Response:
|
|
1563
|
+
"""Create a task-resource assignment and return any warnings.
|
|
1564
|
+
|
|
1565
|
+
Returns overallocation and skill_mismatch warnings in the 201 response.
|
|
1566
|
+
The assignment is always saved regardless of warnings — these are soft
|
|
1567
|
+
alerts, not hard blocks (ADR-0028, ADR-0033).
|
|
1568
|
+
"""
|
|
1569
|
+
serializer = self.get_serializer(data=request.data)
|
|
1570
|
+
serializer.is_valid(raise_exception=True)
|
|
1571
|
+
self.perform_create(serializer)
|
|
1572
|
+
headers = self.get_success_headers(serializer.data)
|
|
1573
|
+
obj: TaskResource = serializer.instance # type: ignore[assignment]
|
|
1574
|
+
project_id = str(obj.task.project_id)
|
|
1575
|
+
# Fetch the resource (with skills prefetched) once and reuse it for both
|
|
1576
|
+
# the overallocation check and the skill-fit check, instead of lazy-loading
|
|
1577
|
+
# obj.resource and then re-fetching the same row with prefetch (#821).
|
|
1578
|
+
# select_related/prefetch the resource calendar too: _check_overallocation
|
|
1579
|
+
# resolves the resource's working days (ADR-0031 "resource calendar wins"),
|
|
1580
|
+
# which would otherwise be two lazy queries per POST.
|
|
1581
|
+
resource_with_skills = (
|
|
1582
|
+
Resource.objects.select_related("calendar")
|
|
1583
|
+
.prefetch_related("skills__skill", "calendar__exceptions")
|
|
1584
|
+
.get(pk=obj.resource_id)
|
|
1585
|
+
)
|
|
1586
|
+
warnings: list[dict[str, object]] = [
|
|
1587
|
+
dict(w) for w in _check_overallocation(resource_with_skills, project_id)
|
|
1588
|
+
]
|
|
1589
|
+
|
|
1590
|
+
# Skill mismatch check — only when the task has requirements.
|
|
1591
|
+
requirements = list(
|
|
1592
|
+
TaskSkillRequirement.objects.filter(
|
|
1593
|
+
task_id=obj.task_id, is_deleted=False
|
|
1594
|
+
).select_related("skill")
|
|
1595
|
+
)
|
|
1596
|
+
_fit, missing = _compute_skill_fit(resource_with_skills, requirements)
|
|
1597
|
+
if missing:
|
|
1598
|
+
missing_labels = ", ".join(
|
|
1599
|
+
f"{m['skill_name']} ({m['required_label']})" for m in missing[:3]
|
|
1600
|
+
)
|
|
1601
|
+
warnings.append(
|
|
1602
|
+
{
|
|
1603
|
+
"code": "skill_mismatch",
|
|
1604
|
+
"detail": f"Task requires: {missing_labels}.",
|
|
1605
|
+
"missing_skills": missing,
|
|
1606
|
+
}
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
data = dict(serializer.data)
|
|
1610
|
+
data["warnings"] = warnings
|
|
1611
|
+
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
|
1612
|
+
|
|
1613
|
+
def perform_create(self, serializer: BaseSerializer[TaskResource]) -> None:
|
|
1614
|
+
"""Block assignment creation for summary tasks, then trigger CPM and broadcast.
|
|
1615
|
+
|
|
1616
|
+
Role check: Resource Manager (2) or above is required. has_object_permission is
|
|
1617
|
+
not called for create (no object exists yet), so the role is verified here against
|
|
1618
|
+
the task's project before the row is written.
|
|
1619
|
+
|
|
1620
|
+
Summary tasks roll up from children — direct resource assignments on
|
|
1621
|
+
them create ambiguous scheduling semantics (ADR-0024).
|
|
1622
|
+
"""
|
|
1623
|
+
task = serializer.validated_data.get("task")
|
|
1624
|
+
if task:
|
|
1625
|
+
role = _membership_role(self.request, str(task.project_id))
|
|
1626
|
+
if role is None or role < Role.SCHEDULER:
|
|
1627
|
+
raise PermissionDenied(
|
|
1628
|
+
"You need at least Resource Manager role to assign resources."
|
|
1629
|
+
)
|
|
1630
|
+
# Same shape as the role check above, and for the same structural reason
|
|
1631
|
+
# (#3414): the route is top-level, the project is reached only through the
|
|
1632
|
+
# body's ``task``, and ``has_object_permission`` does not run on a create —
|
|
1633
|
+
# so the class-level ``IsProjectNotArchived`` cannot see this write.
|
|
1634
|
+
assert_project_not_archived(task.project_id)
|
|
1635
|
+
if task and task_is_summary(task):
|
|
1636
|
+
raise ValidationError({"task": "Cannot assign resources to a summary task."})
|
|
1637
|
+
obj = serializer.save()
|
|
1638
|
+
project_id = str(obj.task.project_id)
|
|
1639
|
+
task_id = str(obj.task.pk)
|
|
1640
|
+
assignment_id = str(obj.pk)
|
|
1641
|
+
|
|
1642
|
+
# Auto-roster: assigning a resource to a task implicitly adds them to
|
|
1643
|
+
# the project roster so they appear in Team → Roster / Heatmap.
|
|
1644
|
+
ensure_project_resource(obj.task.project, obj.resource)
|
|
1645
|
+
|
|
1646
|
+
# Task-activity audit row (ADR-0394, #1886): written synchronously in the
|
|
1647
|
+
# request transaction so it commits/rolls back with the assignment itself.
|
|
1648
|
+
record_assignment_event(
|
|
1649
|
+
task_id=obj.task_id,
|
|
1650
|
+
event_type=TaskActivityEventType.ASSIGNEE_ADDED,
|
|
1651
|
+
resource=obj.resource,
|
|
1652
|
+
actor=self.request.user,
|
|
1653
|
+
units_to=obj.units,
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
def _on_commit() -> None:
|
|
1657
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1658
|
+
|
|
1659
|
+
_enqueue_recalculate(project_id)
|
|
1660
|
+
broadcast_board_event(
|
|
1661
|
+
project_id,
|
|
1662
|
+
"assignment_created",
|
|
1663
|
+
{"id": assignment_id, "task_id": task_id},
|
|
1664
|
+
)
|
|
1665
|
+
|
|
1666
|
+
transaction.on_commit(_on_commit)
|
|
1667
|
+
|
|
1668
|
+
def perform_update(self, serializer: BaseSerializer[TaskResource]) -> None:
|
|
1669
|
+
"""Save the updated assignment and trigger CPM recalculation and broadcast."""
|
|
1670
|
+
# Capture pre-save state so the audit row can distinguish a resource re-point
|
|
1671
|
+
# (remove old + add new) from an allocation-only change (ADR-0394, #1886).
|
|
1672
|
+
pre = serializer.instance
|
|
1673
|
+
old_resource = pre.resource if pre is not None else None
|
|
1674
|
+
old_units = pre.units if pre is not None else None
|
|
1675
|
+
|
|
1676
|
+
obj = serializer.save()
|
|
1677
|
+
project_id = str(obj.task.project_id)
|
|
1678
|
+
task_id = str(obj.task.pk)
|
|
1679
|
+
assignment_id = str(obj.pk)
|
|
1680
|
+
|
|
1681
|
+
# Re-pointing an assignment to a different resource must roster the new
|
|
1682
|
+
# one — otherwise editing assignee leaves them invisible in Team views (#241).
|
|
1683
|
+
ensure_project_resource(obj.task.project, obj.resource)
|
|
1684
|
+
|
|
1685
|
+
# Task-activity audit (ADR-0394, #1886), synchronous in-transaction:
|
|
1686
|
+
# a resource swap is an unassign+reassign; a units-only edit is a re-allocation.
|
|
1687
|
+
if old_resource is not None and old_resource.pk != obj.resource_id:
|
|
1688
|
+
record_assignment_event(
|
|
1689
|
+
task_id=obj.task_id,
|
|
1690
|
+
event_type=TaskActivityEventType.ASSIGNEE_REMOVED,
|
|
1691
|
+
resource=old_resource,
|
|
1692
|
+
actor=self.request.user,
|
|
1693
|
+
units_to=old_units,
|
|
1694
|
+
)
|
|
1695
|
+
record_assignment_event(
|
|
1696
|
+
task_id=obj.task_id,
|
|
1697
|
+
event_type=TaskActivityEventType.ASSIGNEE_ADDED,
|
|
1698
|
+
resource=obj.resource,
|
|
1699
|
+
actor=self.request.user,
|
|
1700
|
+
units_to=obj.units,
|
|
1701
|
+
)
|
|
1702
|
+
elif old_units != obj.units:
|
|
1703
|
+
record_assignment_event(
|
|
1704
|
+
task_id=obj.task_id,
|
|
1705
|
+
event_type=TaskActivityEventType.ASSIGNEE_UNITS_CHANGED,
|
|
1706
|
+
resource=obj.resource,
|
|
1707
|
+
actor=self.request.user,
|
|
1708
|
+
units_from=old_units,
|
|
1709
|
+
units_to=obj.units,
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
def _on_commit() -> None:
|
|
1713
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1714
|
+
|
|
1715
|
+
_enqueue_recalculate(project_id)
|
|
1716
|
+
broadcast_board_event(
|
|
1717
|
+
project_id,
|
|
1718
|
+
"assignment_updated",
|
|
1719
|
+
{"id": assignment_id, "task_id": task_id},
|
|
1720
|
+
)
|
|
1721
|
+
|
|
1722
|
+
transaction.on_commit(_on_commit)
|
|
1723
|
+
|
|
1724
|
+
def perform_destroy(self, instance: TaskResource) -> None:
|
|
1725
|
+
"""Delete the assignment and trigger CPM recalculation and broadcast."""
|
|
1726
|
+
project_id = str(instance.task.project_id)
|
|
1727
|
+
task_id = str(instance.task.pk)
|
|
1728
|
+
assignment_id = str(instance.pk)
|
|
1729
|
+
|
|
1730
|
+
# Task-activity audit row (ADR-0394, #1886) before the hard delete, while the
|
|
1731
|
+
# resource/units are still readable; the task FK survives (only the assignment
|
|
1732
|
+
# row is removed). Synchronous in-transaction, like the risk-link precedent.
|
|
1733
|
+
record_assignment_event(
|
|
1734
|
+
task_id=instance.task_id,
|
|
1735
|
+
event_type=TaskActivityEventType.ASSIGNEE_REMOVED,
|
|
1736
|
+
resource=instance.resource,
|
|
1737
|
+
actor=self.request.user,
|
|
1738
|
+
units_to=instance.units,
|
|
1739
|
+
)
|
|
1740
|
+
instance.delete()
|
|
1741
|
+
|
|
1742
|
+
def _on_commit() -> None:
|
|
1743
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
1744
|
+
|
|
1745
|
+
_enqueue_recalculate(project_id)
|
|
1746
|
+
broadcast_board_event(
|
|
1747
|
+
project_id,
|
|
1748
|
+
"assignment_deleted",
|
|
1749
|
+
{"id": assignment_id, "task_id": task_id},
|
|
1750
|
+
)
|
|
1751
|
+
|
|
1752
|
+
transaction.on_commit(_on_commit)
|