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,967 @@
|
|
|
1
|
+
"""Service-layer helpers for the workspace app (ADR-0087).
|
|
2
|
+
|
|
3
|
+
Three multi-row transactions live here:
|
|
4
|
+
|
|
5
|
+
- :func:`reconcile_group_access` — the Group→project access **cascade**. It
|
|
6
|
+
materializes, updates, and removes ``ProjectMembership`` rows so that every
|
|
7
|
+
(group member × linked project) pair has the project ``Role`` the group
|
|
8
|
+
confers. Reconciliation is idempotent and recomputed from scratch per affected
|
|
9
|
+
pair, so it is safe to call after any group/member/link/role change.
|
|
10
|
+
|
|
11
|
+
- :func:`accept_invite` — atomically provisions (or links) a user account and
|
|
12
|
+
creates the ``WorkspaceMembership`` from a token-bearing invite.
|
|
13
|
+
|
|
14
|
+
- :func:`create_invite` — issues a pending invite with a one-time token.
|
|
15
|
+
|
|
16
|
+
The owner-counting helpers back the last-owner guard: a workspace must never be
|
|
17
|
+
left without at least one active owner (explicit OWNER membership or an active
|
|
18
|
+
Django superuser).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import logging
|
|
25
|
+
import secrets
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from django.contrib.auth import get_user_model
|
|
30
|
+
from django.contrib.auth.password_validation import validate_password
|
|
31
|
+
|
|
32
|
+
# ``ValidationError`` here is Django's (used for the ownership-transfer guards and
|
|
33
|
+
# the password-policy check) — distinct from DRF's ValidationError raised in views.
|
|
34
|
+
from django.core.exceptions import ValidationError
|
|
35
|
+
from django.db import IntegrityError, transaction
|
|
36
|
+
from django.utils import timezone
|
|
37
|
+
|
|
38
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
39
|
+
from trueppm_api.apps.workspace.models import (
|
|
40
|
+
AuditEvent,
|
|
41
|
+
AuditEventType,
|
|
42
|
+
ExportJobStatus,
|
|
43
|
+
GroupMembership,
|
|
44
|
+
GroupProject,
|
|
45
|
+
InviteStatus,
|
|
46
|
+
MemberStatus,
|
|
47
|
+
Workspace,
|
|
48
|
+
WorkspaceExportJob,
|
|
49
|
+
WorkspaceInvite,
|
|
50
|
+
WorkspaceMembership,
|
|
51
|
+
WorkspaceRole,
|
|
52
|
+
)
|
|
53
|
+
from trueppm_api.apps.workspace.signals import audit_event_created
|
|
54
|
+
from trueppm_api.core.extension_signals import dispatch_extension_signal
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
_PK = uuid.UUID | str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InviteSenderUnauthorizedError(Exception):
|
|
62
|
+
"""Resend refused: the invite's sender no longer holds a role above the invited role (#4589)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InviteError(Exception):
|
|
66
|
+
"""Raised when an invite cannot be accepted (invalid/expired token, etc.).
|
|
67
|
+
|
|
68
|
+
The message is deliberately generic for token failures to avoid leaking
|
|
69
|
+
whether a given token exists (enumeration).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Deliberately generic so an invalid/expired/consumed token never reveals whether
|
|
74
|
+
# a given token exists (enumeration). Shared by every failure path in the accept
|
|
75
|
+
# flow so the three sites cannot drift apart (S1192).
|
|
76
|
+
_INVALID_INVITE_MESSAGE = "This invitation link is invalid or has expired."
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Owner counting / last-owner guard
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def workspace_owner_user_ids(exclude_user_id: _PK | None = None) -> set[Any]:
|
|
85
|
+
"""Return the set of user ids that currently hold workspace-owner authority.
|
|
86
|
+
|
|
87
|
+
An owner is an active explicit OWNER ``WorkspaceMembership`` **or** an active
|
|
88
|
+
Django superuser with no explicit (overriding) membership row. ``exclude_user_id``
|
|
89
|
+
drops a candidate so callers can ask "who would remain if I changed this user?".
|
|
90
|
+
"""
|
|
91
|
+
User = get_user_model()
|
|
92
|
+
owners: set[Any] = set(
|
|
93
|
+
WorkspaceMembership.objects.filter(
|
|
94
|
+
role=WorkspaceRole.OWNER,
|
|
95
|
+
status=MemberStatus.ACTIVE,
|
|
96
|
+
is_deleted=False,
|
|
97
|
+
).values_list("user_id", flat=True)
|
|
98
|
+
)
|
|
99
|
+
# Active superusers are implicit owners unless an explicit row overrides them
|
|
100
|
+
# (that row's role is already reflected above if it is OWNER).
|
|
101
|
+
explicit_ids = set(
|
|
102
|
+
WorkspaceMembership.objects.filter(is_deleted=False).values_list("user_id", flat=True)
|
|
103
|
+
)
|
|
104
|
+
for uid in User.objects.filter(is_superuser=True, is_active=True).values_list("id", flat=True):
|
|
105
|
+
if uid not in explicit_ids:
|
|
106
|
+
owners.add(uid)
|
|
107
|
+
if exclude_user_id is not None:
|
|
108
|
+
owners.discard(exclude_user_id)
|
|
109
|
+
return owners
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def would_strand_workspace(user_id: _PK) -> bool:
|
|
113
|
+
"""True if removing/demoting ``user_id`` would leave the workspace ownerless."""
|
|
114
|
+
return len(workspace_owner_user_ids(exclude_user_id=user_id)) == 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Operational audit log (ADR-0157, #859)
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _actor_label(actor: Any) -> str:
|
|
123
|
+
"""Human-readable display for an audit actor, captured at event time.
|
|
124
|
+
|
|
125
|
+
Denormalized onto the row so the log stays readable after the user is deleted
|
|
126
|
+
(the ``actor`` FK then resolves to NULL). Prefers a full name, then email,
|
|
127
|
+
then username; an absent actor (system action) yields an empty string.
|
|
128
|
+
"""
|
|
129
|
+
if actor is None or not getattr(actor, "pk", None):
|
|
130
|
+
return ""
|
|
131
|
+
full_name = ""
|
|
132
|
+
get_full_name = getattr(actor, "get_full_name", None)
|
|
133
|
+
if callable(get_full_name):
|
|
134
|
+
full_name = (get_full_name() or "").strip()
|
|
135
|
+
label = full_name or getattr(actor, "email", "") or actor.get_username()
|
|
136
|
+
return label[:255]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def record_audit_event(
|
|
140
|
+
*,
|
|
141
|
+
event_type: str,
|
|
142
|
+
actor: Any,
|
|
143
|
+
target_type: str = "",
|
|
144
|
+
target_id: Any = None,
|
|
145
|
+
target_label: str = "",
|
|
146
|
+
metadata: dict[str, Any] | None = None,
|
|
147
|
+
) -> AuditEvent:
|
|
148
|
+
"""Write one operational ``AuditEvent`` and fan it out to enterprise receivers.
|
|
149
|
+
|
|
150
|
+
The single choke point for the OSS audit log (ADR-0157): every emission site
|
|
151
|
+
calls this rather than ``AuditEvent.objects.create`` directly. The row is
|
|
152
|
+
written synchronously inside the caller's transaction (so it rolls back with a
|
|
153
|
+
failed action — the log never claims an action that did not happen), and the
|
|
154
|
+
``audit_event_created`` signal is fired from ``transaction.on_commit`` via
|
|
155
|
+
``dispatch_extension_signal`` so a raising enterprise receiver is contained
|
|
156
|
+
and logged, and can never break the OSS write path. OSS itself connects no
|
|
157
|
+
receiver.
|
|
158
|
+
"""
|
|
159
|
+
event = AuditEvent.objects.create(
|
|
160
|
+
actor=actor if (actor is not None and getattr(actor, "pk", None)) else None,
|
|
161
|
+
actor_label=_actor_label(actor),
|
|
162
|
+
event_type=event_type,
|
|
163
|
+
target_type=target_type,
|
|
164
|
+
target_id=target_id,
|
|
165
|
+
target_label=(target_label or "")[:512],
|
|
166
|
+
metadata=metadata or {},
|
|
167
|
+
)
|
|
168
|
+
transaction.on_commit(
|
|
169
|
+
lambda: dispatch_extension_signal(audit_event_created, sender=AuditEvent, audit_event=event)
|
|
170
|
+
)
|
|
171
|
+
return event
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Lifecycle: transfer ownership / export / delete (ADR-0174, #641)
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def transfer_workspace_ownership(*, new_owner: Any, actor: Any) -> WorkspaceMembership:
|
|
180
|
+
"""Atomically promote a workspace member to OWNER, demote the actor to ADMIN.
|
|
181
|
+
|
|
182
|
+
Mirrors :func:`access.services.transfer_project_ownership`. The target must
|
|
183
|
+
already be an **active** workspace member — requiring an explicit membership
|
|
184
|
+
forces an invite step and keeps a clean "joined → promoted" audit sequence.
|
|
185
|
+
|
|
186
|
+
The actor's explicit OWNER row (if any) is demoted to ADMIN. If the actor is
|
|
187
|
+
an *implicit* owner (an active superuser with no explicit row), nothing is
|
|
188
|
+
demoted — there is no row to change and the superuser bootstrap is intentional.
|
|
189
|
+
Promoting the target always leaves at least one owner, so this never strands
|
|
190
|
+
the workspace.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
new_owner: The user who will become OWNER.
|
|
194
|
+
actor: The current owner initiating the transfer.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The new owner's ``WorkspaceMembership`` row.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
ValidationError: if ``new_owner`` is the actor, or has no active membership.
|
|
201
|
+
"""
|
|
202
|
+
if new_owner.pk == actor.pk:
|
|
203
|
+
raise ValidationError("You already own this workspace.")
|
|
204
|
+
|
|
205
|
+
# Defense in depth — the view gates with ``IsWorkspaceOwner``, but the service
|
|
206
|
+
# is reusable (management commands, signals, future endpoints). Without this an
|
|
207
|
+
# actor who is not actually an owner would promote the target while no one is
|
|
208
|
+
# demoted, minting an extra owner. Assert authority before any state change.
|
|
209
|
+
if actor.pk not in workspace_owner_user_ids():
|
|
210
|
+
raise ValidationError("Only an existing workspace Owner can transfer ownership.")
|
|
211
|
+
|
|
212
|
+
with transaction.atomic():
|
|
213
|
+
try:
|
|
214
|
+
target = WorkspaceMembership.objects.select_for_update().get(
|
|
215
|
+
user=new_owner,
|
|
216
|
+
is_deleted=False,
|
|
217
|
+
)
|
|
218
|
+
except WorkspaceMembership.DoesNotExist as exc:
|
|
219
|
+
raise ValidationError(
|
|
220
|
+
"The new owner must already be an active workspace member."
|
|
221
|
+
) from exc
|
|
222
|
+
if target.status == MemberStatus.DEACTIVATED:
|
|
223
|
+
raise ValidationError("The new owner must be an active workspace member.")
|
|
224
|
+
|
|
225
|
+
# Promote the target first so the workspace is never momentarily ownerless.
|
|
226
|
+
if target.role != WorkspaceRole.OWNER:
|
|
227
|
+
target.role = WorkspaceRole.OWNER
|
|
228
|
+
target.role_changed_at = timezone.now()
|
|
229
|
+
target.save(update_fields=["role", "role_changed_at"])
|
|
230
|
+
|
|
231
|
+
# Demote the actor's explicit OWNER row, if they have one.
|
|
232
|
+
actor_row = (
|
|
233
|
+
WorkspaceMembership.objects.select_for_update()
|
|
234
|
+
.filter(user=actor, is_deleted=False)
|
|
235
|
+
.first()
|
|
236
|
+
)
|
|
237
|
+
if actor_row is not None and actor_row.role == WorkspaceRole.OWNER:
|
|
238
|
+
actor_row.role = WorkspaceRole.ADMIN
|
|
239
|
+
actor_row.role_changed_at = timezone.now()
|
|
240
|
+
actor_row.save(update_fields=["role", "role_changed_at"])
|
|
241
|
+
# The demoted Owner can no longer vouch for invites at Admin or above.
|
|
242
|
+
from trueppm_api.apps.access.services import revoke_pending_workspace_invites
|
|
243
|
+
|
|
244
|
+
revoke_pending_workspace_invites(actor, actor=actor, min_role=WorkspaceRole.ADMIN)
|
|
245
|
+
|
|
246
|
+
# Audit inside the same transaction so the row rolls back with a failed
|
|
247
|
+
# transfer. target_id is left null — a User PK is an int, not a UUID — and
|
|
248
|
+
# the new owner's id is carried in metadata instead (ADR-0157).
|
|
249
|
+
record_audit_event(
|
|
250
|
+
event_type=AuditEventType.OWNERSHIP_TRANSFERRED,
|
|
251
|
+
actor=actor,
|
|
252
|
+
target_type="member",
|
|
253
|
+
target_label=_actor_label(new_owner),
|
|
254
|
+
metadata={"new_owner_user_id": new_owner.pk},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return target
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def enqueue_workspace_export(*, requested_by: Any) -> WorkspaceExportJob:
|
|
261
|
+
"""Create an export job row and best-effort dispatch the Celery task (ADR-0174).
|
|
262
|
+
|
|
263
|
+
Follows the transactional-outbox convention (ADR-0080): the row commits with
|
|
264
|
+
the request; ``.delay()`` is attempted in ``transaction.on_commit`` and broker
|
|
265
|
+
errors are swallowed because ``drain_workspace_exports`` re-dispatches stuck
|
|
266
|
+
``pending`` rows. ``.delay()`` is only ever called from here and the drain.
|
|
267
|
+
|
|
268
|
+
De-dupes in-flight work: a full-workspace archive is expensive, so if an export
|
|
269
|
+
is already ``pending``/``running`` the existing job is returned rather than
|
|
270
|
+
queuing a second build (also bounds an owner triggering repeated exports).
|
|
271
|
+
"""
|
|
272
|
+
existing = WorkspaceExportJob.objects.filter(
|
|
273
|
+
status__in=[ExportJobStatus.PENDING, ExportJobStatus.RUNNING]
|
|
274
|
+
).first()
|
|
275
|
+
if existing is not None:
|
|
276
|
+
return existing
|
|
277
|
+
|
|
278
|
+
job = WorkspaceExportJob.objects.create(requested_by=requested_by)
|
|
279
|
+
|
|
280
|
+
# Audit only the path that actually queues a new export — the dedupe branch
|
|
281
|
+
# above returns an in-flight job without minting work, so it is not an event.
|
|
282
|
+
record_audit_event(
|
|
283
|
+
event_type=AuditEventType.EXPORT_TRIGGERED,
|
|
284
|
+
actor=requested_by,
|
|
285
|
+
target_type="workspace_export",
|
|
286
|
+
target_id=job.id,
|
|
287
|
+
target_label="Workspace export",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _dispatch() -> None:
|
|
291
|
+
from trueppm_api.apps.workspace.tasks import run_workspace_export
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
run_workspace_export.delay(str(job.id))
|
|
295
|
+
except Exception: # pragma: no cover - broker-down path, drain recovers
|
|
296
|
+
logger.warning(
|
|
297
|
+
"broker unavailable; drain_workspace_exports will pick up export %s", job.id
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
transaction.on_commit(_dispatch)
|
|
301
|
+
return job
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def purge_workspace() -> None:
|
|
305
|
+
"""Hard-delete every workspace-scoped row and the singleton itself (ADR-0174).
|
|
306
|
+
|
|
307
|
+
Because ``Workspace.load()`` re-materializes the singleton on next access,
|
|
308
|
+
deleting the row is a factory reset: the next request gets a fresh default
|
|
309
|
+
workspace. Deletes run in FK-safe order in one transaction — ``PROTECT``
|
|
310
|
+
membership rows (project/program) and ``Project``-referenced ``Calendar`` rows
|
|
311
|
+
must be removed before their referents, so a naive ``Workspace.delete()``
|
|
312
|
+
cascade is not enough.
|
|
313
|
+
"""
|
|
314
|
+
# Imported lazily: this is the only place the workspace service reaches across
|
|
315
|
+
# into the projects/access/resources models, and doing it at module load would
|
|
316
|
+
# widen the import graph for every workspace request.
|
|
317
|
+
from trueppm_api.apps.access.models import ProgramMembership
|
|
318
|
+
from trueppm_api.apps.projects.models import Calendar, Program, Project
|
|
319
|
+
from trueppm_api.apps.resources.models import Resource, Skill
|
|
320
|
+
|
|
321
|
+
with transaction.atomic():
|
|
322
|
+
# Group access cascade first (GroupProject/GroupMembership → Group).
|
|
323
|
+
GroupProject.objects.all().delete()
|
|
324
|
+
GroupMembership.objects.all().delete()
|
|
325
|
+
from trueppm_api.apps.workspace.models import Group
|
|
326
|
+
|
|
327
|
+
Group.objects.all().delete()
|
|
328
|
+
WorkspaceInvite.objects.all().delete()
|
|
329
|
+
WorkspaceMembership.objects.all().delete()
|
|
330
|
+
# PROTECT memberships must precede their parents.
|
|
331
|
+
ProjectMembership.objects.all().delete()
|
|
332
|
+
ProgramMembership.objects.all().delete()
|
|
333
|
+
# Projects cascade tasks/deps/baselines/sprints/risks/attachments/etc.
|
|
334
|
+
Project.objects.all().delete()
|
|
335
|
+
Program.objects.all().delete()
|
|
336
|
+
# Workspace-global flat tables (no project/workspace FK).
|
|
337
|
+
Resource.objects.all().delete()
|
|
338
|
+
Skill.objects.all().delete()
|
|
339
|
+
# Calendars are PROTECT-referenced by Project; safe now projects are gone.
|
|
340
|
+
Calendar.objects.all().delete()
|
|
341
|
+
WorkspaceExportJob.objects.all().delete()
|
|
342
|
+
Workspace.objects.all().delete()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# Group → project access cascade (ADR-0087 §5)
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _build_confer_map(
|
|
351
|
+
user_ids: set[Any], project_ids: set[Any]
|
|
352
|
+
) -> dict[tuple[Any, Any], tuple[int, Any]]:
|
|
353
|
+
"""Bulk-compute the conferred project ``Role`` per (user, project) pair.
|
|
354
|
+
|
|
355
|
+
Returns ``{(user_id, project_id): (role, source_group_id)}`` for the highest
|
|
356
|
+
role any active group the user belongs to confers on that project. Replaces a
|
|
357
|
+
per-pair query with two bulk reads (GroupProject + GroupMembership), so a
|
|
358
|
+
cascade over M members × P projects costs O(1) queries, not O(M·P).
|
|
359
|
+
"""
|
|
360
|
+
gp_rows = list(
|
|
361
|
+
GroupProject.objects.filter(
|
|
362
|
+
project_id__in=project_ids, group__is_deleted=False
|
|
363
|
+
).values_list("project_id", "group_id", "role")
|
|
364
|
+
)
|
|
365
|
+
involved_group_ids = {gid for _, gid, _ in gp_rows}
|
|
366
|
+
members_by_group: dict[Any, set[Any]] = {}
|
|
367
|
+
for gid, uid in GroupMembership.objects.filter(
|
|
368
|
+
group_id__in=involved_group_ids, is_deleted=False, user_id__in=user_ids
|
|
369
|
+
).values_list("group_id", "user_id"):
|
|
370
|
+
members_by_group.setdefault(gid, set()).add(uid)
|
|
371
|
+
|
|
372
|
+
confer: dict[tuple[Any, Any], tuple[int, Any]] = {}
|
|
373
|
+
for project_id, gid, role in gp_rows:
|
|
374
|
+
for uid in members_by_group.get(gid, ()):
|
|
375
|
+
key = (uid, project_id)
|
|
376
|
+
current = confer.get(key)
|
|
377
|
+
if current is None or role > current[0]:
|
|
378
|
+
confer[key] = (int(role), gid)
|
|
379
|
+
return confer
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _sync_existing_membership(
|
|
383
|
+
existing: ProjectMembership,
|
|
384
|
+
confer_role: int,
|
|
385
|
+
src_group_id: Any,
|
|
386
|
+
now: Any,
|
|
387
|
+
user_id: Any,
|
|
388
|
+
project_id: Any,
|
|
389
|
+
events: list[tuple[Any, str, dict[str, Any]]],
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Role-sync an existing *active* group-derived row to the conferred role.
|
|
392
|
+
|
|
393
|
+
Only broadcasts ``member_role_changed`` when the role actually moved — a pure
|
|
394
|
+
source_group reattribution (e.g. one of two overlapping groups removed, role
|
|
395
|
+
unchanged) is invisible to board consumers.
|
|
396
|
+
"""
|
|
397
|
+
changed: list[str] = []
|
|
398
|
+
role_changed = existing.role != confer_role
|
|
399
|
+
if role_changed:
|
|
400
|
+
existing.role = confer_role
|
|
401
|
+
existing.role_changed_at = now
|
|
402
|
+
changed += ["role", "role_changed_at"]
|
|
403
|
+
if existing.source_group_id != src_group_id:
|
|
404
|
+
existing.source_group_id = src_group_id
|
|
405
|
+
changed.append("source_group")
|
|
406
|
+
if not changed:
|
|
407
|
+
return
|
|
408
|
+
existing.save(update_fields=changed)
|
|
409
|
+
if role_changed:
|
|
410
|
+
events.append(
|
|
411
|
+
(
|
|
412
|
+
project_id,
|
|
413
|
+
"member_role_changed",
|
|
414
|
+
{
|
|
415
|
+
"membership_id": str(existing.pk),
|
|
416
|
+
"user_id": str(user_id),
|
|
417
|
+
"role": existing.role,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _reconcile_pair(
|
|
424
|
+
user_id: Any,
|
|
425
|
+
project_id: Any,
|
|
426
|
+
confer_entry: tuple[int, Any] | None,
|
|
427
|
+
existing: ProjectMembership | None,
|
|
428
|
+
now: Any,
|
|
429
|
+
events: list[tuple[Any, str, dict[str, Any]]],
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Reconcile one (user, project) pair from pre-fetched confer + existing data.
|
|
432
|
+
|
|
433
|
+
A direct membership (``source_group IS NULL``) always wins and is never
|
|
434
|
+
touched. Otherwise the group-derived row is created, role-synced, resurrected,
|
|
435
|
+
or removed to match the conferred role. Board events are appended to ``events``.
|
|
436
|
+
"""
|
|
437
|
+
confer_role, src_group_id = confer_entry if confer_entry is not None else (None, None)
|
|
438
|
+
|
|
439
|
+
# Direct grant wins — group reconciliation must not alter or revoke it.
|
|
440
|
+
if existing is not None and not existing.is_deleted and existing.source_group_id is None:
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
if confer_role is None:
|
|
444
|
+
if (
|
|
445
|
+
existing is not None
|
|
446
|
+
and not existing.is_deleted
|
|
447
|
+
and existing.source_group_id is not None
|
|
448
|
+
):
|
|
449
|
+
existing.soft_delete()
|
|
450
|
+
events.append(
|
|
451
|
+
(
|
|
452
|
+
project_id,
|
|
453
|
+
"member_removed",
|
|
454
|
+
{"membership_id": str(existing.pk), "user_id": str(user_id)},
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
if existing is None:
|
|
460
|
+
pm = ProjectMembership.objects.create(
|
|
461
|
+
project_id=project_id,
|
|
462
|
+
user_id=user_id,
|
|
463
|
+
role=confer_role,
|
|
464
|
+
source_group_id=src_group_id,
|
|
465
|
+
)
|
|
466
|
+
events.append(
|
|
467
|
+
(
|
|
468
|
+
project_id,
|
|
469
|
+
"member_added",
|
|
470
|
+
{"membership_id": str(pm.pk), "user_id": str(user_id), "role": confer_role},
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
elif existing.is_deleted:
|
|
474
|
+
# Resurrect a previously-removed row as group-derived (unique_together on
|
|
475
|
+
# (project, user) means we cannot insert a second row).
|
|
476
|
+
existing.is_deleted = False
|
|
477
|
+
existing.deleted_version = None
|
|
478
|
+
existing.role = confer_role
|
|
479
|
+
existing.source_group_id = src_group_id
|
|
480
|
+
existing.role_changed_at = now
|
|
481
|
+
existing.save()
|
|
482
|
+
events.append(
|
|
483
|
+
(
|
|
484
|
+
project_id,
|
|
485
|
+
"member_added",
|
|
486
|
+
{"membership_id": str(existing.pk), "user_id": str(user_id), "role": confer_role},
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
_sync_existing_membership(
|
|
491
|
+
existing, confer_role, src_group_id, now, user_id, project_id, events
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@transaction.atomic
|
|
496
|
+
def reconcile_group_access(group_id: Any) -> None:
|
|
497
|
+
"""Reconcile all affected (member × project) pairs for a group.
|
|
498
|
+
|
|
499
|
+
Considers the union of the group's current desired pairs (active members ×
|
|
500
|
+
linked projects) and any pair still attributed to this group in
|
|
501
|
+
``ProjectMembership.source_group`` (so removals are caught when a member or
|
|
502
|
+
project link is dropped, or the group is soft-deleted). Reconciliation is
|
|
503
|
+
global across groups (overlaps resolve to the highest conferred role) and is
|
|
504
|
+
driven by bulk reads — three queries regardless of group size.
|
|
505
|
+
"""
|
|
506
|
+
member_ids = list(
|
|
507
|
+
GroupMembership.objects.filter(group_id=group_id, is_deleted=False).values_list(
|
|
508
|
+
"user_id", flat=True
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
project_ids = list(
|
|
512
|
+
GroupProject.objects.filter(group_id=group_id).values_list("project_id", flat=True)
|
|
513
|
+
)
|
|
514
|
+
desired = {(u, p) for u in member_ids for p in project_ids}
|
|
515
|
+
attributed = set(
|
|
516
|
+
ProjectMembership.objects.filter(source_group_id=group_id, is_deleted=False).values_list(
|
|
517
|
+
"user_id", "project_id"
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
pairs = desired | attributed
|
|
521
|
+
if not pairs:
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
user_ids = {u for u, _ in pairs}
|
|
525
|
+
proj_ids = {p for _, p in pairs}
|
|
526
|
+
confer = _build_confer_map(user_ids, proj_ids)
|
|
527
|
+
# Lock the candidate membership rows once (one query), then reconcile in memory.
|
|
528
|
+
# order_by("pk"): access.views' `create` / `partial_update` lock ProjectMembership
|
|
529
|
+
# rows in ascending-pk order to avoid deadlocking against each other on a
|
|
530
|
+
# reversed actor/target pair (#3438) — this bulk lock joins the same
|
|
531
|
+
# convention rather than leaving Postgres to pick its own row order.
|
|
532
|
+
existing = {
|
|
533
|
+
(m.user_id, m.project_id): m
|
|
534
|
+
for m in ProjectMembership.objects.select_for_update()
|
|
535
|
+
.filter(user_id__in=user_ids, project_id__in=proj_ids)
|
|
536
|
+
.order_by("pk")
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
now = timezone.now()
|
|
540
|
+
events: list[tuple[Any, str, dict[str, Any]]] = []
|
|
541
|
+
for user_id, project_id in pairs:
|
|
542
|
+
_reconcile_pair(
|
|
543
|
+
user_id,
|
|
544
|
+
project_id,
|
|
545
|
+
confer.get((user_id, project_id)),
|
|
546
|
+
existing.get((user_id, project_id)),
|
|
547
|
+
now,
|
|
548
|
+
events,
|
|
549
|
+
)
|
|
550
|
+
_broadcast_membership_events(events)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _broadcast_membership_events(events: list[tuple[Any, str, dict[str, Any]]]) -> None:
|
|
554
|
+
"""Defer best-effort board broadcasts for each cascade-affected project."""
|
|
555
|
+
if not events:
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
def _send() -> None:
|
|
559
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
560
|
+
|
|
561
|
+
for project_id, event_type, payload in events:
|
|
562
|
+
broadcast_board_event(str(project_id), event_type, payload)
|
|
563
|
+
|
|
564
|
+
transaction.on_commit(_send)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# Invites
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def create_invite(
|
|
573
|
+
*,
|
|
574
|
+
workspace: Workspace,
|
|
575
|
+
email: str,
|
|
576
|
+
role: int,
|
|
577
|
+
invited_by: Any,
|
|
578
|
+
) -> WorkspaceInvite:
|
|
579
|
+
"""Create a pending invite with a one-time token and queue its email.
|
|
580
|
+
|
|
581
|
+
The raw token is generated here, hashed for the durable credential, and kept
|
|
582
|
+
in ``email_token`` only until the drain sends the link (ADR-0087 §4).
|
|
583
|
+
|
|
584
|
+
The drain is nudged from ``on_commit`` so the email leaves in seconds rather
|
|
585
|
+
than on the next Beat tick (#4191). The nudge is only a latency optimization:
|
|
586
|
+
the row is the durable record and the periodic drain still sends it if the
|
|
587
|
+
broker drops the nudge. Deferring to commit means a rolled-back create never
|
|
588
|
+
enqueues anything, and the drain — the only sender — is lock-serialized, so the
|
|
589
|
+
nudge cannot cause a second send.
|
|
590
|
+
"""
|
|
591
|
+
raw_token = secrets.token_urlsafe(32)
|
|
592
|
+
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
|
593
|
+
invite = WorkspaceInvite.objects.create(
|
|
594
|
+
workspace=workspace,
|
|
595
|
+
email=email,
|
|
596
|
+
role=role,
|
|
597
|
+
token_hash=token_hash,
|
|
598
|
+
email_token=raw_token,
|
|
599
|
+
invited_by=invited_by,
|
|
600
|
+
expires_at=WorkspaceInvite.default_expiry(),
|
|
601
|
+
email_pending=True,
|
|
602
|
+
)
|
|
603
|
+
# Registered after the INSERT: outside an atomic block on_commit runs at once,
|
|
604
|
+
# and the nudged drain must be able to see the row.
|
|
605
|
+
transaction.on_commit(drain_invite_emails_soon)
|
|
606
|
+
return invite
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Statuses an admin may resend (ADR-0149). PENDING covers a lost/bounced live
|
|
610
|
+
# invite; FAILED is a send-exhausted invite — resend is its recovery path. An
|
|
611
|
+
# ACCEPTED/REVOKED/EXPIRED invite is intentionally *not* revivable.
|
|
612
|
+
RESENDABLE_INVITE_STATUSES = (InviteStatus.PENDING, InviteStatus.FAILED)
|
|
613
|
+
|
|
614
|
+
_RESEND_UPDATE_FIELDS = [
|
|
615
|
+
"token_hash",
|
|
616
|
+
"email_token",
|
|
617
|
+
"status",
|
|
618
|
+
"expires_at",
|
|
619
|
+
"email_pending",
|
|
620
|
+
"email_sent_at",
|
|
621
|
+
"email_failed_at",
|
|
622
|
+
"email_attempts",
|
|
623
|
+
]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _reissue_invite_token(invite: WorkspaceInvite) -> None:
|
|
627
|
+
"""Reset an (unsaved, locked) invite back into the drain-eligible queued shape.
|
|
628
|
+
|
|
629
|
+
Regenerates the one-time token — so any link in a previously-sent email stops
|
|
630
|
+
working, which is the correct posture for a re-issue (ADR-0149) — and clears the
|
|
631
|
+
outbox columns (``email_pending`` on, ``email_sent_at``/``email_failed_at`` off,
|
|
632
|
+
attempts zeroed) so the existing ``drain_invite_emails`` picks the row up on its
|
|
633
|
+
next 30 s tick (or sooner, when the view nudges it on commit).
|
|
634
|
+
"""
|
|
635
|
+
raw_token = secrets.token_urlsafe(32)
|
|
636
|
+
invite.token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
|
|
637
|
+
invite.email_token = raw_token
|
|
638
|
+
invite.status = InviteStatus.PENDING
|
|
639
|
+
invite.expires_at = WorkspaceInvite.default_expiry()
|
|
640
|
+
invite.email_pending = True
|
|
641
|
+
invite.email_sent_at = None
|
|
642
|
+
invite.email_failed_at = None
|
|
643
|
+
invite.email_attempts = 0
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _is_in_flight(invite: WorkspaceInvite) -> bool:
|
|
647
|
+
"""True if a send is already queued and not yet sent (idempotent-resend guard).
|
|
648
|
+
|
|
649
|
+
A double-click on Resend (or a created-but-not-yet-drained invite) must not
|
|
650
|
+
re-issue a token the drain is about to send with the *current* token, so resend
|
|
651
|
+
is a no-op for these rows.
|
|
652
|
+
"""
|
|
653
|
+
return invite.email_pending and invite.email_sent_at is None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def drain_invite_emails_soon() -> None:
|
|
657
|
+
"""Best-effort nudge the invite-email drain to run now instead of next tick.
|
|
658
|
+
|
|
659
|
+
Every queued invite (a fresh create or a resend) is drain-eligible as soon as it
|
|
660
|
+
commits — the drain has no age floor (#4191) — so this shortens the wait from the
|
|
661
|
+
30 s Beat cadence to "right after commit". Broker errors are swallowed: the
|
|
662
|
+
periodic ``drain_invite_emails`` is the durability guarantee, this is only an
|
|
663
|
+
optimization (ADR-0149). If a drain already holds the singleton lock the nudged
|
|
664
|
+
run skips, and the row goes on the next tick. Call inside
|
|
665
|
+
``transaction.on_commit``.
|
|
666
|
+
"""
|
|
667
|
+
from trueppm_api.apps.workspace.tasks import drain_invite_emails
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
drain_invite_emails.delay()
|
|
671
|
+
except Exception: # pragma: no cover - broker-down path, periodic drain recovers
|
|
672
|
+
logger.warning("broker unavailable; periodic drain_invite_emails will send queued invites")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def resend_invite(invite_id: _PK) -> WorkspaceInvite | None:
|
|
676
|
+
"""Re-queue one resendable invite's email with a fresh token (ADR-0149).
|
|
677
|
+
|
|
678
|
+
Returns the invite on success, the unchanged invite when it is already in
|
|
679
|
+
flight (idempotent no-op), or ``None`` when it does not exist or is not
|
|
680
|
+
resendable (accepted/revoked/expired) — the view maps ``None`` to 409/404.
|
|
681
|
+
Locks the row so a concurrent double-submit re-issues at most once.
|
|
682
|
+
"""
|
|
683
|
+
with transaction.atomic():
|
|
684
|
+
invite = WorkspaceInvite.objects.select_for_update().filter(pk=invite_id).first()
|
|
685
|
+
if invite is None or invite.status not in RESENDABLE_INVITE_STATUSES:
|
|
686
|
+
return None
|
|
687
|
+
# Never silently re-attribute: a fresh token for a sender who could not
|
|
688
|
+
# accept their own invite today would launder the grant (#4589).
|
|
689
|
+
if not _inviter_still_authorized(invite):
|
|
690
|
+
raise InviteSenderUnauthorizedError
|
|
691
|
+
if _is_in_flight(invite):
|
|
692
|
+
return invite
|
|
693
|
+
_reissue_invite_token(invite)
|
|
694
|
+
invite.save(update_fields=_RESEND_UPDATE_FIELDS)
|
|
695
|
+
return invite
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def resend_all_pending(workspace: Workspace) -> int:
|
|
699
|
+
"""Re-queue every resendable invite in one transaction; return the count.
|
|
700
|
+
|
|
701
|
+
Bundled into a single transaction so "Resend all" is one throttle bucket hit
|
|
702
|
+
(ADR-0149) and can never email-bomb regardless of how many invites are pending.
|
|
703
|
+
In-flight rows are skipped, so the returned count reflects only rows actually
|
|
704
|
+
re-issued.
|
|
705
|
+
"""
|
|
706
|
+
count = 0
|
|
707
|
+
with transaction.atomic():
|
|
708
|
+
invites = list(
|
|
709
|
+
WorkspaceInvite.objects.select_for_update().filter(
|
|
710
|
+
workspace=workspace,
|
|
711
|
+
status__in=RESENDABLE_INVITE_STATUSES,
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
for invite in invites:
|
|
715
|
+
if _is_in_flight(invite) or not _inviter_still_authorized(invite):
|
|
716
|
+
continue
|
|
717
|
+
_reissue_invite_token(invite)
|
|
718
|
+
invite.save(update_fields=_RESEND_UPDATE_FIELDS)
|
|
719
|
+
count += 1
|
|
720
|
+
return count
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# ---------------------------------------------------------------------------
|
|
724
|
+
# Workspace logo (ADR-0149, #969)
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _delete_storage_file_on_commit(name: str) -> None:
|
|
729
|
+
"""Best-effort delete of a storage blob after the current transaction commits.
|
|
730
|
+
|
|
731
|
+
Deferred to ``on_commit`` so a rolled-back logo write never orphans the *new*
|
|
732
|
+
file by deleting the *old* one prematurely; storage drift (already gone) is
|
|
733
|
+
logged, not raised.
|
|
734
|
+
"""
|
|
735
|
+
if not name:
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
def _delete() -> None:
|
|
739
|
+
from django.core.files.storage import default_storage
|
|
740
|
+
|
|
741
|
+
try:
|
|
742
|
+
default_storage.delete(name)
|
|
743
|
+
except OSError: # pragma: no cover - storage drift, nothing to clean up
|
|
744
|
+
logger.warning("could not delete old workspace logo file %s", name)
|
|
745
|
+
|
|
746
|
+
transaction.on_commit(_delete)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def set_workspace_logo(*, file: Any, mime: str) -> Workspace:
|
|
750
|
+
"""Store a new workspace logo, deleting the previously-stored file on commit.
|
|
751
|
+
|
|
752
|
+
The validated content type is pinned in ``logo_mime`` so the public serve
|
|
753
|
+
endpoint sets Content-Type from a trusted column rather than re-sniffing. The
|
|
754
|
+
UUID-prefixed ``upload_to`` guarantees the new key differs from the old, so the
|
|
755
|
+
old blob is always safe to delete once the row commits (ADR-0149).
|
|
756
|
+
"""
|
|
757
|
+
ws = Workspace.load()
|
|
758
|
+
old_name = ws.logo.name
|
|
759
|
+
ws.logo = file
|
|
760
|
+
ws.logo_mime = mime
|
|
761
|
+
ws.save(update_fields=["logo", "logo_mime", "updated_at"])
|
|
762
|
+
if old_name and old_name != ws.logo.name:
|
|
763
|
+
_delete_storage_file_on_commit(old_name)
|
|
764
|
+
return ws
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def clear_workspace_logo() -> Workspace:
|
|
768
|
+
"""Remove the workspace logo and delete its stored file on commit (ADR-0149)."""
|
|
769
|
+
ws = Workspace.load()
|
|
770
|
+
old_name = ws.logo.name
|
|
771
|
+
if old_name:
|
|
772
|
+
ws.logo = ""
|
|
773
|
+
ws.logo_mime = ""
|
|
774
|
+
ws.save(update_fields=["logo", "logo_mime", "updated_at"])
|
|
775
|
+
_delete_storage_file_on_commit(old_name)
|
|
776
|
+
return ws
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _load_pending_invite(token: str) -> WorkspaceInvite:
|
|
780
|
+
"""Resolve a token to a pending, unexpired invite; raise ``InviteError`` otherwise.
|
|
781
|
+
|
|
782
|
+
A lapsed (still-PENDING but expired) invite is marked ``EXPIRED`` via a
|
|
783
|
+
standalone autocommit ``update`` — not inside a provisioning ``atomic`` block —
|
|
784
|
+
so the state change survives the ``InviteError`` raise instead of being rolled
|
|
785
|
+
back and leaving the invite pending.
|
|
786
|
+
"""
|
|
787
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
788
|
+
try:
|
|
789
|
+
invite = WorkspaceInvite.objects.get(token_hash=token_hash)
|
|
790
|
+
except WorkspaceInvite.DoesNotExist as exc:
|
|
791
|
+
raise InviteError(_INVALID_INVITE_MESSAGE) from exc
|
|
792
|
+
|
|
793
|
+
if invite.status != InviteStatus.PENDING or invite.is_expired:
|
|
794
|
+
if invite.status == InviteStatus.PENDING and invite.is_expired:
|
|
795
|
+
WorkspaceInvite.objects.filter(pk=invite.pk).update(
|
|
796
|
+
status=InviteStatus.EXPIRED, email_pending=False, email_token=""
|
|
797
|
+
)
|
|
798
|
+
raise InviteError(_INVALID_INVITE_MESSAGE)
|
|
799
|
+
return invite
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _inviter_still_authorized(invite: WorkspaceInvite) -> bool:
|
|
803
|
+
"""True only if the inviter is an active user who still resolves to workspace Admin+.
|
|
804
|
+
|
|
805
|
+
Catches an account deactivated outside the app (``is_active=False`` directly),
|
|
806
|
+
a deleted inviter (``invited_by`` is SET_NULL), a demoted or deactivated
|
|
807
|
+
membership, and an invite whose role is no longer strictly below the sender's.
|
|
808
|
+
Uses ``workspace_role_for_user`` so the answer matches RBAC (including the
|
|
809
|
+
superuser-with-no-row bootstrap OWNER).
|
|
810
|
+
"""
|
|
811
|
+
from trueppm_api.apps.workspace.permissions import workspace_role_for_user
|
|
812
|
+
|
|
813
|
+
inviter = invite.invited_by
|
|
814
|
+
if inviter is None or not inviter.is_active:
|
|
815
|
+
return False
|
|
816
|
+
role = workspace_role_for_user(inviter)
|
|
817
|
+
# Mirror the create-time ceiling: an invite may only grant a role strictly below
|
|
818
|
+
# the sender's *current* role, so a demoted Owner cannot still mint Admins.
|
|
819
|
+
return role is not None and role >= WorkspaceRole.ADMIN and invite.role < role
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _create_invited_user(*, username: str, email: str, password: str) -> Any:
|
|
823
|
+
"""Mint a new account for an invitee, enforcing the configured password policy.
|
|
824
|
+
|
|
825
|
+
``create_user`` hashes but does NOT run ``AUTH_PASSWORD_VALIDATORS``, and the
|
|
826
|
+
serializer accepts any non-empty string — so the policy is enforced here before
|
|
827
|
+
minting the account. Without this an unauthenticated invitee could set a
|
|
828
|
+
trivially-guessable password.
|
|
829
|
+
"""
|
|
830
|
+
User = get_user_model()
|
|
831
|
+
try:
|
|
832
|
+
validate_password(password)
|
|
833
|
+
except ValidationError as exc:
|
|
834
|
+
raise InviteError(" ".join(exc.messages)) from exc
|
|
835
|
+
try:
|
|
836
|
+
return User.objects.create_user(username=username, email=email, password=password)
|
|
837
|
+
except IntegrityError as exc:
|
|
838
|
+
raise InviteError("That username is already taken.") from exc
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _link_existing_membership(
|
|
842
|
+
membership: WorkspaceMembership, invite: WorkspaceInvite, user: Any, now: Any
|
|
843
|
+
) -> None:
|
|
844
|
+
"""Elevate (never reactivate) an existing membership when replaying an invite.
|
|
845
|
+
|
|
846
|
+
A deactivated member must NOT be silently reactivated (or re-elevated) by
|
|
847
|
+
replaying a pending invite — that would let an admin's deactivation be undone
|
|
848
|
+
without admin consent. Reactivation is an explicit admin action; refuse the
|
|
849
|
+
invite instead. Otherwise the role is raised (never lowered) to the invite's.
|
|
850
|
+
"""
|
|
851
|
+
if membership.status == MemberStatus.DEACTIVATED:
|
|
852
|
+
raise InviteError(
|
|
853
|
+
"Membership is deactivated; an admin must reactivate it before this invite can be used."
|
|
854
|
+
)
|
|
855
|
+
if invite.role <= membership.role:
|
|
856
|
+
return
|
|
857
|
+
old_role = membership.role
|
|
858
|
+
membership.role = invite.role
|
|
859
|
+
membership.role_changed_at = now
|
|
860
|
+
membership.save()
|
|
861
|
+
record_audit_event(
|
|
862
|
+
event_type=AuditEventType.MEMBER_ROLE_CHANGED,
|
|
863
|
+
actor=user,
|
|
864
|
+
target_type="member",
|
|
865
|
+
target_label=_actor_label(user),
|
|
866
|
+
metadata={
|
|
867
|
+
"old_role": WorkspaceRole(old_role).label,
|
|
868
|
+
"new_role": WorkspaceRole(membership.role).label,
|
|
869
|
+
"source": "invite",
|
|
870
|
+
},
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def accept_invite(*, token: str, username: str = "", password: str = "") -> Any:
|
|
875
|
+
"""Provision (or link) a user and create their workspace membership.
|
|
876
|
+
|
|
877
|
+
Looks the invite up by token hash, validates it is pending and unexpired,
|
|
878
|
+
then atomically links an existing user matching the invite email or creates
|
|
879
|
+
a new account from ``username``/``password``. Idempotent under a double-submit:
|
|
880
|
+
the status flips ``pending → accepted`` under a row lock.
|
|
881
|
+
|
|
882
|
+
Raises:
|
|
883
|
+
InviteError: invalid/expired token, or account-creation conflict.
|
|
884
|
+
"""
|
|
885
|
+
invite = _load_pending_invite(token)
|
|
886
|
+
|
|
887
|
+
User = get_user_model()
|
|
888
|
+
user = User.objects.filter(email__iexact=invite.email).first()
|
|
889
|
+
if user is None and (not username or not password):
|
|
890
|
+
raise InviteError("A username and password are required to accept this invitation.")
|
|
891
|
+
|
|
892
|
+
with transaction.atomic():
|
|
893
|
+
# Re-fetch under a row lock to make a double-submit create exactly one
|
|
894
|
+
# membership (the second caller sees status != PENDING and bails).
|
|
895
|
+
invite = WorkspaceInvite.objects.select_for_update().get(pk=invite.pk)
|
|
896
|
+
if invite.status != InviteStatus.PENDING:
|
|
897
|
+
raise InviteError(_INVALID_INVITE_MESSAGE)
|
|
898
|
+
# Defense in depth behind the revocation sweep (#4589): the grant is only as
|
|
899
|
+
# good as its minter's standing *now*. Same body as any invalid invite.
|
|
900
|
+
if not _inviter_still_authorized(invite):
|
|
901
|
+
raise InviteError(_INVALID_INVITE_MESSAGE)
|
|
902
|
+
|
|
903
|
+
if user is None:
|
|
904
|
+
user = _create_invited_user(
|
|
905
|
+
username=username, email=invite.email.lower(), password=password
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
now = timezone.now()
|
|
909
|
+
membership, created = WorkspaceMembership.objects.get_or_create(
|
|
910
|
+
workspace=invite.workspace,
|
|
911
|
+
user=user,
|
|
912
|
+
defaults={"role": invite.role, "status": MemberStatus.ACTIVE},
|
|
913
|
+
)
|
|
914
|
+
if created:
|
|
915
|
+
# Self-service join: the actor is the invitee provisioning their own
|
|
916
|
+
# membership via a token (the endpoint is unauthenticated). Audited
|
|
917
|
+
# inside the provisioning transaction so it rolls back with it.
|
|
918
|
+
record_audit_event(
|
|
919
|
+
event_type=AuditEventType.MEMBER_ADDED,
|
|
920
|
+
actor=user,
|
|
921
|
+
target_type="member",
|
|
922
|
+
target_label=_actor_label(user),
|
|
923
|
+
metadata={
|
|
924
|
+
"role": WorkspaceRole(membership.role).label,
|
|
925
|
+
"source": "invite",
|
|
926
|
+
},
|
|
927
|
+
)
|
|
928
|
+
else:
|
|
929
|
+
_link_existing_membership(membership, invite, user, now)
|
|
930
|
+
|
|
931
|
+
invite.status = InviteStatus.ACCEPTED
|
|
932
|
+
invite.accepted_at = now
|
|
933
|
+
invite.accepted_user = user
|
|
934
|
+
invite.email_pending = False
|
|
935
|
+
invite.email_token = "" # consume the raw token
|
|
936
|
+
invite.save(
|
|
937
|
+
update_fields=[
|
|
938
|
+
"status",
|
|
939
|
+
"accepted_at",
|
|
940
|
+
"accepted_user",
|
|
941
|
+
"email_pending",
|
|
942
|
+
"email_token",
|
|
943
|
+
]
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
# The invite's own durable record (#2911). Distinct from the MEMBER_ADDED
|
|
947
|
+
# row above and not redundant with it: that row's actor is the *invitee*,
|
|
948
|
+
# because this endpoint is unauthenticated and the invitee provisions
|
|
949
|
+
# themselves. It therefore cannot answer "who let them in". The inviter is
|
|
950
|
+
# carried here explicitly, denormalized to a label as well as an id so the
|
|
951
|
+
# answer survives the inviter's own off-boarding (the AuditEvent.actor_label
|
|
952
|
+
# idiom). Written inside the same transaction, so it rolls back with a
|
|
953
|
+
# failed accept rather than claiming a join that did not happen.
|
|
954
|
+
record_audit_event(
|
|
955
|
+
event_type=AuditEventType.INVITE_ACCEPTED,
|
|
956
|
+
actor=user,
|
|
957
|
+
target_type="invite",
|
|
958
|
+
target_id=invite.pk,
|
|
959
|
+
target_label=invite.email,
|
|
960
|
+
metadata={
|
|
961
|
+
"role": WorkspaceRole(invite.role).label,
|
|
962
|
+
"invited_by": _actor_label(invite.invited_by),
|
|
963
|
+
"invited_by_id": str(invite.invited_by_id) if invite.invited_by_id else None,
|
|
964
|
+
"invited_at": invite.created_at.isoformat(),
|
|
965
|
+
},
|
|
966
|
+
)
|
|
967
|
+
return user
|