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,910 @@
|
|
|
1
|
+
"""Team-signal privacy service layer (ADR-0104 §2–§4).
|
|
2
|
+
|
|
3
|
+
Every read-suppression decision and every policy write goes through here so the
|
|
4
|
+
gate lives in exactly one place (ADR-0104 risk #1: a forgotten gate on a future
|
|
5
|
+
signal). The model exposes no bare field write — `set_signal_audience` and
|
|
6
|
+
`raise_signal_ceiling` are the *only* writers of `signal_visibility`, which is the
|
|
7
|
+
sprint-sovereignty contract: a non-team principal can never move a project's
|
|
8
|
+
audience or ceiling.
|
|
9
|
+
|
|
10
|
+
**Reader-gate direction (security-critical).** The audience ladder measures how far
|
|
11
|
+
*up* (toward management) a signal has been shared. A requester reads a signal's
|
|
12
|
+
gated detail iff their reader band is **within** the audience — ``tier <= audience``
|
|
13
|
+
(suppress when ``tier > audience``). The team band (TEAM — ordinary members,
|
|
14
|
+
viewers, and the Scrum Master) is the floor, so the team **always** reads its own
|
|
15
|
+
signals (ordinary members' read is never regressed); the PM band (TEAM_SM_PM) is
|
|
16
|
+
excluded until the team raises a signal's audience to include it; a non-member is
|
|
17
|
+
outside the ladder and never reads. This delivers ADR-0104's core guarantee
|
|
18
|
+
(velocity/pulse are team-private by default, the PM does not read them automatically
|
|
19
|
+
— Morgan's hard-NO) while keeping every ordinary member's existing read intact.
|
|
20
|
+
|
|
21
|
+
This reconciles ADR-0104's internal tension: §1's "no regression" is about *ordinary
|
|
22
|
+
members*, and Decision-1's "the PM no longer reads velocity automatically" is the
|
|
23
|
+
intent — both hold under ``tier <= audience``. §2's earlier "suppress when tier <
|
|
24
|
+
audience" wording (which would have left the PM reading velocity by default) was the
|
|
25
|
+
inverted statement and is corrected here and in the ADR.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from datetime import timedelta
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
|
|
34
|
+
from django.conf import settings as django_settings
|
|
35
|
+
from django.db import transaction
|
|
36
|
+
from django.utils import timezone
|
|
37
|
+
from rest_framework.exceptions import ValidationError
|
|
38
|
+
|
|
39
|
+
from trueppm_api.apps.access.models import Role
|
|
40
|
+
from trueppm_api.apps.access.permissions import _membership_role
|
|
41
|
+
from trueppm_api.apps.projects.models import (
|
|
42
|
+
SIGNAL_DEFAULTS,
|
|
43
|
+
CeilingRaiseStatus,
|
|
44
|
+
CeilingVoteChoice,
|
|
45
|
+
ProjectSignalPrivacyPolicy,
|
|
46
|
+
SignalAudience,
|
|
47
|
+
SignalCeilingRaiseProposal,
|
|
48
|
+
SignalCeilingRaiseVote,
|
|
49
|
+
signal_audience_rank,
|
|
50
|
+
)
|
|
51
|
+
from trueppm_api.core.extension_providers import guard_single_provider
|
|
52
|
+
from trueppm_api.core.extension_signals import dispatch_extension_signal
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from rest_framework.request import Request
|
|
56
|
+
|
|
57
|
+
from trueppm_api.apps.projects.models import Project
|
|
58
|
+
|
|
59
|
+
# The signal keys this policy governs — the iteration set for ratchet / sharing.
|
|
60
|
+
SIGNAL_KEYS = tuple(SIGNAL_DEFAULTS.keys())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Default-posture seam — neutral OSS extension point (ADR-0029 slot; ADR-0104 §1)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
#
|
|
67
|
+
# When a project's policy is first created, its initial signal_visibility is
|
|
68
|
+
# seeded from a registered provider. The OSS core registers NO provider, so the
|
|
69
|
+
# community edition always falls back to the coded SIGNAL_DEFAULTS (all-TEAM,
|
|
70
|
+
# unchanged behavior). trueppm-enterprise registers a provider in its
|
|
71
|
+
# AppConfig.ready() to supply an org-governance default posture (which may open
|
|
72
|
+
# signals) — see trueppm-enterprise#143. This is the integrations-registry idiom
|
|
73
|
+
# (ADR-0049): OSS ships the neutral hook, Enterprise supplies the value; OSS never
|
|
74
|
+
# imports enterprise. The seam runs ONLY at creation, so it can never reach down
|
|
75
|
+
# and reopen a team's already-set ceiling (the G1 team-override guarantee holds
|
|
76
|
+
# structurally — there is no override to clobber at create time).
|
|
77
|
+
_DEFAULT_POSTURE_PROVIDER: Callable[[Project], dict[str, dict[str, str]]] | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def register_default_posture_provider(
|
|
81
|
+
provider: Callable[[Project], dict[str, dict[str, str]]] | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Register (or clear) the initial-posture provider. Enterprise calls this."""
|
|
84
|
+
global _DEFAULT_POSTURE_PROVIDER
|
|
85
|
+
_DEFAULT_POSTURE_PROVIDER = guard_single_provider(
|
|
86
|
+
_DEFAULT_POSTURE_PROVIDER, provider, "signal-privacy default posture"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _seed_initial_visibility(policy: ProjectSignalPrivacyPolicy, project: Project) -> None:
|
|
91
|
+
"""Seed a freshly-created policy from the registered provider, if any.
|
|
92
|
+
|
|
93
|
+
Each provided ``{audience, ceiling}`` is clamped to the ladder and to the
|
|
94
|
+
``audience <= ceiling`` invariant before it is stored, so a misconfigured
|
|
95
|
+
provider can never persist an inconsistent posture. No-op (coded defaults) when
|
|
96
|
+
no provider is registered — the OSS path.
|
|
97
|
+
"""
|
|
98
|
+
if _DEFAULT_POSTURE_PROVIDER is None:
|
|
99
|
+
return
|
|
100
|
+
seed = _DEFAULT_POSTURE_PROVIDER(project) or {}
|
|
101
|
+
cleaned: dict[str, dict[str, str]] = {}
|
|
102
|
+
for signal_key, pair in seed.items():
|
|
103
|
+
if signal_key not in SIGNAL_DEFAULTS or not isinstance(pair, dict):
|
|
104
|
+
continue
|
|
105
|
+
default = SIGNAL_DEFAULTS[signal_key]
|
|
106
|
+
ceiling = pair.get("ceiling", default["ceiling"])
|
|
107
|
+
audience = pair.get("audience", default["audience"])
|
|
108
|
+
if ceiling not in SignalAudience.values or audience not in SignalAudience.values:
|
|
109
|
+
continue
|
|
110
|
+
# Enforce audience <= ceiling at the seam too (defense in depth).
|
|
111
|
+
if signal_audience_rank(audience) > signal_audience_rank(ceiling):
|
|
112
|
+
audience = ceiling
|
|
113
|
+
cleaned[signal_key] = {"audience": audience, "ceiling": ceiling}
|
|
114
|
+
if cleaned:
|
|
115
|
+
policy.signal_visibility = cleaned
|
|
116
|
+
policy._change_reason = "initial org-default posture" # type: ignore[attr-defined]
|
|
117
|
+
policy.save(update_fields=["signal_visibility"])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_or_create_policy(project: Project) -> ProjectSignalPrivacyPolicy:
|
|
121
|
+
"""Get-or-create a project's privacy policy, seeding new rows via the seam.
|
|
122
|
+
|
|
123
|
+
The single entry point every read/write path uses to obtain the policy, so the
|
|
124
|
+
default-posture seam runs exactly once (at creation) and nowhere else.
|
|
125
|
+
"""
|
|
126
|
+
policy, created = ProjectSignalPrivacyPolicy.objects.get_or_create(project=project)
|
|
127
|
+
if created:
|
|
128
|
+
_seed_initial_visibility(policy, project)
|
|
129
|
+
return policy
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# Reader tier + the suppression gate (§2)
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def requester_signal_tier(request: Request, project_id: Any) -> str | None:
|
|
138
|
+
"""Resolve a requester's reader band on the ladder, or None for a non-member.
|
|
139
|
+
|
|
140
|
+
The ladder measures **management distance**: ``TEAM`` is the team itself
|
|
141
|
+
(ordinary members, viewers, and the Scrum Master — all team insiders who always
|
|
142
|
+
read their own team's signals), and ``TEAM_SM_PM`` is the PM/management band.
|
|
143
|
+
A signal is read iff the requester's band is *within* the signal's audience
|
|
144
|
+
(``tier <= audience``, see :func:`audience_can_read`), so the team always reads
|
|
145
|
+
its own signals while the PM is excluded until the team shares upward.
|
|
146
|
+
|
|
147
|
+
``None`` (non-member — the only way an org/PMO principal arrives) is below the
|
|
148
|
+
ladder and is denied every signal regardless of role ordinal: an Enterprise
|
|
149
|
+
custom role above OWNER that is not a project member has no ``ProjectMembership``
|
|
150
|
+
row, so it never passes (the back-door close, ADR-0104 §2). The Scrum-Master
|
|
151
|
+
facet (ADR-0078 / #927) does **not** raise the *read* band — the SM is a team
|
|
152
|
+
insider who reads as TEAM; the facet grants the *write* gate instead.
|
|
153
|
+
"""
|
|
154
|
+
role = _membership_role(request, project_id)
|
|
155
|
+
if role is None:
|
|
156
|
+
return None
|
|
157
|
+
if role >= Role.ADMIN:
|
|
158
|
+
return SignalAudience.TEAM_SM_PM # the PM / management band
|
|
159
|
+
return SignalAudience.TEAM # ordinary members, viewers, and the SM are the team
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def audience_can_read(
|
|
163
|
+
policy: ProjectSignalPrivacyPolicy, signal_key: str, requester_tier: str | None
|
|
164
|
+
) -> bool:
|
|
165
|
+
"""Whether a requester at ``requester_tier`` may read ``signal_key``'s gated detail.
|
|
166
|
+
|
|
167
|
+
Read iff the requester's band is **within** the signal's audience on the ladder
|
|
168
|
+
(``tier <= audience``) — the team (TEAM) always reads its own signals; the PM
|
|
169
|
+
(TEAM_SM_PM) reads only once the team has shared the signal up to its band; the
|
|
170
|
+
program rollup reads only at PROGRAM_SHARED. Raising the audience *widens* who
|
|
171
|
+
can see a signal (shares upward); it never hides it from the team. A non-member
|
|
172
|
+
(``requester_tier is None``) is outside the ladder and never reads.
|
|
173
|
+
"""
|
|
174
|
+
if requester_tier is None:
|
|
175
|
+
return False
|
|
176
|
+
return signal_audience_rank(requester_tier) <= signal_audience_rank(
|
|
177
|
+
policy.audience_of(signal_key)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def can_read_signal(request: Request, project_id: Any, signal_key: str) -> bool:
|
|
182
|
+
"""Convenience: resolve the requester's tier and apply the gate in one call.
|
|
183
|
+
|
|
184
|
+
Read-only: a read path must never *create* a policy (that would be a write on a
|
|
185
|
+
GET, and would run the default-posture seam off a read request). When no policy
|
|
186
|
+
row exists yet the signal resolves to its coded default audience (TEAM), which
|
|
187
|
+
is exactly what an unseeded ``get_or_create`` would have produced.
|
|
188
|
+
"""
|
|
189
|
+
tier = requester_signal_tier(request, project_id)
|
|
190
|
+
if tier is None:
|
|
191
|
+
return False
|
|
192
|
+
policy = ProjectSignalPrivacyPolicy.objects.filter(project_id=project_id).first()
|
|
193
|
+
audience = (
|
|
194
|
+
policy.audience_of(signal_key)
|
|
195
|
+
if policy is not None
|
|
196
|
+
else SIGNAL_DEFAULTS.get(signal_key, {"audience": SignalAudience.TEAM})["audience"]
|
|
197
|
+
)
|
|
198
|
+
return signal_audience_rank(tier) <= signal_audience_rank(audience)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# Velocity_summary fields that are team-private detail (the series + the
|
|
202
|
+
# point-based rolling/forecast numbers). Suppressed for a below-tier reader; the
|
|
203
|
+
# task-count rollups and the keys themselves are retained so existing clients keep
|
|
204
|
+
# a stable shape (ADR-0104 §2.1 — suppress, don't 403).
|
|
205
|
+
_VELOCITY_GATED_FIELDS = (
|
|
206
|
+
"rolling_avg_points",
|
|
207
|
+
"rolling_stdev_points",
|
|
208
|
+
"forecast_range_low",
|
|
209
|
+
"forecast_range_high",
|
|
210
|
+
"team_velocity_per_day",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def suppress_velocity_summary(summary: dict[str, Any]) -> dict[str, Any]:
|
|
215
|
+
"""Return a copy of a velocity_summary with the team-private detail stripped.
|
|
216
|
+
|
|
217
|
+
Empties the per-sprint ``sprints`` series, nulls the point-based rolling/forecast
|
|
218
|
+
numbers, and sets ``velocity_suppressed=True`` so the client can render the
|
|
219
|
+
gated empty-state. The milestone-health % and schedule confidence (computed
|
|
220
|
+
elsewhere) are never touched.
|
|
221
|
+
"""
|
|
222
|
+
# excluded_count (ADR-0113) is zeroed alongside the emptied series: it is an
|
|
223
|
+
# organisational fact ("this team flagged N setup sprints") that the ADR-0104
|
|
224
|
+
# privacy boundary excludes from a below-audience reader, and a non-zero count
|
|
225
|
+
# over an empty sprints[] would be incoherent anyway.
|
|
226
|
+
redacted = {**summary, "sprints": [], "excluded_count": 0, "velocity_suppressed": True}
|
|
227
|
+
for field in _VELOCITY_GATED_FIELDS:
|
|
228
|
+
if field in redacted:
|
|
229
|
+
redacted[field] = None
|
|
230
|
+
return redacted
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# flow_metrics arrays that are team-private historical performance detail
|
|
234
|
+
# (cycle/lead distributions, the CFD series, the weekly throughput series).
|
|
235
|
+
# Emptied for a below-tier reader; the keys themselves and the aggregate-only
|
|
236
|
+
# data_integrity block are retained so existing clients keep a stable shape
|
|
237
|
+
# (ADR-0130 D4 / ADR-0104 §2.1 — suppress, don't 403). data_integrity is NOT
|
|
238
|
+
# stripped: it is aggregate-only advisory data (no per-person rows) and carries
|
|
239
|
+
# no team-performance signal on its own.
|
|
240
|
+
_FLOW_METRICS_GATED_ARRAYS = ("cfd", "throughput")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def suppress_flow_metrics(payload: dict[str, Any]) -> dict[str, Any]:
|
|
244
|
+
"""Return a copy of a flow_metrics payload with the team-private detail stripped.
|
|
245
|
+
|
|
246
|
+
Empties the cycle/lead-time distributions, the CFD series, and the weekly
|
|
247
|
+
throughput series, and sets ``flow_metrics_suppressed=True`` so the client can
|
|
248
|
+
render the gated empty-state (ADR-0130 D4). Models
|
|
249
|
+
:func:`suppress_velocity_summary`.
|
|
250
|
+
|
|
251
|
+
The ``data_integrity`` counts are zeroed too: ``bulk_moved_count`` /
|
|
252
|
+
``backdated_count`` are derived from the same completed-task replay as the
|
|
253
|
+
throughput series, so a non-zero count reveals a floor on completions in the
|
|
254
|
+
window and partially reconstructs the very signal being suppressed. This mirrors
|
|
255
|
+
ADR-0104's treatment of ``excluded_count`` on velocity suppression. The block is
|
|
256
|
+
kept in the shape (zeroed) so the response contract survives suppression.
|
|
257
|
+
"""
|
|
258
|
+
redacted = {
|
|
259
|
+
**payload,
|
|
260
|
+
"cycle_time": {"p50": None, "p80": None, "p95": None},
|
|
261
|
+
"lead_time": {"p50": None, "p80": None, "p95": None},
|
|
262
|
+
"data_integrity": {
|
|
263
|
+
"bulk_moved_count": 0,
|
|
264
|
+
"backdated_count": 0,
|
|
265
|
+
"missing_transition_count": 0,
|
|
266
|
+
},
|
|
267
|
+
"flow_metrics_suppressed": True,
|
|
268
|
+
}
|
|
269
|
+
for field in _FLOW_METRICS_GATED_ARRAYS:
|
|
270
|
+
if field in redacted:
|
|
271
|
+
redacted[field] = []
|
|
272
|
+
return redacted
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Writes — two gates (§1.1). Audience within [TEAM, ceiling]; ceiling team-owned.
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _validate_signal_key(signal_key: str) -> None:
|
|
281
|
+
if signal_key not in SIGNAL_DEFAULTS:
|
|
282
|
+
raise ValidationError({"signal": f"Unknown signal '{signal_key}'."})
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _emit_consent_changed(
|
|
286
|
+
project_id: Any, signal_key: str, change: str, old: str, new: str
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Fire the supply-only extension-point signal on commit (ADR-0104 §3).
|
|
289
|
+
|
|
290
|
+
Also broadcasts the policy change to the project's live clients (#2845). This is
|
|
291
|
+
the one chokepoint every policy writer passes through — ``set_signal_audience``,
|
|
292
|
+
``raise_signal_ceiling`` (including the clamp it may apply) and
|
|
293
|
+
``ratchet_down_to_team`` all land here — so wiring the broadcast here rather than
|
|
294
|
+
at each call site is what keeps a future fourth writer from being silently
|
|
295
|
+
unbroadcast. Nothing in this module previously contained the string "broadcast",
|
|
296
|
+
while the settings UI it backs is a real multi-user surface with a 60s/30s
|
|
297
|
+
``staleTime`` and no ``refetchInterval``: a facilitator's ratchet-to-team was
|
|
298
|
+
invisible to every other member for up to a minute.
|
|
299
|
+
"""
|
|
300
|
+
from trueppm_api.apps.projects.signals import team_signal_consent_changed
|
|
301
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
302
|
+
|
|
303
|
+
project_id_str = str(project_id)
|
|
304
|
+
transaction.on_commit(
|
|
305
|
+
lambda: dispatch_extension_signal(
|
|
306
|
+
team_signal_consent_changed,
|
|
307
|
+
sender=ProjectSignalPrivacyPolicy,
|
|
308
|
+
project_id=project_id_str,
|
|
309
|
+
signal_key=signal_key,
|
|
310
|
+
change=change,
|
|
311
|
+
old=old,
|
|
312
|
+
new=new,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
# Values are already plain strings here (broadcast-check H-1: nothing in this
|
|
316
|
+
# closure re-reads an ORM instance that could have moved on by commit time).
|
|
317
|
+
transaction.on_commit(
|
|
318
|
+
lambda: broadcast_board_event(
|
|
319
|
+
project_id_str,
|
|
320
|
+
"signal_privacy_changed",
|
|
321
|
+
{"id": project_id_str, "signal_key": signal_key, "change": change},
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def set_signal_audience(
|
|
327
|
+
policy: ProjectSignalPrivacyPolicy,
|
|
328
|
+
signal_key: str,
|
|
329
|
+
new_audience: str,
|
|
330
|
+
*,
|
|
331
|
+
actor: Any = None,
|
|
332
|
+
) -> ProjectSignalPrivacyPolicy:
|
|
333
|
+
"""Move a signal's *audience* within ``[TEAM, ceiling]`` — the day-to-day write.
|
|
334
|
+
|
|
335
|
+
This is the facilitator's dial. It is the **only** path that touches an
|
|
336
|
+
audience, and it **refuses to touch a ceiling** (ADR-0104 §1.1 + threat-model
|
|
337
|
+
🔴-1: a generic field PATCH that could write ``ceiling`` reopens the unilateral
|
|
338
|
+
PM-raise hole). Rejects an audience above the team-authorized ceiling with 400.
|
|
339
|
+
Idempotent: setting the current value writes no history row.
|
|
340
|
+
"""
|
|
341
|
+
_validate_signal_key(signal_key)
|
|
342
|
+
if new_audience not in SignalAudience.values:
|
|
343
|
+
raise ValidationError({"audience": f"Invalid audience '{new_audience}'."})
|
|
344
|
+
|
|
345
|
+
resolved = policy.resolved(signal_key)
|
|
346
|
+
old_audience = resolved["audience"]
|
|
347
|
+
ceiling = resolved["ceiling"]
|
|
348
|
+
if signal_audience_rank(new_audience) > signal_audience_rank(ceiling):
|
|
349
|
+
raise ValidationError(
|
|
350
|
+
{
|
|
351
|
+
"audience": (
|
|
352
|
+
f"Audience '{new_audience}' exceeds the team-authorized ceiling "
|
|
353
|
+
f"'{ceiling}'. Raise the ceiling first (a team decision)."
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
if new_audience == old_audience:
|
|
358
|
+
return policy # no-op — no history row (ADR-0104 §DE idempotency)
|
|
359
|
+
|
|
360
|
+
entry = dict(policy.signal_visibility.get(signal_key, {}))
|
|
361
|
+
entry["audience"] = new_audience
|
|
362
|
+
policy.signal_visibility = {**policy.signal_visibility, signal_key: entry}
|
|
363
|
+
policy._change_reason = f"{signal_key} audience: {old_audience} -> {new_audience}" # type: ignore[attr-defined]
|
|
364
|
+
policy.save()
|
|
365
|
+
_emit_consent_changed(policy.project_id, signal_key, "audience", old_audience, new_audience)
|
|
366
|
+
return policy
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def raise_signal_ceiling(
|
|
370
|
+
policy: ProjectSignalPrivacyPolicy,
|
|
371
|
+
signal_key: str,
|
|
372
|
+
new_ceiling: str,
|
|
373
|
+
*,
|
|
374
|
+
actor: Any = None,
|
|
375
|
+
) -> ProjectSignalPrivacyPolicy:
|
|
376
|
+
"""Set a signal's *ceiling* — authorizing wider exposure (the team-owned act).
|
|
377
|
+
|
|
378
|
+
Raising a ceiling authorizes the facilitator to later move the audience that far;
|
|
379
|
+
it never moves the audience itself. **Lowering** a ceiling is always allowed
|
|
380
|
+
(more private) and clamps the audience down with it (ADR-0104 §1.1). Audited and
|
|
381
|
+
emitted as a team-visible consent event.
|
|
382
|
+
"""
|
|
383
|
+
_validate_signal_key(signal_key)
|
|
384
|
+
if new_ceiling not in SignalAudience.values:
|
|
385
|
+
raise ValidationError({"ceiling": f"Invalid ceiling '{new_ceiling}'."})
|
|
386
|
+
|
|
387
|
+
resolved = policy.resolved(signal_key)
|
|
388
|
+
old_ceiling = resolved["ceiling"]
|
|
389
|
+
audience = resolved["audience"]
|
|
390
|
+
if new_ceiling == old_ceiling:
|
|
391
|
+
return policy
|
|
392
|
+
|
|
393
|
+
entry = dict(policy.signal_visibility.get(signal_key, {}))
|
|
394
|
+
entry["ceiling"] = new_ceiling
|
|
395
|
+
# Lowering the ceiling below the current audience clamps the audience down with
|
|
396
|
+
# it — the signal can never sit above what the team now authorizes.
|
|
397
|
+
clamped = audience
|
|
398
|
+
if signal_audience_rank(new_ceiling) < signal_audience_rank(audience):
|
|
399
|
+
clamped = new_ceiling
|
|
400
|
+
entry["audience"] = clamped
|
|
401
|
+
policy.signal_visibility = {**policy.signal_visibility, signal_key: entry}
|
|
402
|
+
|
|
403
|
+
direction = (
|
|
404
|
+
"raise"
|
|
405
|
+
if signal_audience_rank(new_ceiling) > signal_audience_rank(old_ceiling)
|
|
406
|
+
else "lower"
|
|
407
|
+
)
|
|
408
|
+
reason = f"{signal_key} ceiling: {old_ceiling} -> {new_ceiling} (team-owned {direction})"
|
|
409
|
+
policy._change_reason = reason # type: ignore[attr-defined]
|
|
410
|
+
policy.save()
|
|
411
|
+
_emit_consent_changed(policy.project_id, signal_key, "ceiling", old_ceiling, new_ceiling)
|
|
412
|
+
if clamped != audience:
|
|
413
|
+
_emit_consent_changed(policy.project_id, signal_key, "audience", audience, clamped)
|
|
414
|
+
return policy
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def ratchet_down_to_team(
|
|
418
|
+
policy: ProjectSignalPrivacyPolicy, *, actor: Any = None
|
|
419
|
+
) -> ProjectSignalPrivacyPolicy:
|
|
420
|
+
"""Set every signal's audience to TEAM in one call — the SM panic button (§1).
|
|
421
|
+
|
|
422
|
+
Never touches ceilings (it is the convenience form of set-audience). Idempotent;
|
|
423
|
+
writes one audited history entry only for signals that actually changed.
|
|
424
|
+
"""
|
|
425
|
+
changed = False
|
|
426
|
+
new_map = dict(policy.signal_visibility)
|
|
427
|
+
reasons: list[str] = []
|
|
428
|
+
moves: list[tuple[str, str]] = []
|
|
429
|
+
for signal_key in SIGNAL_KEYS:
|
|
430
|
+
old_audience = policy.audience_of(signal_key)
|
|
431
|
+
if old_audience == SignalAudience.TEAM:
|
|
432
|
+
continue
|
|
433
|
+
entry = dict(new_map.get(signal_key, {}))
|
|
434
|
+
entry["audience"] = SignalAudience.TEAM
|
|
435
|
+
new_map[signal_key] = entry
|
|
436
|
+
reasons.append(f"{signal_key} audience: {old_audience} -> team")
|
|
437
|
+
moves.append((signal_key, old_audience))
|
|
438
|
+
changed = True
|
|
439
|
+
if not changed:
|
|
440
|
+
return policy
|
|
441
|
+
policy.signal_visibility = new_map
|
|
442
|
+
policy._change_reason = "ratchet to team-only: " + "; ".join(reasons) # type: ignore[attr-defined]
|
|
443
|
+
policy.save()
|
|
444
|
+
for signal_key, old_audience in moves:
|
|
445
|
+
_emit_consent_changed(
|
|
446
|
+
policy.project_id, signal_key, "audience", old_audience, SignalAudience.TEAM
|
|
447
|
+
)
|
|
448
|
+
return policy
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# Enterprise extension point — supply-only, opt-in only (§3)
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def get_shared_team_signals(project: Project) -> dict[str, str] | None:
|
|
457
|
+
"""Return the signals a project has opted into the program rollup, or None.
|
|
458
|
+
|
|
459
|
+
Returns ``{signal_key: audience}`` for *only* the signals whose audience is
|
|
460
|
+
``PROGRAM_SHARED`` (reachable only after a team raised the ceiling there and set
|
|
461
|
+
the audience there). Returns **None** when the project shared nothing — the
|
|
462
|
+
consumer skips a ``None`` rather than zero-filling, so a non-consenting team is
|
|
463
|
+
excluded from the aggregate, never inferred as a zero (ADR-0104 §3, Alternative
|
|
464
|
+
D rejected). Pure read; no side effects.
|
|
465
|
+
"""
|
|
466
|
+
policy = get_or_create_policy(project)
|
|
467
|
+
shared = {
|
|
468
|
+
signal_key: policy.audience_of(signal_key)
|
|
469
|
+
for signal_key in SIGNAL_KEYS
|
|
470
|
+
if policy.audience_of(signal_key) == SignalAudience.PROGRAM_SHARED
|
|
471
|
+
}
|
|
472
|
+
return shared or None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def velocity_shared_externally(project: Project) -> bool:
|
|
476
|
+
"""Whether velocity may travel to an *external* webhook consumer (ADR-0147).
|
|
477
|
+
|
|
478
|
+
A webhook recipient has no ``ProjectMembership`` and therefore no ladder tier —
|
|
479
|
+
the reader gate would deny it every signal, which would suppress velocity for
|
|
480
|
+
*every* webhook and defeat the legitimate "relay our cadence to our own
|
|
481
|
+
dashboard" use case. Instead an external consumer is mapped to the single
|
|
482
|
+
explicit outward-share rung: velocity (``completed_points`` / ``completed_task_count``
|
|
483
|
+
on ``sprint.closed``) leaves the team boundary **iff** the team has raised the
|
|
484
|
+
``velocity`` signal's audience to ``PROGRAM_SHARED`` — the one rung reachable only
|
|
485
|
+
by a deliberate, team-owned ceiling+audience raise. This is the same authority
|
|
486
|
+
``get_shared_team_signals`` consults, kept here so the webhook gate and the rollup
|
|
487
|
+
gate can never drift. Pure read; uses the resolved default (TEAM) when no policy
|
|
488
|
+
row exists, so the default suppresses.
|
|
489
|
+
"""
|
|
490
|
+
policy = ProjectSignalPrivacyPolicy.objects.filter(project=project).first()
|
|
491
|
+
audience = (
|
|
492
|
+
policy.audience_of("velocity")
|
|
493
|
+
if policy is not None
|
|
494
|
+
else SIGNAL_DEFAULTS["velocity"]["audience"]
|
|
495
|
+
)
|
|
496
|
+
return audience == SignalAudience.PROGRAM_SHARED
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# Ceiling-raise ratification (ADR-0104 Amendment A, #930)
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
#
|
|
503
|
+
# Raising a ceiling authorizes *wider* upward exposure of a team signal, so it is the
|
|
504
|
+
# team-owned act (ADR-0104 §1.1). It no longer applies immediately: a raise opens a
|
|
505
|
+
# ratification proposal and the ceiling is applied only when a strict majority of the
|
|
506
|
+
# current team roster approves. A lone facilitator can never widen exposure alone.
|
|
507
|
+
# Lowering a ceiling and set-audience moves stay immediate single actions — tightening
|
|
508
|
+
# is never gated heavier than loosening. Silence is never consent: an unratified
|
|
509
|
+
# proposal expires with the ceiling unchanged (no auto-apply-on-timeout). There is no
|
|
510
|
+
# management bypass — that would reopen the §2 back-door this whole model closes.
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class CeilingProposalConflict(Exception):
|
|
514
|
+
"""A ceiling-proposal action conflicts with the proposal's current state.
|
|
515
|
+
|
|
516
|
+
A plain ``Exception`` (not an ``APIException``) so the view maps it to a structured
|
|
517
|
+
``409 {code, detail}`` — mirroring ADR-0102's ``ScopeAcceptForbidden`` → structured
|
|
518
|
+
response pattern rather than DRF's default ``400`` for a ``ValidationError``.
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
522
|
+
self.code = code
|
|
523
|
+
self.detail = detail
|
|
524
|
+
super().__init__(detail)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _is_real_user(actor: Any) -> bool:
|
|
528
|
+
"""Whether ``actor`` is an authenticated user (not None / AnonymousUser)."""
|
|
529
|
+
return actor is not None and bool(getattr(actor, "is_authenticated", False))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _ceiling_proposal_ttl() -> timedelta:
|
|
533
|
+
"""The OPEN-proposal lifetime (default 72h, settings-overridable; Amendment A.3).
|
|
534
|
+
|
|
535
|
+
Clamped to >= 1 hour so a misconfigured ``SIGNAL_CEILING_PROPOSAL_TTL_HOURS`` of 0
|
|
536
|
+
or negative can never mint a proposal that expires before anyone can vote on it.
|
|
537
|
+
"""
|
|
538
|
+
hours = getattr(django_settings, "SIGNAL_CEILING_PROPOSAL_TTL_HOURS", 72)
|
|
539
|
+
return timedelta(hours=max(1, int(hours)))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def ratification_threshold(eligible_count: int) -> int:
|
|
543
|
+
"""Strict majority of the current roster — ``floor(n/2) + 1`` (Amendment A.2).
|
|
544
|
+
|
|
545
|
+
Yields 1->1, 2->2, 3->2, 4->3, 5->3. For any team of >=2 members the proposer's
|
|
546
|
+
own approval (1) never meets the bar, so a lone facilitator can never raise alone;
|
|
547
|
+
a 1-member team's sole member ratifies (there is no other member to consult). Not
|
|
548
|
+
unanimity and not whole-roster, so one disengaged member can never deadlock a raise.
|
|
549
|
+
"""
|
|
550
|
+
if eligible_count <= 0:
|
|
551
|
+
return 1
|
|
552
|
+
return eligible_count // 2 + 1
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _team_voter_ids(project_id: Any) -> set[Any]:
|
|
556
|
+
"""The eligible-voter set (Amendment A.2 as amended by Amendment C, #3387).
|
|
557
|
+
|
|
558
|
+
Default-team ``TeamMembership`` **intersected with live ``ProjectMembership``** —
|
|
559
|
+
the intersection lives in ``team_member_user_ids`` and is defaulted on, so this is
|
|
560
|
+
a plain delegation rather than a local filter. Both the denominator
|
|
561
|
+
(:func:`ratification_threshold`) and the numerator (:func:`_count_current_votes`)
|
|
562
|
+
read this one set, which is what keeps a tally internally consistent when the
|
|
563
|
+
roster moves mid-proposal.
|
|
564
|
+
"""
|
|
565
|
+
from trueppm_api.apps.teams.services import team_member_user_ids
|
|
566
|
+
|
|
567
|
+
return team_member_user_ids(project_id)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _emit_proposal_changed(proposal: SignalCeilingRaiseProposal) -> None:
|
|
571
|
+
"""Fire the supply-only proposal-lifecycle signal on commit (Amendment A.6).
|
|
572
|
+
|
|
573
|
+
Also broadcasts the transition (#2845). Every status change routes through
|
|
574
|
+
``_resolve_proposal`` or the open path, both of which land here, so this covers
|
|
575
|
+
opened / ratified / rejected / expired / superseded / withdrawn. A vote that does
|
|
576
|
+
*not* move the status broadcasts separately from :func:`cast_ceiling_vote` — the
|
|
577
|
+
running tally is itself the live signal a voting UI needs.
|
|
578
|
+
|
|
579
|
+
The architecturally equivalent feature is Planning Poker, which broadcasts on
|
|
580
|
+
every open/vote/reveal/reopen/commit/cancel; this one shipped with none.
|
|
581
|
+
"""
|
|
582
|
+
from trueppm_api.apps.projects.signals import team_signal_ceiling_proposal_changed
|
|
583
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
584
|
+
|
|
585
|
+
project_id = str(proposal.project_id)
|
|
586
|
+
signal_key = proposal.signal_key
|
|
587
|
+
proposal_id = str(proposal.id)
|
|
588
|
+
status = proposal.status
|
|
589
|
+
transaction.on_commit(
|
|
590
|
+
lambda: dispatch_extension_signal(
|
|
591
|
+
team_signal_ceiling_proposal_changed,
|
|
592
|
+
sender=SignalCeilingRaiseProposal,
|
|
593
|
+
project_id=project_id,
|
|
594
|
+
signal_key=signal_key,
|
|
595
|
+
proposal_id=proposal_id,
|
|
596
|
+
status=status,
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
# Plain scalars snapshotted above, before the closure (broadcast-check H-1). No
|
|
600
|
+
# vote values or voter identities in the payload — the event is a nudge to
|
|
601
|
+
# re-read through the REST surface, which re-applies the privacy gate.
|
|
602
|
+
transaction.on_commit(
|
|
603
|
+
lambda: broadcast_board_event(
|
|
604
|
+
project_id,
|
|
605
|
+
"signal_ceiling_proposal_changed",
|
|
606
|
+
{"id": proposal_id, "signal_key": signal_key, "status": status},
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _resolve_proposal(
|
|
612
|
+
proposal: SignalCeilingRaiseProposal, new_status: str
|
|
613
|
+
) -> SignalCeilingRaiseProposal:
|
|
614
|
+
"""Move an OPEN proposal to a terminal state; apply the raise only on RATIFIED.
|
|
615
|
+
|
|
616
|
+
RATIFIED re-resolves the *current* ceiling and applies via ``raise_signal_ceiling``
|
|
617
|
+
only when ``to_ceiling`` is still strictly higher — so a team that already raised
|
|
618
|
+
the ceiling by other means never gets a surprise double-jump, and a ratification can
|
|
619
|
+
never *lower* a ceiling (Amendment A.4). Every terminal state emits the lifecycle
|
|
620
|
+
signal on commit. Must be called under a row lock on ``proposal``.
|
|
621
|
+
"""
|
|
622
|
+
proposal.status = new_status
|
|
623
|
+
proposal.resolved_at = timezone.now()
|
|
624
|
+
proposal.save(update_fields=["status", "resolved_at"])
|
|
625
|
+
if new_status == CeilingRaiseStatus.RATIFIED:
|
|
626
|
+
policy = get_or_create_policy(proposal.project)
|
|
627
|
+
current = policy.ceiling_of(proposal.signal_key)
|
|
628
|
+
if signal_audience_rank(proposal.to_ceiling) > signal_audience_rank(current):
|
|
629
|
+
raise_signal_ceiling(
|
|
630
|
+
policy, proposal.signal_key, proposal.to_ceiling, actor=proposal.proposed_by
|
|
631
|
+
)
|
|
632
|
+
_emit_proposal_changed(proposal)
|
|
633
|
+
return proposal
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _count_current_votes(
|
|
637
|
+
proposal: SignalCeilingRaiseProposal, voter_ids: set[Any]
|
|
638
|
+
) -> tuple[int, int, int]:
|
|
639
|
+
"""(approve, reject, disregarded) — votes restricted to *current* eligible members.
|
|
640
|
+
|
|
641
|
+
The third value is the count of cast votes whose caster is no longer eligible. It
|
|
642
|
+
exists so the tally can **explain itself**: with a live roster both the numerator
|
|
643
|
+
and the denominator can move between two reads of the same OPEN proposal, and a
|
|
644
|
+
bare pair of shrinking integers gives a reader no way to tell a withdrawn vote from
|
|
645
|
+
a departed voter. Surfaced as ``disregarded_vote_count`` on the API tally.
|
|
646
|
+
|
|
647
|
+
**This is the already-cast-vote rule of Amendment C, and it is deliberate.** A vote
|
|
648
|
+
counts only while its caster is eligible, so the numerator and the denominator move
|
|
649
|
+
together and the tally always reconciles with the roster the threshold is computed
|
|
650
|
+
against. Since #3387 "eligible" also means holding a live ``ProjectMembership``, so
|
|
651
|
+
an offboarded member's vote stops counting at the same instant they stop counting
|
|
652
|
+
toward the bar — a proposal can never ratify on an absent member's approval.
|
|
653
|
+
|
|
654
|
+
The vote **row is not deleted**: it stays as the audit record Amendment A.6 promises,
|
|
655
|
+
and re-seating the member restores their vote to the tally. And the recomputation
|
|
656
|
+
can only ever reach an OPEN proposal — :func:`_tally_and_maybe_apply` returns
|
|
657
|
+
immediately once the status is terminal and a ratified raise is never revisited —
|
|
658
|
+
so no offboarding can retroactively flip a decision that has already been applied.
|
|
659
|
+
"""
|
|
660
|
+
approve = 0
|
|
661
|
+
reject = 0
|
|
662
|
+
disregarded = 0
|
|
663
|
+
for vote in proposal.votes.all():
|
|
664
|
+
if vote.voter_id not in voter_ids:
|
|
665
|
+
disregarded += 1
|
|
666
|
+
continue
|
|
667
|
+
if vote.choice == CeilingVoteChoice.APPROVE:
|
|
668
|
+
approve += 1
|
|
669
|
+
elif vote.choice == CeilingVoteChoice.REJECT:
|
|
670
|
+
reject += 1
|
|
671
|
+
return approve, reject, disregarded
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _tally_and_maybe_apply(proposal: SignalCeilingRaiseProposal) -> SignalCeilingRaiseProposal:
|
|
675
|
+
"""Recompute the outcome from current votes + roster; resolve when decided.
|
|
676
|
+
|
|
677
|
+
Call under ``select_for_update`` on the proposal. Idempotent: a no-op once the
|
|
678
|
+
proposal has left OPEN. Expiry is evaluated first (lazy, Amendment A.3).
|
|
679
|
+
"""
|
|
680
|
+
if proposal.status != CeilingRaiseStatus.OPEN:
|
|
681
|
+
return proposal
|
|
682
|
+
if timezone.now() >= proposal.expires_at:
|
|
683
|
+
return _resolve_proposal(proposal, CeilingRaiseStatus.EXPIRED)
|
|
684
|
+
voter_ids = _team_voter_ids(proposal.project_id)
|
|
685
|
+
eligible = len(voter_ids)
|
|
686
|
+
threshold = ratification_threshold(eligible)
|
|
687
|
+
approve, reject, _disregarded = _count_current_votes(proposal, voter_ids)
|
|
688
|
+
if approve >= threshold:
|
|
689
|
+
return _resolve_proposal(proposal, CeilingRaiseStatus.RATIFIED)
|
|
690
|
+
# Approval can no longer reach the bar (even every remaining member voting yes
|
|
691
|
+
# falls short) — reject early rather than wait out the timeout. Guarded on a
|
|
692
|
+
# non-empty roster: with no eligible voters yet the proposal stays OPEN (it will
|
|
693
|
+
# expire) rather than auto-rejecting on a vacuous "unreachable" check.
|
|
694
|
+
if eligible > 0 and reject > eligible - threshold:
|
|
695
|
+
return _resolve_proposal(proposal, CeilingRaiseStatus.REJECTED)
|
|
696
|
+
return proposal
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def expire_stale_proposals(project_id: Any, signal_key: str | None = None) -> None:
|
|
700
|
+
"""Lazily expire past-due OPEN proposals (Amendment A.3) — no Celery/Beat.
|
|
701
|
+
|
|
702
|
+
Called on the write paths before the one-open-per-signal check, so a stale OPEN
|
|
703
|
+
row never blocks a fresh proposal. An expired proposal dies UNRATIFIED.
|
|
704
|
+
"""
|
|
705
|
+
with transaction.atomic():
|
|
706
|
+
stale = SignalCeilingRaiseProposal.objects.select_for_update().filter(
|
|
707
|
+
project_id=project_id,
|
|
708
|
+
status=CeilingRaiseStatus.OPEN,
|
|
709
|
+
expires_at__lte=timezone.now(),
|
|
710
|
+
)
|
|
711
|
+
if signal_key is not None:
|
|
712
|
+
stale = stale.filter(signal_key=signal_key)
|
|
713
|
+
for proposal in stale:
|
|
714
|
+
_resolve_proposal(proposal, CeilingRaiseStatus.EXPIRED)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _supersede_open_proposals(project_id: Any, signal_key: str) -> None:
|
|
718
|
+
"""Supersede any OPEN raise proposal for a signal whose ceiling is being lowered.
|
|
719
|
+
|
|
720
|
+
A lower moves the baseline the proposal was relative to, so the pending raise is no
|
|
721
|
+
longer valid — it is SUPERSEDED (audited), forcing a fresh proposal + vote rather
|
|
722
|
+
than letting a stale ratification apply an unexpected jump. This is the audit
|
|
723
|
+
surface for the lower-then-raise pattern (Amendment A.4).
|
|
724
|
+
"""
|
|
725
|
+
with transaction.atomic():
|
|
726
|
+
for proposal in SignalCeilingRaiseProposal.objects.select_for_update().filter(
|
|
727
|
+
project_id=project_id,
|
|
728
|
+
signal_key=signal_key,
|
|
729
|
+
status=CeilingRaiseStatus.OPEN,
|
|
730
|
+
):
|
|
731
|
+
_resolve_proposal(proposal, CeilingRaiseStatus.SUPERSEDED)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def propose_or_apply_ceiling_change(
|
|
735
|
+
policy: ProjectSignalPrivacyPolicy,
|
|
736
|
+
signal_key: str,
|
|
737
|
+
new_ceiling: str,
|
|
738
|
+
*,
|
|
739
|
+
actor: Any,
|
|
740
|
+
) -> SignalCeilingRaiseProposal | None:
|
|
741
|
+
"""Route a ceiling change: a RAISE opens a ratification proposal; a LOWER applies now.
|
|
742
|
+
|
|
743
|
+
Returns the proposal on a raise (OPEN, or already RATIFIED when a solo team's sole
|
|
744
|
+
member is the proposer), or ``None`` on a lower / no-op (applied immediately via
|
|
745
|
+
``raise_signal_ceiling``). The policy is unchanged until ratification, so the caller
|
|
746
|
+
re-reads it after this returns.
|
|
747
|
+
"""
|
|
748
|
+
_validate_signal_key(signal_key)
|
|
749
|
+
if new_ceiling not in SignalAudience.values:
|
|
750
|
+
raise ValidationError({"ceiling": f"Invalid ceiling '{new_ceiling}'."})
|
|
751
|
+
current = policy.ceiling_of(signal_key)
|
|
752
|
+
if signal_audience_rank(new_ceiling) <= signal_audience_rank(current):
|
|
753
|
+
# Lower or no-op: immediate single action. A lower supersedes any open raise
|
|
754
|
+
# proposal for this signal (its baseline just moved).
|
|
755
|
+
if signal_audience_rank(new_ceiling) < signal_audience_rank(current):
|
|
756
|
+
_supersede_open_proposals(policy.project_id, signal_key)
|
|
757
|
+
raise_signal_ceiling(policy, signal_key, new_ceiling, actor=actor)
|
|
758
|
+
return None
|
|
759
|
+
# Raise — the team-owned act: open a ratification proposal.
|
|
760
|
+
project_id = policy.project_id
|
|
761
|
+
with transaction.atomic():
|
|
762
|
+
expire_stale_proposals(project_id, signal_key)
|
|
763
|
+
existing = (
|
|
764
|
+
SignalCeilingRaiseProposal.objects.select_for_update()
|
|
765
|
+
.filter(
|
|
766
|
+
project_id=project_id,
|
|
767
|
+
signal_key=signal_key,
|
|
768
|
+
status=CeilingRaiseStatus.OPEN,
|
|
769
|
+
)
|
|
770
|
+
.first()
|
|
771
|
+
)
|
|
772
|
+
if existing is not None:
|
|
773
|
+
raise CeilingProposalConflict(
|
|
774
|
+
"proposal_already_open",
|
|
775
|
+
"A ceiling-raise proposal is already open for this signal.",
|
|
776
|
+
)
|
|
777
|
+
proposal = SignalCeilingRaiseProposal.objects.create(
|
|
778
|
+
project_id=project_id,
|
|
779
|
+
signal_key=signal_key,
|
|
780
|
+
from_ceiling=current,
|
|
781
|
+
to_ceiling=new_ceiling,
|
|
782
|
+
proposed_by=actor if _is_real_user(actor) else None,
|
|
783
|
+
expires_at=timezone.now() + _ceiling_proposal_ttl(),
|
|
784
|
+
)
|
|
785
|
+
# Proposing is an implicit APPROVE — but only when the proposer is a team
|
|
786
|
+
# member (a non-team project Admin may kick off the team's vote without
|
|
787
|
+
# casting one). A solo-team proposer thereby ratifies immediately.
|
|
788
|
+
voter_ids = _team_voter_ids(project_id)
|
|
789
|
+
if _is_real_user(actor) and actor.pk in voter_ids:
|
|
790
|
+
SignalCeilingRaiseVote.objects.create(
|
|
791
|
+
proposal=proposal, voter=actor, choice=CeilingVoteChoice.APPROVE
|
|
792
|
+
)
|
|
793
|
+
_emit_proposal_changed(proposal) # opened
|
|
794
|
+
proposal = _tally_and_maybe_apply(proposal)
|
|
795
|
+
return proposal
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def cast_ceiling_vote(proposal_id: Any, voter: Any, choice: str) -> SignalCeilingRaiseProposal:
|
|
799
|
+
"""Record (or change) a team member's vote and re-tally under a row lock.
|
|
800
|
+
|
|
801
|
+
Idempotent: the vote is an upsert (``unique(proposal, voter)``) and the apply runs
|
|
802
|
+
once behind the OPEN->RATIFIED status guard. Raises ``CeilingProposalConflict`` when
|
|
803
|
+
the proposal is no longer open (including a lazy expiry evaluated under the lock).
|
|
804
|
+
"""
|
|
805
|
+
if choice not in CeilingVoteChoice.values:
|
|
806
|
+
raise ValidationError({"choice": f"Invalid choice '{choice}'."})
|
|
807
|
+
with transaction.atomic():
|
|
808
|
+
try:
|
|
809
|
+
proposal = SignalCeilingRaiseProposal.objects.select_for_update().get(pk=proposal_id)
|
|
810
|
+
except SignalCeilingRaiseProposal.DoesNotExist:
|
|
811
|
+
raise CeilingProposalConflict("not_found", "Proposal not found.") from None
|
|
812
|
+
if proposal.status == CeilingRaiseStatus.OPEN and timezone.now() >= proposal.expires_at:
|
|
813
|
+
_resolve_proposal(proposal, CeilingRaiseStatus.EXPIRED)
|
|
814
|
+
if proposal.status != CeilingRaiseStatus.OPEN:
|
|
815
|
+
raise CeilingProposalConflict(
|
|
816
|
+
"proposal_closed", "This proposal is no longer open for voting."
|
|
817
|
+
)
|
|
818
|
+
SignalCeilingRaiseVote.objects.update_or_create(
|
|
819
|
+
proposal=proposal, voter=voter, defaults={"choice": choice}
|
|
820
|
+
)
|
|
821
|
+
settled = _tally_and_maybe_apply(proposal)
|
|
822
|
+
# A vote that does not tip the tally leaves the status OPEN, so
|
|
823
|
+
# _emit_proposal_changed never fires — yet the running count IS what the
|
|
824
|
+
# other voters are watching (#2845). Broadcast the vote itself in that case;
|
|
825
|
+
# when the tally did settle the proposal, the status event already covers it
|
|
826
|
+
# and a second frame would only make clients re-read twice.
|
|
827
|
+
if settled.status == CeilingRaiseStatus.OPEN:
|
|
828
|
+
from trueppm_api.apps.sync.broadcast import broadcast_board_event
|
|
829
|
+
|
|
830
|
+
project_id = str(settled.project_id)
|
|
831
|
+
proposal_id = str(settled.pk)
|
|
832
|
+
signal_key = settled.signal_key
|
|
833
|
+
transaction.on_commit(
|
|
834
|
+
lambda: broadcast_board_event(
|
|
835
|
+
project_id,
|
|
836
|
+
"signal_ceiling_vote_cast",
|
|
837
|
+
{"id": proposal_id, "signal_key": signal_key},
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
return settled
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def withdraw_ceiling_proposal(proposal_id: Any, actor: Any) -> SignalCeilingRaiseProposal:
|
|
844
|
+
"""Withdraw an OPEN proposal (the proposer's cancel), recorded as REJECTED.
|
|
845
|
+
|
|
846
|
+
Frees the signal for a fresh proposal without waiting out the TTL. The caller
|
|
847
|
+
enforces that ``actor`` is the proposer (still a Member or above) or a
|
|
848
|
+
facilitator/Admin.
|
|
849
|
+
"""
|
|
850
|
+
with transaction.atomic():
|
|
851
|
+
try:
|
|
852
|
+
proposal = SignalCeilingRaiseProposal.objects.select_for_update().get(pk=proposal_id)
|
|
853
|
+
except SignalCeilingRaiseProposal.DoesNotExist:
|
|
854
|
+
raise CeilingProposalConflict("not_found", "Proposal not found.") from None
|
|
855
|
+
if proposal.status != CeilingRaiseStatus.OPEN:
|
|
856
|
+
raise CeilingProposalConflict("proposal_closed", "This proposal is no longer open.")
|
|
857
|
+
return _resolve_proposal(proposal, CeilingRaiseStatus.REJECTED)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def live_open_proposals(project_id: Any) -> dict[str, SignalCeilingRaiseProposal]:
|
|
861
|
+
"""Return ``{signal_key: live OPEN proposal}`` for the project, in ONE query.
|
|
862
|
+
|
|
863
|
+
A read helper for the GET surface: it does NOT persist an expiry (reads stay
|
|
864
|
+
side-effect-free) — a past-due OPEN row is treated as absent and is GC'd to
|
|
865
|
+
EXPIRED on the next write path. Votes are prefetched so the caller's per-proposal
|
|
866
|
+
tally/serialize do not re-query (the partial-unique constraint bounds the result
|
|
867
|
+
to at most one OPEN row per signal). The eligible roster should be fetched once by
|
|
868
|
+
the caller and threaded into ``proposal_tally``/serialization.
|
|
869
|
+
"""
|
|
870
|
+
now = timezone.now()
|
|
871
|
+
result: dict[str, SignalCeilingRaiseProposal] = {}
|
|
872
|
+
for proposal in (
|
|
873
|
+
SignalCeilingRaiseProposal.objects.filter(
|
|
874
|
+
project_id=project_id, status=CeilingRaiseStatus.OPEN
|
|
875
|
+
)
|
|
876
|
+
.prefetch_related("votes")
|
|
877
|
+
.order_by("-created_at")
|
|
878
|
+
):
|
|
879
|
+
if proposal.expires_at > now and proposal.signal_key not in result:
|
|
880
|
+
result[proposal.signal_key] = proposal
|
|
881
|
+
return result
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def proposal_tally(
|
|
885
|
+
proposal: SignalCeilingRaiseProposal, voter_ids: set[Any] | None = None
|
|
886
|
+
) -> dict[str, int]:
|
|
887
|
+
"""Current approve/reject/eligible/threshold counts for a proposal (read).
|
|
888
|
+
|
|
889
|
+
Pass ``voter_ids`` (the precomputed roster, identical across a project's proposals)
|
|
890
|
+
to avoid one roster query per proposal when serializing a list.
|
|
891
|
+
|
|
892
|
+
``disregarded_vote_count`` is the explainability handle Amendment C owes the reader:
|
|
893
|
+
eligibility is evaluated at *tally* time, so ``eligible_count``, ``threshold`` and
|
|
894
|
+
the approve/reject pair can all move between two reads of the same OPEN proposal
|
|
895
|
+
when the roster changes underneath it. Without this field a client can see the
|
|
896
|
+
numbers shift but cannot distinguish "someone changed their mind" from "someone left
|
|
897
|
+
the project", and cannot tell a member their own vote has stopped counting. It is
|
|
898
|
+
the count of cast votes whose caster is no longer eligible — normally 0.
|
|
899
|
+
"""
|
|
900
|
+
if voter_ids is None:
|
|
901
|
+
voter_ids = _team_voter_ids(proposal.project_id)
|
|
902
|
+
eligible = len(voter_ids)
|
|
903
|
+
approve, reject, disregarded = _count_current_votes(proposal, voter_ids)
|
|
904
|
+
return {
|
|
905
|
+
"approve_count": approve,
|
|
906
|
+
"reject_count": reject,
|
|
907
|
+
"eligible_count": eligible,
|
|
908
|
+
"threshold": ratification_threshold(eligible),
|
|
909
|
+
"disregarded_vote_count": disregarded,
|
|
910
|
+
}
|