trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
"""Config-change notice: a change to the shared surface tells everyone on it (#2972).
|
|
2
|
+
|
|
3
|
+
Removing a board lane, hiding a column or a leaf surface, or switching the
|
|
4
|
+
methodology preset changes the surface **every assignee** works on. Before this,
|
|
5
|
+
only the person who clicked knew — the write broadcast ``board_config_updated``
|
|
6
|
+
to whoever happened to have the board open, and nothing reached anyone else.
|
|
7
|
+
|
|
8
|
+
Three properties, and the second is the one most likely to be eroded later:
|
|
9
|
+
|
|
10
|
+
1. **It fires on the consequence, not the write.** A rename, a color, a WIP limit,
|
|
11
|
+
an added lane, a reorder — none of these notify. Only a change that moves or
|
|
12
|
+
hides work does. A notice on every save is noise, and noise gets muted, which
|
|
13
|
+
costs the signal this exists to carry. This is why the leaf-surface arm speaks
|
|
14
|
+
only for :data:`ENFORCED_SURFACE_KEYS`: a toggle no client reads moves nothing
|
|
15
|
+
on screen, so announcing it is the write talking, not the consequence (#3376).
|
|
16
|
+
2. **The body names what the change did to the recipient's own items.** "Board
|
|
17
|
+
configuration updated" is precisely the notification this module exists to
|
|
18
|
+
refuse. Each recipient's row is rendered for them, with their own count, and
|
|
19
|
+
the batch is written in one insert — ``create_event_notifications_batch`` for
|
|
20
|
+
the board notice, its project-spanning form for the surface notice.
|
|
21
|
+
3. **It reaches everyone the change re-shapes, not just the actor.** For the
|
|
22
|
+
board notice that is everyone with work on the board: the union of
|
|
23
|
+
``Task.assignee`` and ``TaskResource`` over the project — see
|
|
24
|
+
:func:`assigned_recipient_ids` for why it is the union and not either one
|
|
25
|
+
alone — intersected with live membership. For the **surface** notice it is
|
|
26
|
+
wider, because assignment is the wrong proxy for "this surface is yours":
|
|
27
|
+
:func:`surface_recipient_ids` adds the ADR-0078 facet holders and everyone
|
|
28
|
+
seated at ``role >= SCHEDULER`` (#3291).
|
|
29
|
+
|
|
30
|
+
Never dispatched from a refusal path. Under ``ATOMIC_REQUESTS`` DRF's exception
|
|
31
|
+
handler calls ``set_rollback()`` for every ``APIException``, which discards the
|
|
32
|
+
whole transaction *including* its ``on_commit`` callbacks — so a notice attached
|
|
33
|
+
to a rejected write would be silently dropped anyway. Every entry point here is
|
|
34
|
+
called after a successful ``save()``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import logging
|
|
41
|
+
from collections import Counter
|
|
42
|
+
from functools import partial
|
|
43
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
44
|
+
|
|
45
|
+
from django.db import transaction
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from collections.abc import Sequence
|
|
49
|
+
|
|
50
|
+
from .models import Project
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
#: Human labels for the four leaf surfaces (``surface_visibility.SURFACE_KEYS``).
|
|
55
|
+
#: Kept here rather than derived from the key so the notice reads as product copy
|
|
56
|
+
#: ("Monte Carlo") rather than as a column name ("monte_carlo"). The map stays
|
|
57
|
+
#: complete even though ``time_tracking`` is currently unreachable through
|
|
58
|
+
#: :data:`ENFORCED_SURFACE_KEYS` — restoring that key is then a one-line change
|
|
59
|
+
#: rather than a re-derivation.
|
|
60
|
+
SURFACE_LABELS: dict[str, str] = {
|
|
61
|
+
"reporting": "Reporting",
|
|
62
|
+
"time_tracking": "Time tracking",
|
|
63
|
+
"baselines": "Baselines",
|
|
64
|
+
"monte_carlo": "Monte Carlo",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#: The surfaces whose visibility a client actually acts on — the subset of
|
|
68
|
+
#: ``SURFACE_KEYS`` this module is allowed to speak about.
|
|
69
|
+
#:
|
|
70
|
+
#: ``time_tracking`` is deliberately absent (#3376). Nothing on web or mobile
|
|
71
|
+
#: reads ``Project.show_time_tracking``, so hiding it moves nothing on anyone's
|
|
72
|
+
#: screen — yet the notice told every assigned member, every facet holder and
|
|
73
|
+
#: every Scheduler+ seat that "Dana hid Time tracking in this project." That is
|
|
74
|
+
#: property 1 of this module inverted: it fired on the *write* rather than on the
|
|
75
|
+
#: consequence, and a notice about a change with no consequence is exactly the
|
|
76
|
+
#: noise that gets the signal muted.
|
|
77
|
+
#:
|
|
78
|
+
#: Filtering here rather than in the copy is deliberate — it is the single choke
|
|
79
|
+
#: point. :func:`capture_project_surface` builds both snapshots, so restricting it
|
|
80
|
+
#: suppresses the clause AND stops a time-tracking-only flip from being a
|
|
81
|
+
#: :class:`SurfaceChange` at all (which would otherwise emit a notice whose only
|
|
82
|
+
#: remaining sentence is "Nothing you own moved."). Add the key back the day a
|
|
83
|
+
#: surface reads the flag; see ``Project.show_time_tracking``.
|
|
84
|
+
#:
|
|
85
|
+
#: **Known hole, tracked as #3400.** This set is the has-a-consumer fact, and it
|
|
86
|
+
#: lives here — client-side of the API — so the REST surface still serves
|
|
87
|
+
#: ``effective_surface_visibility.time_tracking`` as a bare boolean an agent reads
|
|
88
|
+
#: as a real answer. #3400 moves the fact beside ``SURFACE_KEYS`` and serves it,
|
|
89
|
+
#: which also collapses the three places that must currently be edited in lockstep
|
|
90
|
+
#: (this set, the settings page's ``notEnforced`` string, and the docs section)
|
|
91
|
+
#: into one. Do not add a fourth unbound copy; extend #3400 instead.
|
|
92
|
+
#:
|
|
93
|
+
#: Pinned against the live ``SURFACE_KEYS`` by ``test_config_change_notice``'s
|
|
94
|
+
#: ``test_the_notice_speaks_for_every_surface_except_the_one_with_no_reader`` —
|
|
95
|
+
#: an allowlist omits a NEW key by default, and that failure is silent.
|
|
96
|
+
ENFORCED_SURFACE_KEYS: frozenset[str] = frozenset({"reporting", "baselines", "monte_carlo"})
|
|
97
|
+
|
|
98
|
+
#: Projects whose surface notices are resolved, rendered and written together.
|
|
99
|
+
#:
|
|
100
|
+
#: The reads are batched, so on query count alone the best chunk is the whole
|
|
101
|
+
#: apply — two hundred projects for eight reads. This trades a little of that
|
|
102
|
+
#: (four passes instead of one for a max-size apply, against ~1,800 before the
|
|
103
|
+
#: batching) for two bounds one flat batch does not have:
|
|
104
|
+
#:
|
|
105
|
+
#: * **Peak memory.** The rendered ``rows`` list holds a subject and a body
|
|
106
|
+
#: string per recipient. Flat, that is the sum over the whole apply; chunked it
|
|
107
|
+
#: is the maximum over one chunk.
|
|
108
|
+
#: * **Blast radius.** The reads and the insert sit under one ``try``, so a
|
|
109
|
+
#: failure in either loses every project that shares it. Flat, one bad read
|
|
110
|
+
#: drops the whole apply's notices; chunked it drops one chunk's.
|
|
111
|
+
#:
|
|
112
|
+
#: Fifty is the size at which the two bounds in play agree rather than one
|
|
113
|
+
#: dominating: a chunk of fifty projects at a typical ten-recipient cohort
|
|
114
|
+
#: renders ~500 rows, which is exactly ``NOTIFICATION_BULK_BATCH_SIZE`` — so the
|
|
115
|
+
#: list being accumulated and the statement it becomes are capped at the same
|
|
116
|
+
#: order of magnitude, and neither bound is doing all the work alone.
|
|
117
|
+
SURFACE_EMIT_CHUNK_SIZE = 50
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _chunked(items: Sequence[SurfaceChange], size: int) -> list[Sequence[SurfaceChange]]:
|
|
121
|
+
"""``items`` split into consecutive runs of at most ``size``.
|
|
122
|
+
|
|
123
|
+
``itertools.batched`` is the stdlib answer and this is not it, deliberately:
|
|
124
|
+
the runtime floor is Python 3.12 but ``[tool.mypy] python_version`` is 3.11,
|
|
125
|
+
where the stubs have no ``batched`` and ``mypy --strict`` fails. Widening a
|
|
126
|
+
global type-checker setting to import one helper is the wrong trade.
|
|
127
|
+
"""
|
|
128
|
+
return [items[start : start + size] for start in range(0, len(items), size)]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
#: Human labels for the methodology preset values.
|
|
132
|
+
METHODOLOGY_LABELS: dict[str, str] = {
|
|
133
|
+
"WATERFALL": "Waterfall",
|
|
134
|
+
"AGILE": "Agile",
|
|
135
|
+
"HYBRID": "Hybrid",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Recipients — "everyone assigned in the project"
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _assigned_candidates_by_project(project_ids: Sequence[Any]) -> dict[str, set[Any]]:
|
|
145
|
+
"""``{str(project_id): {user_id, ...}}`` — everyone with work, for a batch.
|
|
146
|
+
|
|
147
|
+
Two queries for any number of projects, which is the whole point: the union
|
|
148
|
+
of ``Task.assignee`` and ``TaskResource.resource.user`` is the same pair of
|
|
149
|
+
reads whether it is asked of one project or two hundred, and asking it per
|
|
150
|
+
project inside a bulk apply is how the emit reached ~2,000 round-trips
|
|
151
|
+
(#3335). :func:`assigned_recipient_ids` is the one-project caller.
|
|
152
|
+
|
|
153
|
+
Keys are stringified because the ids that reach here travel through an
|
|
154
|
+
``on_commit`` payload as ``str``, while the ORM yields ``uuid.UUID`` — a
|
|
155
|
+
mapping keyed on the raw column would silently miss every lookup.
|
|
156
|
+
|
|
157
|
+
``.distinct()`` matters on both arms: without it Postgres streams one row per
|
|
158
|
+
TASK to build a set of maybe thirty user ids, so a 10k-task project pulls 10k
|
|
159
|
+
UUIDs over the wire twice. The de-dupe belongs in the database.
|
|
160
|
+
|
|
161
|
+
**``.order_by()`` is what makes that ``.distinct()`` real, and it is not
|
|
162
|
+
cosmetic.** ``Task.Meta.ordering`` is ``["wbs_path", "name"]``, and a
|
|
163
|
+
``.distinct()`` with no ``distinct_fields`` promotes every ORDER BY column
|
|
164
|
+
into the SELECT list — so the statement becomes ``SELECT DISTINCT project_id,
|
|
165
|
+
assignee_id, wbs_path, name``. ``projects_task`` carries an
|
|
166
|
+
``ExclusionConstraint`` on ``(project, wbs_path)`` over live rows, which makes
|
|
167
|
+
that tuple unique among exactly the rows being scanned, so the DISTINCT
|
|
168
|
+
matches nothing and returns one row per task — the de-dupe silently does not
|
|
169
|
+
happen. Clearing the default ordering is the only thing standing between this
|
|
170
|
+
and the full-table transfer the previous paragraph exists to prevent.
|
|
171
|
+
"""
|
|
172
|
+
from trueppm_api.apps.resources.models import TaskResource
|
|
173
|
+
|
|
174
|
+
from .models import Task
|
|
175
|
+
|
|
176
|
+
by_project: dict[str, set[Any]] = {}
|
|
177
|
+
for project_id, user_id in (
|
|
178
|
+
Task.objects.filter(project_id__in=project_ids, is_deleted=False)
|
|
179
|
+
.exclude(assignee_id=None)
|
|
180
|
+
.order_by()
|
|
181
|
+
.values_list("project_id", "assignee_id")
|
|
182
|
+
.distinct()
|
|
183
|
+
):
|
|
184
|
+
by_project.setdefault(str(project_id), set()).add(user_id)
|
|
185
|
+
for project_id, user_id in (
|
|
186
|
+
# `TaskResource.Meta.ordering` is empty today, so this `order_by()` is a
|
|
187
|
+
# no-op — kept so that giving the model a default ordering later cannot
|
|
188
|
+
# quietly defeat this `distinct()` the way it does the Task arm above.
|
|
189
|
+
TaskResource.objects.filter(task__project_id__in=project_ids, task__is_deleted=False)
|
|
190
|
+
.exclude(resource__user__isnull=True)
|
|
191
|
+
.order_by()
|
|
192
|
+
.values_list("task__project_id", "resource__user_id")
|
|
193
|
+
.distinct()
|
|
194
|
+
):
|
|
195
|
+
by_project.setdefault(str(project_id), set()).add(user_id)
|
|
196
|
+
return by_project
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def assigned_recipient_ids(
|
|
200
|
+
project_id: Any,
|
|
201
|
+
*,
|
|
202
|
+
exclude_user_id: Any = None,
|
|
203
|
+
live_member_ids: set[Any] | None = None,
|
|
204
|
+
) -> list[Any]:
|
|
205
|
+
"""User ids of everyone with work in ``project_id``, minus the actor.
|
|
206
|
+
|
|
207
|
+
The board notice's cohort, and the assignment-shaped half of
|
|
208
|
+
:func:`surface_recipient_ids`. A lane going away moves *cards*, so having a
|
|
209
|
+
card is what makes someone a recipient — the wider surface cohort is not
|
|
210
|
+
right here, because a Product Owner with no card on the board has had nothing
|
|
211
|
+
moved out from under them.
|
|
212
|
+
|
|
213
|
+
The **union** of ``Task.assignee`` and ``TaskResource.resource.user``,
|
|
214
|
+
deliberately — and this is the one place in the codebase where the union is
|
|
215
|
+
right rather than one or the other. ``notify_amend`` uses ``TaskResource``
|
|
216
|
+
alone because it is talking about committed *load*, and a bare assignee
|
|
217
|
+
carries none. This is about the board a person works on, and a bare assignee
|
|
218
|
+
looks at exactly the same board as a resourced one; using ``TaskResource``
|
|
219
|
+
alone would silently tell nobody in a project imported from Jira (which
|
|
220
|
+
writes neither) or seeded from a template (assignees, no assignments).
|
|
221
|
+
|
|
222
|
+
Intersected with live ``ProjectMembership`` (``is_deleted=False``): an inbox
|
|
223
|
+
row about a project the recipient can no longer open is a dead link, and
|
|
224
|
+
naming a project's internal structure to a removed member is a scope leak
|
|
225
|
+
(ADR-0104's back-door close — a non-member sits below every tier).
|
|
226
|
+
|
|
227
|
+
``live_member_ids`` lets a caller that has already read the project's live
|
|
228
|
+
membership pass it in rather than making this run the same filter a second
|
|
229
|
+
time — :func:`surface_recipient_ids` needs the full ``(user_id, role)`` map
|
|
230
|
+
for its own cohorts, and that map is a strict superset of the answer this
|
|
231
|
+
liveness check would compute for itself. Defaulted to ``None`` so the board
|
|
232
|
+
call site stays self-contained.
|
|
233
|
+
"""
|
|
234
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
235
|
+
|
|
236
|
+
candidates = set(_assigned_candidates_by_project([project_id]).get(str(project_id), set()))
|
|
237
|
+
candidates.discard(None)
|
|
238
|
+
if exclude_user_id is not None:
|
|
239
|
+
candidates.discard(exclude_user_id)
|
|
240
|
+
if not candidates:
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
live_members = (
|
|
244
|
+
live_member_ids
|
|
245
|
+
if live_member_ids is not None
|
|
246
|
+
else set(
|
|
247
|
+
ProjectMembership.objects.filter(
|
|
248
|
+
project_id=project_id, is_deleted=False, user_id__in=candidates
|
|
249
|
+
).values_list("user_id", flat=True)
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return sorted(candidates & live_members, key=str)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def surface_recipient_ids(project_id: Any, *, exclude_user_id: Any = None) -> list[Any]:
|
|
256
|
+
"""User ids of everyone a surface change re-shapes in ``project_id``, minus the actor.
|
|
257
|
+
|
|
258
|
+
Three cohorts, unioned:
|
|
259
|
+
|
|
260
|
+
1. everyone with work in the project — :func:`assigned_recipient_ids`;
|
|
261
|
+
2. the ADR-0078 Scrum Master and Product Owner facet holders;
|
|
262
|
+
3. ``ProjectMembership`` rows at ``role >= Role.SCHEDULER``.
|
|
263
|
+
|
|
264
|
+
Assignment alone is the wrong proxy for "this surface is yours" (#3291). A
|
|
265
|
+
Product Owner authors and prioritizes the backlog and is frequently assigned
|
|
266
|
+
none of it; a Scrum Master facilitates and is neither an assignee nor a booked
|
|
267
|
+
resource; a PM who owns the plan routinely assigns none of it to themselves.
|
|
268
|
+
Those are the three people a preset flip re-shapes hardest, and the ones
|
|
269
|
+
everyone else asks to explain it — and the assigned-only cohort excluded
|
|
270
|
+
exactly them. Same class as #2897 at a new sink, which is why the facet half
|
|
271
|
+
reads :func:`~trueppm_api.apps.teams.services.facet_holder_user_ids` — the
|
|
272
|
+
scope gate's own source — rather than reimplementing the team lookup here.
|
|
273
|
+
|
|
274
|
+
The two sinks are ``ProjectViewSet.perform_update`` (gated ``IsProjectScheduler``)
|
|
275
|
+
and the program bulk matrix (gated ``IsProgramAdmin`` — a program-level gate with
|
|
276
|
+
no project-role correspondence at all). ``role >= SCHEDULER`` therefore sits *at
|
|
277
|
+
or below* the write gate on both paths rather than mirroring either, and the
|
|
278
|
+
threshold is justified by **read** parity, not write parity: ``show_*`` and
|
|
279
|
+
``effective_surface_visibility`` are already readable by any project member
|
|
280
|
+
(Viewer+), so telling a Scheduler discloses nothing they could not fetch
|
|
281
|
+
themselves. Note the cohort is deliberately not role-monotone — the facet arm
|
|
282
|
+
notifies Member- and Viewer-seated holders, per ADR-0078 and #2897.
|
|
283
|
+
|
|
284
|
+
**Every cohort is intersected with live ``ProjectMembership``**, and the facet
|
|
285
|
+
half genuinely needs it: the ADR-0078 §F mirror only ever *creates* team rows
|
|
286
|
+
(:mod:`trueppm_api.apps.teams.signals` has no ``post_delete`` receiver and
|
|
287
|
+
``TeamMembership`` has no FK a cascade could travel), so soft-deleting a
|
|
288
|
+
``ProjectMembership`` leaves the mirrored ``TeamMembership`` live with its
|
|
289
|
+
facet flags intact. Without the intersection, widening the cohort would widen
|
|
290
|
+
who keeps being told a project's internal structure after losing access — a
|
|
291
|
+
dead link and a scope leak (ADR-0104's back-door close: a non-member sits
|
|
292
|
+
below every tier).
|
|
293
|
+
|
|
294
|
+
The two sibling facet cohorts leaked on exactly that path until #3334 moved the
|
|
295
|
+
guard into ``facet_holder_user_ids`` itself, where it is now the default for
|
|
296
|
+
every caller — :func:`~trueppm_api.apps.projects.services._sprint_lead_recipient_ids`
|
|
297
|
+
and :func:`~trueppm_api.apps.projects.blocker_services.resolve_impediment_recipients`
|
|
298
|
+
get it without asking. This function opts *out* of the helper's own subquery
|
|
299
|
+
(``live_project_members_only=False``) precisely because it has already read live
|
|
300
|
+
membership and intersects in Python — see the paragraph below.
|
|
301
|
+
|
|
302
|
+
One membership read serves all three cohorts: the ``(user_id, role)`` map is
|
|
303
|
+
the Scheduler+ set, the facet arm's liveness floor, **and** the liveness floor
|
|
304
|
+
:func:`assigned_recipient_ids` would otherwise query for itself — which is why
|
|
305
|
+
it is threaded in rather than letting that call repeat the same filter.
|
|
306
|
+
|
|
307
|
+
The actor is discarded **after** the union, not before it, and that order is
|
|
308
|
+
load-bearing: the actor of a surface change is Scheduler+ on the
|
|
309
|
+
``perform_update`` path essentially by definition, so the Scheduler arm re-adds
|
|
310
|
+
them on every flip. This final ``discard`` is the only thing standing between a
|
|
311
|
+
PM and a notice about their own click.
|
|
312
|
+
|
|
313
|
+
Implemented by :func:`surface_recipient_ids_by_project` with a one-element
|
|
314
|
+
batch — same four queries, one definition of the cohort.
|
|
315
|
+
"""
|
|
316
|
+
return surface_recipient_ids_by_project([project_id], exclude_user_id=exclude_user_id).get(
|
|
317
|
+
str(project_id), []
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def surface_recipient_ids_by_project(
|
|
322
|
+
project_ids: Sequence[Any], *, exclude_user_id: Any = None
|
|
323
|
+
) -> dict[str, list[Any]]:
|
|
324
|
+
"""``{str(project_id): [user_id, ...]}`` — the surface cohort, for a batch.
|
|
325
|
+
|
|
326
|
+
Four queries for any number of projects, and exactly the four
|
|
327
|
+
:func:`surface_recipient_ids` used to issue for one: the membership scan, the
|
|
328
|
+
two assignment reads and the facet join. Each is ``project_id IN (...)``
|
|
329
|
+
grouped by project, and nothing in the batch depends on another project's
|
|
330
|
+
result, so the per-project loop that used to hold them belongs above them
|
|
331
|
+
rather than around them (#3335).
|
|
332
|
+
|
|
333
|
+
Cohort semantics, liveness and actor exclusion are :func:`surface_recipient_ids`'s
|
|
334
|
+
— read that docstring; this is the same function asked of a set of projects,
|
|
335
|
+
and the single-project form now delegates here so the two cannot drift.
|
|
336
|
+
|
|
337
|
+
The facet arm reads :func:`~trueppm_api.apps.teams.services.facet_holder_user_ids_by_project`,
|
|
338
|
+
the batch counterpart of the scope gate's own source, and intersects it with
|
|
339
|
+
``live_ids`` here — the same intersection the one-project path applied, from
|
|
340
|
+
the same membership map, so widening the batch cannot widen who keeps being
|
|
341
|
+
told a project's internal structure after losing access.
|
|
342
|
+
"""
|
|
343
|
+
from trueppm_api.apps.access.models import ProjectMembership, Role
|
|
344
|
+
from trueppm_api.apps.teams.services import facet_holder_user_ids_by_project
|
|
345
|
+
|
|
346
|
+
live_roles: dict[str, dict[Any, int]] = {}
|
|
347
|
+
for project_id, user_id, role in ProjectMembership.objects.filter(
|
|
348
|
+
project_id__in=project_ids, is_deleted=False
|
|
349
|
+
).values_list("project_id", "user_id", "role"):
|
|
350
|
+
live_roles.setdefault(str(project_id), {})[user_id] = role
|
|
351
|
+
|
|
352
|
+
assigned = _assigned_candidates_by_project(project_ids)
|
|
353
|
+
facets = {
|
|
354
|
+
str(pid): holders for pid, holders in facet_holder_user_ids_by_project(project_ids).items()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
cohorts: dict[str, list[Any]] = {}
|
|
358
|
+
for raw_id in project_ids:
|
|
359
|
+
pid = str(raw_id)
|
|
360
|
+
roles = live_roles.get(pid, {})
|
|
361
|
+
live_ids = set(roles)
|
|
362
|
+
|
|
363
|
+
recipients = assigned.get(pid, set()) & live_ids
|
|
364
|
+
recipients |= {uid for uid, role in roles.items() if role >= Role.SCHEDULER}
|
|
365
|
+
recipients |= facets.get(pid, set()) & live_ids
|
|
366
|
+
|
|
367
|
+
recipients.discard(None)
|
|
368
|
+
if exclude_user_id is not None:
|
|
369
|
+
recipients.discard(exclude_user_id)
|
|
370
|
+
cohorts[pid] = sorted(recipients, key=str)
|
|
371
|
+
return cohorts
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _counts_by_user(project_id: Any, task_filter: Any) -> Counter[Any]:
|
|
375
|
+
"""Per-user count of the affected tasks in ``project_id``.
|
|
376
|
+
|
|
377
|
+
Two **grouped** queries, and no per-task rows materialized in Python. The
|
|
378
|
+
surface path passes a bare ``Q()`` — every task in the project — so pulling
|
|
379
|
+
``(user, task)`` pairs back to de-duplicate them would build a set
|
|
380
|
+
proportional to the whole task table on a project that could have tens of
|
|
381
|
+
thousands of rows, to produce a handful of integers.
|
|
382
|
+
|
|
383
|
+
A task the same user both owns and is booked on must count **once**, which is
|
|
384
|
+
what the ``F("task__assignee_id")`` exclusion does: the assignee side counts
|
|
385
|
+
every owned task, and the resource side counts only the ones the counter does
|
|
386
|
+
not already own. ``distinct=True`` belongs on the **resource** arm only, where
|
|
387
|
+
a user booked twice on one task through two resource rows would otherwise
|
|
388
|
+
count twice; the assignee arm groups by ``assignee_id`` over a join-free
|
|
389
|
+
queryset, so its ``id`` is already unique per group.
|
|
390
|
+
|
|
391
|
+
Implemented by :func:`_counts_by_user_by_project` with a one-element batch —
|
|
392
|
+
still two queries, one definition of the count.
|
|
393
|
+
"""
|
|
394
|
+
return _counts_by_user_by_project([project_id], task_filter).get(str(project_id), Counter())
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _counts_by_user_by_project(
|
|
398
|
+
project_ids: Sequence[Any], task_filter: Any
|
|
399
|
+
) -> dict[str, Counter[Any]]:
|
|
400
|
+
"""``{str(project_id): Counter({user_id: n})}`` — :func:`_counts_by_user`, batched.
|
|
401
|
+
|
|
402
|
+
Two **grouped** queries for any number of projects: the grouping key gains
|
|
403
|
+
``project_id`` and the filter becomes ``IN (...)``, which is the entire
|
|
404
|
+
difference. The surface notice runs this once per bulk apply rather than once
|
|
405
|
+
per project (#3335), and every de-duplication rule described above is
|
|
406
|
+
unchanged — ``project_id`` only partitions groups a single project's query
|
|
407
|
+
already kept separate, since a task belongs to exactly one project.
|
|
408
|
+
"""
|
|
409
|
+
from django.db.models import Count, F
|
|
410
|
+
|
|
411
|
+
from trueppm_api.apps.resources.models import TaskResource
|
|
412
|
+
|
|
413
|
+
from .models import Task
|
|
414
|
+
|
|
415
|
+
affected = Task.objects.filter(project_id__in=project_ids, is_deleted=False).filter(task_filter)
|
|
416
|
+
counts: dict[str, Counter[Any]] = {}
|
|
417
|
+
for pid, uid, n in (
|
|
418
|
+
affected.exclude(assignee_id=None)
|
|
419
|
+
.values("project_id", "assignee_id")
|
|
420
|
+
.annotate(n=Count("id"))
|
|
421
|
+
.values_list("project_id", "assignee_id", "n")
|
|
422
|
+
):
|
|
423
|
+
counts.setdefault(str(pid), Counter())[uid] += n
|
|
424
|
+
for pid, uid, n in (
|
|
425
|
+
TaskResource.objects.filter(task__in=affected)
|
|
426
|
+
.exclude(resource__user__isnull=True)
|
|
427
|
+
.exclude(resource__user_id=F("task__assignee_id"))
|
|
428
|
+
.values("task__project_id", "resource__user_id")
|
|
429
|
+
.annotate(n=Count("task_id", distinct=True))
|
|
430
|
+
.values_list("task__project_id", "resource__user_id", "n")
|
|
431
|
+
):
|
|
432
|
+
counts.setdefault(str(pid), Counter())[uid] += n
|
|
433
|
+
return counts
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _actor_name(actor: Any) -> str:
|
|
437
|
+
"""Display name for the person who made the change, or a neutral stand-in."""
|
|
438
|
+
if actor is None or not getattr(actor, "is_authenticated", False):
|
|
439
|
+
return "Someone"
|
|
440
|
+
return (
|
|
441
|
+
getattr(actor, "get_full_name", lambda: "")() or getattr(actor, "username", "") or "Someone"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _actor_id(actor: Any) -> Any:
|
|
446
|
+
return actor.pk if actor is not None and getattr(actor, "is_authenticated", False) else None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _plural(count: int, singular: str = "item", plural: str = "items") -> str:
|
|
450
|
+
return f"{count} {singular if count == 1 else plural}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
# Board configuration — lanes removed, columns hidden
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class _RemovedLane(NamedTuple):
|
|
459
|
+
key: str
|
|
460
|
+
label: str
|
|
461
|
+
column_label: str
|
|
462
|
+
destination: str
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class _HiddenColumn(NamedTuple):
|
|
466
|
+
status: str
|
|
467
|
+
label: str
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _column_label(column: dict[str, Any]) -> str:
|
|
471
|
+
return str(column.get("label") or column.get("status") or "this column")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _first_lane_label(column: dict[str, Any] | None) -> str:
|
|
475
|
+
"""Where an orphaned lane key lands, said in words.
|
|
476
|
+
|
|
477
|
+
``Task.board_lane`` is never rewritten when a lane is deleted (a bulk UPDATE
|
|
478
|
+
would bypass the ``server_version`` bump every synced write depends on), so
|
|
479
|
+
the board resolves a dangling key to the column's **first** lane on read. The
|
|
480
|
+
notice must name the same destination the board will actually show, or it
|
|
481
|
+
describes a move that did not happen.
|
|
482
|
+
"""
|
|
483
|
+
if column is None:
|
|
484
|
+
return "its column"
|
|
485
|
+
lanes = column.get("lanes") or []
|
|
486
|
+
if lanes:
|
|
487
|
+
first = lanes[0]
|
|
488
|
+
return str(first.get("label") or first.get("key") or "the first lane")
|
|
489
|
+
# No lanes left at all: the column renders as one implicit lane, i.e. itself.
|
|
490
|
+
return _column_label(column)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _removed_lanes(
|
|
494
|
+
old_by_status: dict[str, dict[str, Any]],
|
|
495
|
+
new_by_status: dict[str, dict[str, Any]],
|
|
496
|
+
) -> list[_RemovedLane]:
|
|
497
|
+
"""Lanes that existed before the write and do not survive it.
|
|
498
|
+
|
|
499
|
+
Each one carries the destination its orphaned cards will resolve to, read
|
|
500
|
+
from the *new* column — see :func:`_first_lane_label`.
|
|
501
|
+
"""
|
|
502
|
+
removed: list[_RemovedLane] = []
|
|
503
|
+
for status, old_col in old_by_status.items():
|
|
504
|
+
new_col = new_by_status.get(status)
|
|
505
|
+
surviving = {str(lane.get("key")) for lane in (new_col or {}).get("lanes") or []}
|
|
506
|
+
for lane in old_col.get("lanes") or []:
|
|
507
|
+
key = str(lane.get("key"))
|
|
508
|
+
if key in surviving:
|
|
509
|
+
continue
|
|
510
|
+
removed.append(
|
|
511
|
+
_RemovedLane(
|
|
512
|
+
key=key,
|
|
513
|
+
label=str(lane.get("label") or key),
|
|
514
|
+
column_label=_column_label(new_col or old_col),
|
|
515
|
+
destination=_first_lane_label(new_col),
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
return removed
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _hidden_columns(
|
|
522
|
+
old_by_status: dict[str, dict[str, Any]],
|
|
523
|
+
new_by_status: dict[str, dict[str, Any]],
|
|
524
|
+
) -> list[_HiddenColumn]:
|
|
525
|
+
"""Columns that were visible before the write and are not after it."""
|
|
526
|
+
hidden: list[_HiddenColumn] = []
|
|
527
|
+
for status, old_col in old_by_status.items():
|
|
528
|
+
new_col = new_by_status.get(status)
|
|
529
|
+
was_visible = old_col.get("visible", True)
|
|
530
|
+
# A column dropped from the payload entirely cannot happen (the serializer
|
|
531
|
+
# requires all five canonical statuses), but treat absence as hidden
|
|
532
|
+
# rather than as "unchanged" so the notice can never under-report.
|
|
533
|
+
now_visible = new_col.get("visible", True) if new_col is not None else False
|
|
534
|
+
if was_visible and not now_visible:
|
|
535
|
+
hidden.append(_HiddenColumn(status=status, label=_column_label(new_col or old_col)))
|
|
536
|
+
return hidden
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def diff_board_config(
|
|
540
|
+
old_columns: list[dict[str, Any]], new_columns: list[dict[str, Any]]
|
|
541
|
+
) -> tuple[list[_RemovedLane], list[_HiddenColumn]]:
|
|
542
|
+
"""The two board changes that move or hide work.
|
|
543
|
+
|
|
544
|
+
Everything else a board-config PUT can carry — rename, reorder, recolor, a
|
|
545
|
+
WIP-limit change, an *added* lane, a column being *un*-hidden — leaves every
|
|
546
|
+
card exactly where the person who owns it last saw it, and produces no
|
|
547
|
+
notice. Returning the pair (rather than a bool) is what lets the body name
|
|
548
|
+
the specific lane and column instead of the fact that a PUT happened.
|
|
549
|
+
"""
|
|
550
|
+
new_by_status = {str(c.get("status")): c for c in new_columns}
|
|
551
|
+
old_by_status = {str(c.get("status")): c for c in old_columns}
|
|
552
|
+
return (
|
|
553
|
+
_removed_lanes(old_by_status, new_by_status),
|
|
554
|
+
_hidden_columns(old_by_status, new_by_status),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _removed_clause(actor_name: str, removed: list[_RemovedLane], lane_count: int) -> str:
|
|
559
|
+
"""The removed-lane sentence pair, or ``""`` when no lane was removed.
|
|
560
|
+
|
|
561
|
+
Every destination is named against the lane it belongs to. Removing two lanes
|
|
562
|
+
from two different columns sends their cards to two *different* first lanes,
|
|
563
|
+
so a single destination borrowed from ``removed[0]`` would send most of the
|
|
564
|
+
recipient to the wrong place — which is worse than saying nothing, because
|
|
565
|
+
they will look there, not find their work, and stop trusting the next notice.
|
|
566
|
+
"""
|
|
567
|
+
if not removed:
|
|
568
|
+
return ""
|
|
569
|
+
if len(removed) == 1:
|
|
570
|
+
lane = removed[0]
|
|
571
|
+
lead = f"{actor_name} removed the “{lane.label}” lane from {lane.column_label}."
|
|
572
|
+
tail = (
|
|
573
|
+
f"Your {_plural(lane_count)} in there now "
|
|
574
|
+
f"{'shows' if lane_count == 1 else 'show'} in “{lane.destination}” — "
|
|
575
|
+
f"the first lane of that column."
|
|
576
|
+
if lane_count
|
|
577
|
+
else "None of your items were in it."
|
|
578
|
+
)
|
|
579
|
+
else:
|
|
580
|
+
moves = _join_phrases(
|
|
581
|
+
[
|
|
582
|
+
f"“{lane.label}” from {lane.column_label} (cards move to “{lane.destination}”)"
|
|
583
|
+
for lane in removed
|
|
584
|
+
]
|
|
585
|
+
)
|
|
586
|
+
lead = f"{actor_name} removed {len(removed)} lanes: {moves}."
|
|
587
|
+
tail = (
|
|
588
|
+
f"{lane_count} of those items {'is' if lane_count == 1 else 'are'} yours."
|
|
589
|
+
if lane_count
|
|
590
|
+
else "None of your items were in them."
|
|
591
|
+
)
|
|
592
|
+
return f"{lead} {tail}"
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _hidden_clause(actor_name: str, hidden: list[_HiddenColumn], column_count: int) -> str:
|
|
596
|
+
"""The hidden-column sentence pair, or ``""`` when no column was hidden."""
|
|
597
|
+
if not hidden:
|
|
598
|
+
return ""
|
|
599
|
+
names = _join_phrases([f"“{col.label}”" for col in hidden])
|
|
600
|
+
noun = "column" if len(hidden) == 1 else "columns"
|
|
601
|
+
lead = f"{actor_name} hid the {names} {noun}."
|
|
602
|
+
if column_count:
|
|
603
|
+
# NOT "reach them from My Work": `/me/work/` filters on `assignee` alone
|
|
604
|
+
# and excludes BACKLOG, so that instruction is false for anyone counted
|
|
605
|
+
# through a TaskResource booking, and false for everyone when the hidden
|
|
606
|
+
# column is Backlog. A notice that sends the reader somewhere their work
|
|
607
|
+
# is not is the failure this whole module is written to avoid — so it
|
|
608
|
+
# names the two surfaces that show every status and both assignment kinds.
|
|
609
|
+
it_them = "it" if column_count == 1 else "them"
|
|
610
|
+
return (
|
|
611
|
+
f"{lead} Your {_plural(column_count)} with that status "
|
|
612
|
+
f"{'keeps its' if column_count == 1 else 'keep their'} status and dates, "
|
|
613
|
+
f"but the board no longer shows {it_them} — find {it_them} in the "
|
|
614
|
+
f"task list or search."
|
|
615
|
+
)
|
|
616
|
+
return f"{lead} None of your items have that status."
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _board_body(
|
|
620
|
+
actor_name: str,
|
|
621
|
+
removed: list[_RemovedLane],
|
|
622
|
+
hidden: list[_HiddenColumn],
|
|
623
|
+
lane_count: int,
|
|
624
|
+
column_count: int,
|
|
625
|
+
) -> str:
|
|
626
|
+
"""One recipient's copy of the board notice."""
|
|
627
|
+
parts = [
|
|
628
|
+
_removed_clause(actor_name, removed, lane_count),
|
|
629
|
+
_hidden_clause(actor_name, hidden, column_count),
|
|
630
|
+
]
|
|
631
|
+
return " ".join(part for part in parts if part)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _join_phrases(phrases: list[str]) -> str:
|
|
635
|
+
"""``a``, ``a and b``, ``a, b and c`` — an Oxford-comma-free serial list."""
|
|
636
|
+
if len(phrases) == 1:
|
|
637
|
+
return phrases[0]
|
|
638
|
+
return f"{', '.join(phrases[:-1])} and {phrases[-1]}"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _board_subject(removed: list[_RemovedLane], hidden: list[_HiddenColumn], affected: int) -> str:
|
|
642
|
+
"""A subject that already carries the consequence.
|
|
643
|
+
|
|
644
|
+
The inbox row previews the body, but the subject is what a recipient scanning
|
|
645
|
+
a full bell sees first — so it states what happened to *them*, not that a
|
|
646
|
+
configuration changed.
|
|
647
|
+
"""
|
|
648
|
+
if removed and hidden:
|
|
649
|
+
return (
|
|
650
|
+
f"Your board was reconfigured — {_plural(affected)} affected"
|
|
651
|
+
if affected
|
|
652
|
+
else "Your board was reconfigured"
|
|
653
|
+
)
|
|
654
|
+
if removed:
|
|
655
|
+
what = (
|
|
656
|
+
"A board lane was removed"
|
|
657
|
+
if len(removed) == 1
|
|
658
|
+
else f"{len(removed)} board lanes were removed"
|
|
659
|
+
)
|
|
660
|
+
return f"{what} — {_plural(affected)} moved" if affected else what
|
|
661
|
+
what = (
|
|
662
|
+
"A board column was hidden"
|
|
663
|
+
if len(hidden) == 1
|
|
664
|
+
else f"{len(hidden)} board columns were hidden"
|
|
665
|
+
)
|
|
666
|
+
return f"{what} — {_plural(affected)} no longer on the board" if affected else what
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def notify_board_config_change(
|
|
670
|
+
project: Project,
|
|
671
|
+
*,
|
|
672
|
+
old_columns: list[dict[str, Any]],
|
|
673
|
+
new_columns: list[dict[str, Any]],
|
|
674
|
+
actor: Any,
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Tell everyone with work in ``project`` that a lane or column went away.
|
|
677
|
+
|
|
678
|
+
Queued through the ``ConfigNoticeRequest`` outbox (ADR-1174) and emitted by a
|
|
679
|
+
worker: a notification failure must never revert a board configuration the
|
|
680
|
+
caller has already accepted, and must not be lost to a broker outage either.
|
|
681
|
+
|
|
682
|
+
**Every read the notice depends on happens in the worker** — the recipient set
|
|
683
|
+
as well as the counts. Resolving recipients here instead would run three
|
|
684
|
+
queries while the caller still holds its write locks, and would pair a
|
|
685
|
+
pre-commit recipient list with post-commit counts, so a person whose
|
|
686
|
+
assignment changed in the window could get a row whose count describes a
|
|
687
|
+
different moment than the list that selected them.
|
|
688
|
+
"""
|
|
689
|
+
removed, hidden = diff_board_config(old_columns, new_columns)
|
|
690
|
+
if not removed and not hidden:
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
_enqueue(
|
|
694
|
+
KIND_BOARD,
|
|
695
|
+
{
|
|
696
|
+
"project_id": str(project.pk),
|
|
697
|
+
"actor_name": _actor_name(actor),
|
|
698
|
+
"removed": [lane._asdict() for lane in removed],
|
|
699
|
+
"hidden": [column._asdict() for column in hidden],
|
|
700
|
+
},
|
|
701
|
+
actor=actor,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _emit_board_notifications(
|
|
706
|
+
project_id: str,
|
|
707
|
+
actor_id: Any,
|
|
708
|
+
removed: list[_RemovedLane],
|
|
709
|
+
hidden: list[_HiddenColumn],
|
|
710
|
+
actor_name: str,
|
|
711
|
+
) -> None:
|
|
712
|
+
from django.db.models import Q
|
|
713
|
+
|
|
714
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
715
|
+
from trueppm_api.apps.notifications.services import create_event_notifications_batch
|
|
716
|
+
|
|
717
|
+
try:
|
|
718
|
+
recipient_ids = assigned_recipient_ids(project_id, exclude_user_id=actor_id)
|
|
719
|
+
if not recipient_ids:
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
lane_counts: Counter[Any] = Counter()
|
|
723
|
+
if removed:
|
|
724
|
+
lane_counts = _counts_by_user(
|
|
725
|
+
project_id, Q(board_lane__in=[lane.key for lane in removed])
|
|
726
|
+
)
|
|
727
|
+
column_counts: Counter[Any] = Counter()
|
|
728
|
+
if hidden:
|
|
729
|
+
column_counts = _counts_by_user(
|
|
730
|
+
project_id, Q(status__in=[col.status for col in hidden])
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
rows = []
|
|
734
|
+
for rid in recipient_ids:
|
|
735
|
+
lane_n = lane_counts.get(rid, 0)
|
|
736
|
+
column_n = column_counts.get(rid, 0)
|
|
737
|
+
rows.append(
|
|
738
|
+
(
|
|
739
|
+
rid,
|
|
740
|
+
_board_subject(removed, hidden, lane_n + column_n),
|
|
741
|
+
_board_body(actor_name, removed, hidden, lane_n, column_n),
|
|
742
|
+
None,
|
|
743
|
+
)
|
|
744
|
+
)
|
|
745
|
+
create_event_notifications_batch(
|
|
746
|
+
event_type=NotificationEventType.PROJECT_CONFIG_CHANGED,
|
|
747
|
+
project_id=project_id,
|
|
748
|
+
rows=rows,
|
|
749
|
+
)
|
|
750
|
+
except Exception:
|
|
751
|
+
logger.exception("notify_board_config_change: emit failed for project %s", project_id)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
# ---------------------------------------------------------------------------
|
|
755
|
+
# Project preset and leaf-surface visibility
|
|
756
|
+
# ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class ProjectSurfaceSnapshot(NamedTuple):
|
|
760
|
+
"""The project's surface as it stood before a write.
|
|
761
|
+
|
|
762
|
+
Captured rather than re-read because ``serializer.save()`` mutates the
|
|
763
|
+
instance in place — after it, the previous preset is unrecoverable.
|
|
764
|
+
"""
|
|
765
|
+
|
|
766
|
+
methodology: str
|
|
767
|
+
#: Effective visibility keyed by surface, restricted to
|
|
768
|
+
#: :data:`ENFORCED_SURFACE_KEYS` — NOT every key in ``SURFACE_KEYS``.
|
|
769
|
+
visibility: dict[str, bool]
|
|
770
|
+
#: The surfaces :data:`ENFORCED_SURFACE_KEYS` leaves out. Captured for ONE
|
|
771
|
+
#: purpose — the diagnostic in :func:`collect_project_surface_change` — and
|
|
772
|
+
#: deliberately excluded from the change comparison, so it can never
|
|
773
|
+
#: resurrect the notice it exists to explain the absence of. Defaults to
|
|
774
|
+
#: empty for hand-built test snapshots; never mutated, so the shared default
|
|
775
|
+
#: is safe.
|
|
776
|
+
unenforced: dict[str, bool] = {} # noqa: RUF012
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def capture_project_surface(project: Project, *, workspace: Any = None) -> ProjectSurfaceSnapshot:
|
|
780
|
+
"""Snapshot ``project``'s preset and its *enforced* leaf-surface visibility.
|
|
781
|
+
|
|
782
|
+
Narrowed to :data:`ENFORCED_SURFACE_KEYS`, so the snapshot carries only the
|
|
783
|
+
surfaces a client actually renders against. A surface nothing reads cannot
|
|
784
|
+
make the before/after differ, and therefore cannot produce a notice — which
|
|
785
|
+
is the whole guarantee, applied once here rather than restated in the diff
|
|
786
|
+
and again in the copy.
|
|
787
|
+
"""
|
|
788
|
+
from .surface_visibility import resolve_effective_visibility
|
|
789
|
+
|
|
790
|
+
resolved = resolve_effective_visibility(project, workspace=workspace)
|
|
791
|
+
return ProjectSurfaceSnapshot(
|
|
792
|
+
methodology=str(project.methodology or ""),
|
|
793
|
+
visibility={k: v for k, v in resolved.items() if k in ENFORCED_SURFACE_KEYS},
|
|
794
|
+
unenforced={k: v for k, v in resolved.items() if k not in ENFORCED_SURFACE_KEYS},
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _is_are(count: int) -> str:
|
|
799
|
+
return "is" if count == 1 else "are"
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _attribution_clauses(
|
|
803
|
+
actor_name: str,
|
|
804
|
+
before: ProjectSurfaceSnapshot,
|
|
805
|
+
after: ProjectSurfaceSnapshot,
|
|
806
|
+
) -> list[str]:
|
|
807
|
+
"""The preset clause and the surface clauses, in that order.
|
|
808
|
+
|
|
809
|
+
The actor is named exactly once, and which clause names them is load-bearing
|
|
810
|
+
— which is why both sides of the preset-vs-override split live in this one
|
|
811
|
+
function rather than in two. A preset switch is the deliberate act and the
|
|
812
|
+
surface changes fall out of it, so the preset clause is attributed and the
|
|
813
|
+
surfaces are stated as consequence: writing "Dana hid Baselines" for a flip
|
|
814
|
+
Dana never made surface-by-surface would attribute a decision to them that
|
|
815
|
+
they did not take. When there is no preset change the override IS the
|
|
816
|
+
deliberate act, so it takes the attribution.
|
|
817
|
+
"""
|
|
818
|
+
parts: list[str] = []
|
|
819
|
+
hidden = [k for k, v in after.visibility.items() if before.visibility.get(k) and not v]
|
|
820
|
+
shown = [k for k, v in after.visibility.items() if v and not before.visibility.get(k, False)]
|
|
821
|
+
|
|
822
|
+
if before.methodology != after.methodology:
|
|
823
|
+
old_label = METHODOLOGY_LABELS.get(before.methodology, before.methodology or "none")
|
|
824
|
+
new_label = METHODOLOGY_LABELS.get(after.methodology, after.methodology)
|
|
825
|
+
parts.append(
|
|
826
|
+
f"{actor_name} switched this project's planning preset from {old_label} to {new_label}."
|
|
827
|
+
)
|
|
828
|
+
if hidden:
|
|
829
|
+
parts.append(f"{_join_labels(hidden)} {_is_are(len(hidden))} no longer shown here.")
|
|
830
|
+
if shown:
|
|
831
|
+
parts.append(f"{_join_labels(shown)} {_is_are(len(shown))} now shown.")
|
|
832
|
+
else:
|
|
833
|
+
if hidden:
|
|
834
|
+
parts.append(f"{actor_name} hid {_join_labels(hidden)} in this project.")
|
|
835
|
+
if shown:
|
|
836
|
+
parts.append(f"{actor_name} turned {_join_labels(shown)} back on in this project.")
|
|
837
|
+
return parts
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _ownership_clause(item_count: int) -> str:
|
|
841
|
+
"""The closing clause, which always speaks to the recipient's own items.
|
|
842
|
+
|
|
843
|
+
The zero branch is load-bearing rather than an edge case: once the cohort
|
|
844
|
+
includes facet holders and Scheduler+ members (:func:`surface_recipient_ids`),
|
|
845
|
+
a recipient owning nothing is the *normal* case for a PO or Scrum Master, not
|
|
846
|
+
a rarity. It stays a sentence rather than an omission because a dropped clause
|
|
847
|
+
reads as an unstated many.
|
|
848
|
+
"""
|
|
849
|
+
if item_count:
|
|
850
|
+
keeps = "keeps its" if item_count == 1 else "keep their"
|
|
851
|
+
return (
|
|
852
|
+
f"Your {_plural(item_count)} {keeps} status, dates and assignments — "
|
|
853
|
+
f"what changed is where you find "
|
|
854
|
+
f"{'it' if item_count == 1 else 'them'}."
|
|
855
|
+
)
|
|
856
|
+
return "Nothing you own moved."
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _surface_body(
|
|
860
|
+
actor_name: str,
|
|
861
|
+
before: ProjectSurfaceSnapshot,
|
|
862
|
+
after: ProjectSurfaceSnapshot,
|
|
863
|
+
item_count: int,
|
|
864
|
+
) -> str:
|
|
865
|
+
"""One recipient's copy of the preset / views notice.
|
|
866
|
+
|
|
867
|
+
Every clause is grounded in a value the server computed: the two preset
|
|
868
|
+
labels, and the surfaces whose *effective* visibility actually flipped. It
|
|
869
|
+
does not claim anything about which chrome the web client chooses to render
|
|
870
|
+
for a preset — a notice that over-claims is worse than a terse one, because
|
|
871
|
+
the recipient checks it once and stops believing the next one.
|
|
872
|
+
"""
|
|
873
|
+
parts = _attribution_clauses(actor_name, before, after)
|
|
874
|
+
parts.append(_ownership_clause(item_count))
|
|
875
|
+
return " ".join(parts)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _join_labels(keys: list[str]) -> str:
|
|
879
|
+
return _join_phrases([SURFACE_LABELS.get(k, k) for k in keys])
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _surface_subject(before: ProjectSurfaceSnapshot, after: ProjectSurfaceSnapshot) -> str:
|
|
883
|
+
if before.methodology != after.methodology:
|
|
884
|
+
new_label = METHODOLOGY_LABELS.get(after.methodology, after.methodology)
|
|
885
|
+
return f"This project now runs as {new_label}"
|
|
886
|
+
return "The views in this project changed"
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
class SurfaceChange(NamedTuple):
|
|
890
|
+
"""One project's before/after, ready to render once the transaction commits."""
|
|
891
|
+
|
|
892
|
+
project_id: str
|
|
893
|
+
before: ProjectSurfaceSnapshot
|
|
894
|
+
after: ProjectSurfaceSnapshot
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def collect_project_surface_change(
|
|
898
|
+
project: Project,
|
|
899
|
+
*,
|
|
900
|
+
before: ProjectSurfaceSnapshot,
|
|
901
|
+
workspace: Any = None,
|
|
902
|
+
) -> SurfaceChange | None:
|
|
903
|
+
"""``SurfaceChange`` if this project's surface actually moved, else ``None``.
|
|
904
|
+
|
|
905
|
+
Split out from :func:`notify_project_surface_change` so the bulk settings
|
|
906
|
+
matrix — which applies one field map to as many as ``MAX_BULK_TARGETS``
|
|
907
|
+
projects — can register **one** ``on_commit`` callback for the whole batch
|
|
908
|
+
rather than one per project. Two hundred separate callbacks would each run
|
|
909
|
+
their own recipient and count queries inline in the request/response cycle.
|
|
910
|
+
|
|
911
|
+
Returns ``None`` when the preset is unchanged and every leaf surface's
|
|
912
|
+
**effective** visibility is unchanged — so writing an explicit override equal
|
|
913
|
+
to the value the project already resolved to notifies nobody, which is right:
|
|
914
|
+
nothing on screen moved.
|
|
915
|
+
"""
|
|
916
|
+
after = capture_project_surface(project, workspace=workspace)
|
|
917
|
+
if before.methodology == after.methodology and before.visibility == after.visibility:
|
|
918
|
+
# A suppressed notice is a silent non-event: an operator can see the write
|
|
919
|
+
# in ``HistoricalProject`` and see zero Notification rows, and nothing
|
|
920
|
+
# connects the two. Without this line the answer to "I hid Time tracking
|
|
921
|
+
# and nobody was told — is the notice system broken?" lives only in a
|
|
922
|
+
# frozenset in this file. Log the ONE case that is a deliberate
|
|
923
|
+
# suppression rather than a genuine no-op, so the decision is visible at
|
|
924
|
+
# the moment it takes effect (#3376).
|
|
925
|
+
moved = sorted(k for k, v in after.unenforced.items() if before.unenforced.get(k) != v)
|
|
926
|
+
if moved:
|
|
927
|
+
logger.info(
|
|
928
|
+
"surface notice suppressed for project %s: only unenforced surface(s) %s "
|
|
929
|
+
"moved — nothing reads them, so nothing on screen changed "
|
|
930
|
+
"(config_notice.ENFORCED_SURFACE_KEYS)",
|
|
931
|
+
project.pk,
|
|
932
|
+
moved,
|
|
933
|
+
)
|
|
934
|
+
return None
|
|
935
|
+
return SurfaceChange(project_id=str(project.pk), before=before, after=after)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def notify_project_surface_change(
|
|
939
|
+
project: Project,
|
|
940
|
+
*,
|
|
941
|
+
before: ProjectSurfaceSnapshot,
|
|
942
|
+
actor: Any,
|
|
943
|
+
workspace: Any = None,
|
|
944
|
+
) -> None:
|
|
945
|
+
"""Tell everyone this project's surface belongs to that its preset or views moved.
|
|
946
|
+
|
|
947
|
+
The cohort is :func:`surface_recipient_ids` — wider than the board notice's,
|
|
948
|
+
because a preset flip re-shapes the working surface of people who hold no
|
|
949
|
+
assigned task.
|
|
950
|
+
"""
|
|
951
|
+
change = collect_project_surface_change(project, before=before, workspace=workspace)
|
|
952
|
+
if change is None:
|
|
953
|
+
return
|
|
954
|
+
notify_surface_changes([change], actor=actor)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def notify_surface_changes(changes: list[SurfaceChange], *, actor: Any) -> None:
|
|
958
|
+
"""Queue one emit for every project whose surface moved in this transaction.
|
|
959
|
+
|
|
960
|
+
One outbox row for the whole batch, so the bulk matrix's reads stay grouped in
|
|
961
|
+
the worker exactly as #3335 grouped them in the request (ADR-1174).
|
|
962
|
+
"""
|
|
963
|
+
if not changes:
|
|
964
|
+
return
|
|
965
|
+
_enqueue(
|
|
966
|
+
KIND_SURFACE,
|
|
967
|
+
{
|
|
968
|
+
"actor_name": _actor_name(actor),
|
|
969
|
+
"changes": [_surface_change_to_json(change) for change in changes],
|
|
970
|
+
},
|
|
971
|
+
actor=actor,
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# ---------------------------------------------------------------------------
|
|
976
|
+
# Outbox — the emit leaves the request (#3009, ADR-1174)
|
|
977
|
+
# ---------------------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
KIND_BOARD = "board"
|
|
980
|
+
KIND_SURFACE = "surface"
|
|
981
|
+
|
|
982
|
+
#: Claims a queued notice may take before its failure is final. Counted per claim,
|
|
983
|
+
#: so a dispatch whose broker message was lost never spends the budget.
|
|
984
|
+
MAX_CONFIG_NOTICE_ATTEMPTS = 3
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _snapshot_to_json(snapshot: ProjectSurfaceSnapshot) -> dict[str, Any]:
|
|
988
|
+
return {"methodology": snapshot.methodology, "visibility": dict(snapshot.visibility)}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _snapshot_from_json(data: dict[str, Any]) -> ProjectSurfaceSnapshot:
|
|
992
|
+
return ProjectSurfaceSnapshot(
|
|
993
|
+
methodology=str(data["methodology"]),
|
|
994
|
+
visibility={str(k): bool(v) for k, v in data["visibility"].items()},
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _surface_change_to_json(change: SurfaceChange) -> dict[str, Any]:
|
|
999
|
+
return {
|
|
1000
|
+
"project_id": str(change.project_id),
|
|
1001
|
+
"before": _snapshot_to_json(change.before),
|
|
1002
|
+
"after": _snapshot_to_json(change.after),
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _surface_change_from_json(data: dict[str, Any]) -> SurfaceChange:
|
|
1007
|
+
return SurfaceChange(
|
|
1008
|
+
project_id=str(data["project_id"]),
|
|
1009
|
+
before=_snapshot_from_json(data["before"]),
|
|
1010
|
+
after=_snapshot_from_json(data["after"]),
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _enqueue(kind: str, payload: dict[str, Any], *, actor: Any) -> None:
|
|
1015
|
+
"""Write the outbox row in the caller's transaction; dispatch once it commits.
|
|
1016
|
+
|
|
1017
|
+
The row is the durability, not the dispatch. It commits or rolls back with the
|
|
1018
|
+
config write it describes, so a notice can neither outlive a rejected write nor
|
|
1019
|
+
be lost to a broker that was unreachable at the moment of commit — the drain
|
|
1020
|
+
picks up whatever the on-commit dispatch could not send.
|
|
1021
|
+
"""
|
|
1022
|
+
from .models import ConfigNoticeRequest
|
|
1023
|
+
|
|
1024
|
+
row = ConfigNoticeRequest.objects.create(kind=kind, payload=payload, actor_id=_actor_id(actor))
|
|
1025
|
+
transaction.on_commit(partial(_dispatch_config_notice, str(row.pk)))
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _dispatch_config_notice(request_id: str) -> None:
|
|
1029
|
+
"""Best-effort immediate dispatch of a committed notice.
|
|
1030
|
+
|
|
1031
|
+
Swallows every failure: this runs after COMMIT but still inside the request, so
|
|
1032
|
+
raising here would turn a write the server already accepted into a 500. A row
|
|
1033
|
+
this cannot mark ``dispatched`` stays ``pending`` and the drain sends it.
|
|
1034
|
+
"""
|
|
1035
|
+
from django.utils import timezone
|
|
1036
|
+
|
|
1037
|
+
from .models import ConfigNoticeRequest, ConfigNoticeRequestStatus
|
|
1038
|
+
from .tasks import emit_config_notice
|
|
1039
|
+
|
|
1040
|
+
try:
|
|
1041
|
+
result = emit_config_notice.delay(request_id)
|
|
1042
|
+
ConfigNoticeRequest.objects.filter(
|
|
1043
|
+
pk=request_id, status=ConfigNoticeRequestStatus.PENDING
|
|
1044
|
+
).update(
|
|
1045
|
+
status=ConfigNoticeRequestStatus.DISPATCHED,
|
|
1046
|
+
celery_task_id=str(result.id),
|
|
1047
|
+
claimed_at=timezone.now(),
|
|
1048
|
+
)
|
|
1049
|
+
except Exception:
|
|
1050
|
+
logger.exception(
|
|
1051
|
+
"config notice %s: immediate dispatch failed; left pending for the drain",
|
|
1052
|
+
request_id,
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def run_config_notice_request(request_id: str) -> None:
|
|
1057
|
+
"""Claim a queued notice, apply the cooldown, and emit it.
|
|
1058
|
+
|
|
1059
|
+
The claim is a conditional UPDATE, so the on-commit dispatch and a drain
|
|
1060
|
+
re-dispatch of the same row can race: zero rows claimed means the row is
|
|
1061
|
+
running elsewhere or already finished, and this delivery is a no-op.
|
|
1062
|
+
|
|
1063
|
+
The emitters log and swallow their own per-chunk and per-project failures, so
|
|
1064
|
+
an exception reaching this handler is structural (an unreadable payload). It
|
|
1065
|
+
returns the row to ``pending`` for the drain, or marks it ``dead`` once the
|
|
1066
|
+
claim budget is spent.
|
|
1067
|
+
"""
|
|
1068
|
+
from django.db.models import F
|
|
1069
|
+
from django.utils import timezone
|
|
1070
|
+
|
|
1071
|
+
from .models import ConfigNoticeRequest, ConfigNoticeRequestStatus
|
|
1072
|
+
|
|
1073
|
+
claimed = ConfigNoticeRequest.objects.filter(
|
|
1074
|
+
pk=request_id,
|
|
1075
|
+
status__in=[ConfigNoticeRequestStatus.PENDING, ConfigNoticeRequestStatus.DISPATCHED],
|
|
1076
|
+
).update(
|
|
1077
|
+
status=ConfigNoticeRequestStatus.RUNNING,
|
|
1078
|
+
claimed_at=timezone.now(),
|
|
1079
|
+
attempt_count=F("attempt_count") + 1,
|
|
1080
|
+
)
|
|
1081
|
+
if not claimed:
|
|
1082
|
+
return
|
|
1083
|
+
|
|
1084
|
+
row = ConfigNoticeRequest.objects.get(pk=request_id)
|
|
1085
|
+
running = ConfigNoticeRequest.objects.filter(
|
|
1086
|
+
pk=request_id, status=ConfigNoticeRequestStatus.RUNNING
|
|
1087
|
+
)
|
|
1088
|
+
try:
|
|
1089
|
+
if row.kind == KIND_BOARD:
|
|
1090
|
+
_run_board_notice(row)
|
|
1091
|
+
else:
|
|
1092
|
+
_run_surface_notice(row)
|
|
1093
|
+
except Exception:
|
|
1094
|
+
final = row.attempt_count >= MAX_CONFIG_NOTICE_ATTEMPTS
|
|
1095
|
+
logger.log(
|
|
1096
|
+
logging.ERROR if final else logging.WARNING,
|
|
1097
|
+
"config notice %s: emit failed on attempt %d%s",
|
|
1098
|
+
request_id,
|
|
1099
|
+
row.attempt_count,
|
|
1100
|
+
" — abandoned" if final else "; left pending for the drain",
|
|
1101
|
+
exc_info=True,
|
|
1102
|
+
)
|
|
1103
|
+
running.update(
|
|
1104
|
+
status=ConfigNoticeRequestStatus.DEAD if final else ConfigNoticeRequestStatus.PENDING,
|
|
1105
|
+
completed_at=timezone.now() if final else None,
|
|
1106
|
+
)
|
|
1107
|
+
return
|
|
1108
|
+
running.update(status=ConfigNoticeRequestStatus.DONE, completed_at=timezone.now())
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _run_board_notice(row: Any) -> None:
|
|
1112
|
+
payload = row.payload
|
|
1113
|
+
project_id = str(payload["project_id"])
|
|
1114
|
+
removed = [_RemovedLane(**lane) for lane in payload["removed"]]
|
|
1115
|
+
hidden = [_HiddenColumn(**column) for column in payload["hidden"]]
|
|
1116
|
+
suppressed = _cooling_down(
|
|
1117
|
+
KIND_BOARD, row.actor_id, str(row.pk), {project_id: _board_signature(removed, hidden)}
|
|
1118
|
+
)
|
|
1119
|
+
if project_id in suppressed:
|
|
1120
|
+
return
|
|
1121
|
+
_emit_board_notifications(project_id, row.actor_id, removed, hidden, str(payload["actor_name"]))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _run_surface_notice(row: Any) -> None:
|
|
1125
|
+
payload = row.payload
|
|
1126
|
+
changes = [_surface_change_from_json(change) for change in payload["changes"]]
|
|
1127
|
+
suppressed = _cooling_down(
|
|
1128
|
+
KIND_SURFACE,
|
|
1129
|
+
row.actor_id,
|
|
1130
|
+
str(row.pk),
|
|
1131
|
+
{change.project_id: _surface_signature(change) for change in changes},
|
|
1132
|
+
)
|
|
1133
|
+
live = [change for change in changes if change.project_id not in suppressed]
|
|
1134
|
+
if live:
|
|
1135
|
+
_emit_surface_notifications(live, row.actor_id, str(payload["actor_name"]))
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _board_signature(removed: list[_RemovedLane], hidden: list[_HiddenColumn]) -> str:
|
|
1139
|
+
"""What a board notice asserts: which lanes went where, which columns went away."""
|
|
1140
|
+
return json.dumps(
|
|
1141
|
+
{
|
|
1142
|
+
"removed": sorted([lane.key, lane.column_label, lane.destination] for lane in removed),
|
|
1143
|
+
"hidden": sorted(column.status for column in hidden),
|
|
1144
|
+
},
|
|
1145
|
+
sort_keys=True,
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _surface_signature(change: SurfaceChange) -> str:
|
|
1150
|
+
"""What a surface notice asserts — the same three facts ``_attribution_clauses`` renders."""
|
|
1151
|
+
before, after = change.before, change.after
|
|
1152
|
+
return json.dumps(
|
|
1153
|
+
{
|
|
1154
|
+
"preset": after.methodology if before.methodology != after.methodology else None,
|
|
1155
|
+
"hidden": sorted(
|
|
1156
|
+
k for k, v in after.visibility.items() if before.visibility.get(k) and not v
|
|
1157
|
+
),
|
|
1158
|
+
"shown": sorted(
|
|
1159
|
+
k for k, v in after.visibility.items() if v and not before.visibility.get(k, False)
|
|
1160
|
+
),
|
|
1161
|
+
},
|
|
1162
|
+
sort_keys=True,
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _cooling_down(
|
|
1167
|
+
kind: str, actor_id: Any, request_id: str, signatures: dict[str, str]
|
|
1168
|
+
) -> set[str]:
|
|
1169
|
+
"""Projects whose notice repeats the last one sent for them, by the same actor.
|
|
1170
|
+
|
|
1171
|
+
Suppresses only when the stored actor, the signature, AND a *different* outbox
|
|
1172
|
+
row all match the last notice recorded for ``(kind, project)``:
|
|
1173
|
+
|
|
1174
|
+
* **Against the last notice sent, never "any recent" one.** After
|
|
1175
|
+
Agile→Waterfall→Agile, a third flip to Waterfall must send — suppressing it
|
|
1176
|
+
because the first matched would leave the recipient's latest notice saying
|
|
1177
|
+
Agile about a project that runs as Waterfall. A hide/un-hide/hide cycle does
|
|
1178
|
+
collapse, because the un-hide sends nothing and the last notice still says
|
|
1179
|
+
"hidden".
|
|
1180
|
+
* **Keyed per project, with the actor stored.** If someone else's notice
|
|
1181
|
+
intervened, the stored actor no longer matches and the repeat sends.
|
|
1182
|
+
* **The row id is stored** so a row the drain re-drives after a worker died
|
|
1183
|
+
mid-emit does not find its own signature and suppress the chunks it never
|
|
1184
|
+
wrote.
|
|
1185
|
+
|
|
1186
|
+
Fails open on a Valkey error, as ``MentionRateThrottle`` does: the cooldown only
|
|
1187
|
+
ever removes noise and must never be the way a notice is lost (ADR-1174 §D).
|
|
1188
|
+
The window is fixed from the notice that was sent, not slid by suppressed
|
|
1189
|
+
repeats, so a change toggled indefinitely still re-notifies once per window.
|
|
1190
|
+
"""
|
|
1191
|
+
from typing import cast
|
|
1192
|
+
|
|
1193
|
+
import redis
|
|
1194
|
+
from django.conf import settings
|
|
1195
|
+
|
|
1196
|
+
from trueppm_api.core import valkey
|
|
1197
|
+
|
|
1198
|
+
window = int(getattr(settings, "TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS", 0) or 0)
|
|
1199
|
+
if window <= 0 or actor_id is None or not signatures:
|
|
1200
|
+
return set()
|
|
1201
|
+
|
|
1202
|
+
actor = str(actor_id)
|
|
1203
|
+
items = list(signatures.items())
|
|
1204
|
+
keys = [f"config_notice:last:{kind}:{project_id}" for project_id, _ in items]
|
|
1205
|
+
suppressed: set[str] = set()
|
|
1206
|
+
try:
|
|
1207
|
+
client = valkey.client(valkey.DB_NOTIFICATIONS, decode_responses=True)
|
|
1208
|
+
previous = cast("list[str | None]", client.mget(keys))
|
|
1209
|
+
pipe = client.pipeline(transaction=False)
|
|
1210
|
+
for (project_id, signature), key, stored in zip(items, keys, previous, strict=True):
|
|
1211
|
+
if stored is not None:
|
|
1212
|
+
last_actor, last_signature, last_request = json.loads(stored)
|
|
1213
|
+
if (
|
|
1214
|
+
last_actor == actor
|
|
1215
|
+
and last_signature == signature
|
|
1216
|
+
and last_request != request_id
|
|
1217
|
+
):
|
|
1218
|
+
suppressed.add(project_id)
|
|
1219
|
+
continue
|
|
1220
|
+
pipe.set(key, json.dumps([actor, signature, request_id]), ex=window)
|
|
1221
|
+
pipe.execute()
|
|
1222
|
+
except redis.RedisError:
|
|
1223
|
+
logger.exception("config notice cooldown: Valkey error, failing open")
|
|
1224
|
+
return set()
|
|
1225
|
+
|
|
1226
|
+
for project_id in suppressed:
|
|
1227
|
+
logger.info(
|
|
1228
|
+
"config notice suppressed for project %s: repeats the last %s notice actor %s sent "
|
|
1229
|
+
"within %ss (TRUEPPM_CONFIG_NOTICE_COOLDOWN_SECONDS)",
|
|
1230
|
+
project_id,
|
|
1231
|
+
kind,
|
|
1232
|
+
actor,
|
|
1233
|
+
window,
|
|
1234
|
+
)
|
|
1235
|
+
return suppressed
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _emit_surface_notifications(
|
|
1239
|
+
changes: list[SurfaceChange],
|
|
1240
|
+
actor_id: Any,
|
|
1241
|
+
actor_name: str,
|
|
1242
|
+
) -> None:
|
|
1243
|
+
"""Render and write one notice per recipient across every changed project.
|
|
1244
|
+
|
|
1245
|
+
**Every read is hoisted out of the per-project loop** (#3335). The program
|
|
1246
|
+
settings matrix can hand this up to ``MAX_BULK_TARGETS`` projects in one
|
|
1247
|
+
outbox row. Since #3009 the emit runs in the ``projects.emit_config_notice``
|
|
1248
|
+
worker rather than the request thread, but a read left inside the loop would
|
|
1249
|
+
still be two hundred round-trips holding a worker slot. Each read is
|
|
1250
|
+
``project_id IN (...)`` grouped by project, and nothing in the batch depends
|
|
1251
|
+
on another project's result.
|
|
1252
|
+
|
|
1253
|
+
The work is then processed in chunks of :data:`SURFACE_EMIT_CHUNK_SIZE`
|
|
1254
|
+
projects rather than as one batch, which is a deliberate trade of a little of
|
|
1255
|
+
that saving for two bounds the flat version did not have — see that constant
|
|
1256
|
+
for why. Within a chunk the cost is flat: the same eight reads whether the
|
|
1257
|
+
chunk holds one project or fifty.
|
|
1258
|
+
|
|
1259
|
+
The per-project ``try`` survives the hoist and now guards **only rendering**,
|
|
1260
|
+
which is where a per-project failure can actually originate: a malformed
|
|
1261
|
+
snapshot in one project's copy must not swallow the notices for the rest of
|
|
1262
|
+
the batch. The per-chunk ``try`` is both the on-commit contract — this
|
|
1263
|
+
callback runs after the write has been accepted and must never raise into it
|
|
1264
|
+
— and the reason a failed read or insert costs one chunk rather than the
|
|
1265
|
+
whole apply.
|
|
1266
|
+
"""
|
|
1267
|
+
from django.db.models import Q
|
|
1268
|
+
|
|
1269
|
+
from trueppm_api.apps.notifications.models import NotificationEventType
|
|
1270
|
+
from trueppm_api.apps.notifications.services import (
|
|
1271
|
+
create_event_notifications_multi_project,
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
for chunk in _chunked(changes, SURFACE_EMIT_CHUNK_SIZE):
|
|
1275
|
+
project_ids = [change.project_id for change in chunk]
|
|
1276
|
+
try:
|
|
1277
|
+
recipients_by_project = surface_recipient_ids_by_project(
|
|
1278
|
+
project_ids, exclude_user_id=actor_id
|
|
1279
|
+
)
|
|
1280
|
+
counts_by_project = _counts_by_user_by_project(project_ids, Q())
|
|
1281
|
+
|
|
1282
|
+
rows: list[tuple[Any, Any, str, str, Any]] = []
|
|
1283
|
+
for change in chunk:
|
|
1284
|
+
try:
|
|
1285
|
+
pid = str(change.project_id)
|
|
1286
|
+
recipient_ids = recipients_by_project.get(pid, [])
|
|
1287
|
+
if not recipient_ids:
|
|
1288
|
+
continue
|
|
1289
|
+
counts = counts_by_project.get(pid, Counter())
|
|
1290
|
+
subject = _surface_subject(change.before, change.after)
|
|
1291
|
+
# Materialized before it is appended, so a render that fails
|
|
1292
|
+
# half-way through one project's recipients contributes
|
|
1293
|
+
# nothing rather than a partial fan-out.
|
|
1294
|
+
rendered = [
|
|
1295
|
+
(
|
|
1296
|
+
rid,
|
|
1297
|
+
change.project_id,
|
|
1298
|
+
subject,
|
|
1299
|
+
_surface_body(
|
|
1300
|
+
actor_name, change.before, change.after, counts.get(rid, 0)
|
|
1301
|
+
),
|
|
1302
|
+
None,
|
|
1303
|
+
)
|
|
1304
|
+
for rid in recipient_ids
|
|
1305
|
+
]
|
|
1306
|
+
rows.extend(rendered)
|
|
1307
|
+
except Exception:
|
|
1308
|
+
logger.exception(
|
|
1309
|
+
"notify_project_surface_change: render failed for project %s",
|
|
1310
|
+
change.project_id,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
if not rows:
|
|
1314
|
+
continue
|
|
1315
|
+
# ``Notification`` carries project_id per row, so a chunk is one
|
|
1316
|
+
# insert (itself capped at NOTIFICATION_BULK_BATCH_SIZE rows per
|
|
1317
|
+
# statement) rather than one insert per project.
|
|
1318
|
+
create_event_notifications_multi_project(
|
|
1319
|
+
event_type=NotificationEventType.PROJECT_CONFIG_CHANGED,
|
|
1320
|
+
rows=rows,
|
|
1321
|
+
)
|
|
1322
|
+
except Exception:
|
|
1323
|
+
logger.exception(
|
|
1324
|
+
"notify_project_surface_change: emit failed for projects %s", project_ids
|
|
1325
|
+
)
|