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,1451 @@
|
|
|
1
|
+
"""Notification fan-out + mention parser (ADR-0075 §C, §D).
|
|
2
|
+
|
|
3
|
+
Pure-function `parse_mentions` extracts `@user` and `@group` references from
|
|
4
|
+
free-text comment bodies. Code-fence and inline-code aware so `@foo` inside
|
|
5
|
+
triple-backtick blocks or single backticks is NOT treated as a mention.
|
|
6
|
+
`\\@foo` is an explicit escape that renders the literal text.
|
|
7
|
+
|
|
8
|
+
`create_mention_notifications` is the single transactional fan-out path:
|
|
9
|
+
called from the comment viewset's `perform_create` (inside `transaction.
|
|
10
|
+
on_commit` is NOT correct here — Notification rows must be visible to the
|
|
11
|
+
caller in the same transaction so the API response can include the count).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
20
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
21
|
+
|
|
22
|
+
from django.conf import settings
|
|
23
|
+
from django.contrib.auth import get_user_model
|
|
24
|
+
from django.db import transaction
|
|
25
|
+
from django.db.models.functions import Lower
|
|
26
|
+
from django.utils import timezone
|
|
27
|
+
|
|
28
|
+
# django-stubs needs a real class for type annotations; get_user_model() returns
|
|
29
|
+
# a runtime alias that mypy can't use in a type position. Import the concrete
|
|
30
|
+
# User class for typing, keep get_user_model() result for runtime queries.
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from django.contrib.auth.models import User as UserType
|
|
33
|
+
else:
|
|
34
|
+
UserType = get_user_model()
|
|
35
|
+
|
|
36
|
+
from trueppm_api.apps.access.groups import (
|
|
37
|
+
ALL_AUTO_GROUP_KEYS,
|
|
38
|
+
GroupTooLargeError,
|
|
39
|
+
InvalidGroupKeyError,
|
|
40
|
+
resolve_group_members,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
from .models import (
|
|
44
|
+
DEFAULT_PREFERENCES,
|
|
45
|
+
DND_BYPASS_EVENTS,
|
|
46
|
+
PROJECT_NOTIFICATION_DEFAULT_MATRIX,
|
|
47
|
+
Mention,
|
|
48
|
+
MentionScope,
|
|
49
|
+
Notification,
|
|
50
|
+
NotificationChannel,
|
|
51
|
+
NotificationEventType,
|
|
52
|
+
NotificationPreference,
|
|
53
|
+
ProjectNotificationChannel,
|
|
54
|
+
ProjectNotificationEventType,
|
|
55
|
+
ProjectNotificationPreference,
|
|
56
|
+
UserNotificationSettings,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
import uuid
|
|
61
|
+
from collections.abc import Callable, Sequence
|
|
62
|
+
|
|
63
|
+
from trueppm_api.apps.access.models import ExternalStakeholder
|
|
64
|
+
from trueppm_api.apps.projects.models import TaskComment
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
User = get_user_model()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Mention parser
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ParsedMention(NamedTuple):
|
|
77
|
+
"""One extracted @reference. `kind` is 'user' or 'group'."""
|
|
78
|
+
|
|
79
|
+
kind: str # "user" or "group"
|
|
80
|
+
value: str # username or group key
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Matches @{username-or-groupkey}. Username/key allows letters, digits,
|
|
84
|
+
# underscore, hyphen, dot. Min 1 char. Word boundary required after.
|
|
85
|
+
# A leading backslash escapes the @ (handled in the loop, not the regex).
|
|
86
|
+
_MENTION_RE = re.compile(r"(?P<esc>\\?)@(?P<name>[A-Za-z0-9_.-]+)")
|
|
87
|
+
|
|
88
|
+
# Stripping strategy: replace fenced code blocks (```...```) and inline code
|
|
89
|
+
# (`...`) with same-length placeholders so the regex's character offsets stay
|
|
90
|
+
# consistent, then scan for mentions. We don't need to preserve original
|
|
91
|
+
# content — only to mask out the regions where @ doesn't mean mention.
|
|
92
|
+
_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
|
|
93
|
+
_INLINE_CODE_RE = re.compile(r"`[^`]*`")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _mask_code_regions(body: str) -> str:
|
|
97
|
+
"""Replace code spans with same-length spaces so @-mentions inside don't match."""
|
|
98
|
+
|
|
99
|
+
def _spaces(match: re.Match[str]) -> str:
|
|
100
|
+
return " " * (match.end() - match.start())
|
|
101
|
+
|
|
102
|
+
body = _FENCE_RE.sub(_spaces, body)
|
|
103
|
+
body = _INLINE_CODE_RE.sub(_spaces, body)
|
|
104
|
+
return body
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_mentions(body: str) -> list[ParsedMention]:
|
|
108
|
+
"""Extract distinct @mentions from a comment body.
|
|
109
|
+
|
|
110
|
+
- `@scrum-team`, `@program-pms`, and other auto-group keys (project +
|
|
111
|
+
program scope) are returned as `kind='group'`
|
|
112
|
+
- Anything else matching the @name pattern is returned as `kind='user'`
|
|
113
|
+
- `\\@name` is treated as escaped — not a mention
|
|
114
|
+
- `@name` inside ``` ``` ``` ``` fences or `single-backtick` code is NOT a mention
|
|
115
|
+
- Duplicates collapsed (one mention per distinct value, first-occurrence order)
|
|
116
|
+
|
|
117
|
+
User existence + project-membership check happens in
|
|
118
|
+
`create_mention_notifications`, not in the parser. The parser is pure.
|
|
119
|
+
"""
|
|
120
|
+
masked = _mask_code_regions(body)
|
|
121
|
+
seen: set[tuple[str, str]] = set()
|
|
122
|
+
out: list[ParsedMention] = []
|
|
123
|
+
|
|
124
|
+
for match in _MENTION_RE.finditer(masked):
|
|
125
|
+
if match.group("esc"): # escaped: \@name
|
|
126
|
+
continue
|
|
127
|
+
name = match.group("name")
|
|
128
|
+
kind = "group" if name.lower() in ALL_AUTO_GROUP_KEYS else "user"
|
|
129
|
+
key = (kind, name if kind == "user" else name.lower())
|
|
130
|
+
if key in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add(key)
|
|
133
|
+
out.append(ParsedMention(kind=kind, value=key[1]))
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Rate-limit constants (ADR-0075 locked constraints #8, #9)
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
MENTION_DAILY_LIMIT = 1000
|
|
142
|
+
MENTION_HOURLY_BURST = 100
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Notification preference defaults
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_or_create_default_preferences(user: UserType) -> list[NotificationPreference]:
|
|
151
|
+
"""Backfill NotificationPreference rows for a user using DEFAULT_PREFERENCES.
|
|
152
|
+
|
|
153
|
+
Idempotent: existing rows are left alone; only missing (event_type, channel)
|
|
154
|
+
pairs are inserted. Returns the user's full preference set.
|
|
155
|
+
"""
|
|
156
|
+
with transaction.atomic():
|
|
157
|
+
existing = {
|
|
158
|
+
(pref.event_type, pref.channel): pref
|
|
159
|
+
for pref in NotificationPreference.objects.filter(user=user)
|
|
160
|
+
}
|
|
161
|
+
to_create = [
|
|
162
|
+
NotificationPreference(
|
|
163
|
+
user=user, event_type=event_type, channel=channel, enabled=enabled
|
|
164
|
+
)
|
|
165
|
+
for event_type, channel, enabled in DEFAULT_PREFERENCES
|
|
166
|
+
if (event_type, channel) not in existing
|
|
167
|
+
]
|
|
168
|
+
if to_create:
|
|
169
|
+
NotificationPreference.objects.bulk_create(to_create)
|
|
170
|
+
return list(NotificationPreference.objects.filter(user=user))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Project-scoped delivery gate (#674)
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
# Channels carrying a durable record rather than a transient ping. The in-app
|
|
178
|
+
# inbox row *is* the notification — suppressing it during quiet hours would
|
|
179
|
+
# lose the mention outright instead of deferring a ping, so quiet hours never
|
|
180
|
+
# gate it (the matrix in_app cell still does). Every other channel is a transient
|
|
181
|
+
# interrupt and IS silenced inside the window. Mirrors Slack and GitHub DND: the
|
|
182
|
+
# record persists; only the interruption is held back.
|
|
183
|
+
#
|
|
184
|
+
# Today that means email, and only email: `slack` and `mobile_push` are matrix
|
|
185
|
+
# columns with no delivery path (see PROJECT_NOTIFICATION_DELIVERABLE_CHANNELS),
|
|
186
|
+
# so the gate below is written for them but has nothing to hold back. Naming them
|
|
187
|
+
# as silenced channels would read as a claim that they otherwise deliver (#3378).
|
|
188
|
+
_QUIET_HOURS_EXEMPT_CHANNELS = frozenset({ProjectNotificationChannel.IN_APP.value})
|
|
189
|
+
|
|
190
|
+
# Channels account-wide Do-Not-Disturb (UserNotificationSettings.dnd_enabled)
|
|
191
|
+
# silences. Same rationale as _QUIET_HOURS_EXEMPT_CHANNELS: the in-app inbox row
|
|
192
|
+
# is the durable record and is NEVER silenced — DND holds back the interrupt, not
|
|
193
|
+
# the record. The "in_app" string value is shared by NotificationChannel.IN_APP
|
|
194
|
+
# and ProjectNotificationChannel.IN_APP, so this exemption is correct on both the
|
|
195
|
+
# global event-sourced gate and the project-scoped gate.
|
|
196
|
+
_DND_EXEMPT_CHANNELS = frozenset({NotificationChannel.IN_APP.value})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _workspace_timezone_name(workspace: Any = None) -> str:
|
|
200
|
+
"""The singleton workspace's IANA timezone name, or ``""`` if unreadable.
|
|
201
|
+
|
|
202
|
+
Two ways to get ``""``, both meaning "no workspace tier — move down the chain":
|
|
203
|
+
there is no singleton row (a fresh install, or one purged per ADR-0174), or the
|
|
204
|
+
table is not reachable at all (mid-migration). This sits inside a notification
|
|
205
|
+
dispatch path, so neither may take the fan-out down with it.
|
|
206
|
+
|
|
207
|
+
Pass an already-loaded ``workspace`` to skip the query entirely — the same
|
|
208
|
+
threading idiom as ``scheduling.forecast_history_settings.resolve_effective_mc_history``,
|
|
209
|
+
which keeps a batched resolve at one workspace query rather than one per row.
|
|
210
|
+
"""
|
|
211
|
+
if workspace is not None:
|
|
212
|
+
return str(getattr(workspace, "timezone", "") or "").strip()
|
|
213
|
+
from django.db import DatabaseError, transaction
|
|
214
|
+
|
|
215
|
+
from trueppm_api.apps.workspace.models import Workspace
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Two deliberate choices here.
|
|
219
|
+
#
|
|
220
|
+
# A plain read, NOT ``Workspace.load()``: load() is a get_or_create, and a
|
|
221
|
+
# dispatch path must not write a row as a side effect of asking a question.
|
|
222
|
+
# No row simply means "no workspace tier" and the chain moves on.
|
|
223
|
+
#
|
|
224
|
+
# Wrapped in a savepoint: ATOMIC_REQUESTS wraps the whole request, so a
|
|
225
|
+
# DatabaseError swallowed without one would leave the outer atomic block
|
|
226
|
+
# marked for rollback and turn this clean degradation into a 500 that names
|
|
227
|
+
# some unrelated later query.
|
|
228
|
+
with transaction.atomic():
|
|
229
|
+
stored = (
|
|
230
|
+
Workspace.objects.filter(singleton_key=1).values_list("timezone", flat=True).first()
|
|
231
|
+
)
|
|
232
|
+
except DatabaseError:
|
|
233
|
+
# Narrow on purpose. The intended degradation is "the table isn't reachable"
|
|
234
|
+
# (fresh install, mid-migration). A bare ``except Exception`` would also
|
|
235
|
+
# swallow a genuine bug in this resolve and report it as "no workspace
|
|
236
|
+
# timezone set", which is indistinguishable from a correct empty answer.
|
|
237
|
+
logger.warning("Workspace timezone unreadable — falling back", exc_info=True)
|
|
238
|
+
return ""
|
|
239
|
+
return str(stored or "").strip()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# Which tier of the quiet-hours timezone chain supplied the resolved zone. Returned
|
|
243
|
+
# alongside the zone by :func:`resolve_quiet_hours_timezone` and surfaced read-only on
|
|
244
|
+
# the per-project notification-preference endpoint, so "why was my 20:00 email held
|
|
245
|
+
# back?" has a server-side answer instead of requiring the client to re-implement a
|
|
246
|
+
# four-tier chain across three models (#3377).
|
|
247
|
+
QUIET_HOURS_TZ_SOURCE_PROJECT = "project"
|
|
248
|
+
QUIET_HOURS_TZ_SOURCE_WORKSPACE = "workspace"
|
|
249
|
+
QUIET_HOURS_TZ_SOURCE_SERVER = "server"
|
|
250
|
+
QUIET_HOURS_TZ_SOURCE_FALLBACK = "fallback"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def resolve_quiet_hours_timezone(project: Any, *, workspace: Any = None) -> tuple[ZoneInfo, str]:
|
|
254
|
+
"""Resolve the tz a project's quiet-hours window is interpreted in, and its tier.
|
|
255
|
+
|
|
256
|
+
Quiet hours are a *project* policy, not a per-viewer preference, so the window
|
|
257
|
+
anchors to the project's IANA timezone (``Project.timezone``, #520), falling back
|
|
258
|
+
to the workspace default (``Workspace.timezone``, #3377), then to the server's
|
|
259
|
+
``settings.TIME_ZONE``, then to UTC.
|
|
260
|
+
|
|
261
|
+
Unparseable tz data at any tier **walks to the next tier** rather than jumping
|
|
262
|
+
straight to UTC (the pre-#3377 behavior): a workspace whose admin saved a bad zone
|
|
263
|
+
should still get the server default, not silently lose an hour boundary. Nothing
|
|
264
|
+
here raises — this sits inside a dispatch path, and a bad string must not take a
|
|
265
|
+
fan-out down.
|
|
266
|
+
|
|
267
|
+
This function is the **only** consumer of ``Workspace.timezone`` — see the comment
|
|
268
|
+
on that field. It is deliberately not a *display* timezone: an instant rendered to
|
|
269
|
+
a person is re-clocked from that person's own ``profiles.Profile.timezone``
|
|
270
|
+
(ADR-0410). Quiet hours cannot use that value because the window is one stored
|
|
271
|
+
range per project preference row, not a per-viewer rendering.
|
|
272
|
+
|
|
273
|
+
The workspace tier is read lazily — a project that sets its own usable timezone
|
|
274
|
+
never touches the workspace table. Pass ``workspace`` from a fan-out that has
|
|
275
|
+
already loaded the singleton so the resolve stays at one query per fan-out rather
|
|
276
|
+
than one per recipient.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
project: The project whose quiet-hours window is being interpreted. May be
|
|
280
|
+
``None`` (a deleted project mid-fan-out) — the chain simply starts lower.
|
|
281
|
+
workspace: Optional pre-loaded ``Workspace`` singleton.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
``(zone, source)`` where ``source`` is one of ``QUIET_HOURS_TZ_SOURCE_*``.
|
|
285
|
+
"""
|
|
286
|
+
project_name = (getattr(project, "timezone", "") or "").strip()
|
|
287
|
+
if project_name:
|
|
288
|
+
try:
|
|
289
|
+
return ZoneInfo(project_name), QUIET_HOURS_TZ_SOURCE_PROJECT
|
|
290
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
291
|
+
# Log every skipped tier: relocating someone's quiet-hours window to a
|
|
292
|
+
# different wall clock with no signal anywhere is the failure mode that
|
|
293
|
+
# is impossible to debug from the outside. Project.timezone predates any
|
|
294
|
+
# validator, so stored rows can still be unparseable.
|
|
295
|
+
logger.warning("Unparseable Project.timezone %r — trying the workspace", project_name)
|
|
296
|
+
workspace_name = _workspace_timezone_name(workspace)
|
|
297
|
+
if workspace_name:
|
|
298
|
+
try:
|
|
299
|
+
return ZoneInfo(workspace_name), QUIET_HOURS_TZ_SOURCE_WORKSPACE
|
|
300
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
301
|
+
logger.warning(
|
|
302
|
+
"Unparseable Workspace.timezone %r — trying the server default", workspace_name
|
|
303
|
+
)
|
|
304
|
+
server_name = (settings.TIME_ZONE or "").strip()
|
|
305
|
+
if server_name:
|
|
306
|
+
try:
|
|
307
|
+
return ZoneInfo(server_name), QUIET_HOURS_TZ_SOURCE_SERVER
|
|
308
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
309
|
+
logger.warning("Unparseable settings.TIME_ZONE %r — falling back to UTC", server_name)
|
|
310
|
+
return ZoneInfo("UTC"), QUIET_HOURS_TZ_SOURCE_FALLBACK
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _project_timezone(project: Any, *, workspace: Any = None) -> ZoneInfo:
|
|
314
|
+
"""The zone half of :func:`resolve_quiet_hours_timezone` — see it for the chain."""
|
|
315
|
+
return resolve_quiet_hours_timezone(project, workspace=workspace)[0]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _in_quiet_window(now_local: datetime.time, start: datetime.time, end: datetime.time) -> bool:
|
|
319
|
+
"""True if ``now_local`` falls in the half-open ``[start, end)`` window.
|
|
320
|
+
|
|
321
|
+
Handles the wrap-past-midnight case (e.g. 22:00–07:00, where ``start > end``).
|
|
322
|
+
A zero-width window (``start == end``) means "no quiet hours".
|
|
323
|
+
"""
|
|
324
|
+
if start == end:
|
|
325
|
+
return False
|
|
326
|
+
if start < end:
|
|
327
|
+
return start <= now_local < end
|
|
328
|
+
return now_local >= start or now_local < end
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _matrix_cell(matrix: Any, event_type: str, channel: str) -> bool:
|
|
332
|
+
"""Effective on/off for one ``(event_type, channel)`` cell.
|
|
333
|
+
|
|
334
|
+
Falls through to ``PROJECT_NOTIFICATION_DEFAULT_MATRIX`` whenever the stored
|
|
335
|
+
matrix omits the event type or channel, so a stale row that predates a newly
|
|
336
|
+
added event still routes correctly. Unknown keys resolve to ``False`` — a
|
|
337
|
+
safe default that also neutralizes any legacy garbage that escaped key
|
|
338
|
+
validation (#675).
|
|
339
|
+
"""
|
|
340
|
+
if isinstance(matrix, dict):
|
|
341
|
+
row = matrix.get(event_type)
|
|
342
|
+
if isinstance(row, dict):
|
|
343
|
+
cell = row.get(channel)
|
|
344
|
+
if isinstance(cell, bool):
|
|
345
|
+
return cell
|
|
346
|
+
default_row = PROJECT_NOTIFICATION_DEFAULT_MATRIX.get(event_type, {})
|
|
347
|
+
return bool(default_row.get(channel, False))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _preference_allows(
|
|
351
|
+
pref: ProjectNotificationPreference,
|
|
352
|
+
*,
|
|
353
|
+
event_type: str,
|
|
354
|
+
channel: str,
|
|
355
|
+
now: datetime.datetime,
|
|
356
|
+
tz: ZoneInfo,
|
|
357
|
+
) -> bool:
|
|
358
|
+
"""Evaluate the delivery gate against an already-loaded preference row.
|
|
359
|
+
|
|
360
|
+
Pure (no DB access) so a fan-out can batch-load every recipient's row in one
|
|
361
|
+
query and evaluate each without an N+1 or a write-amplifying get_or_create.
|
|
362
|
+
An unsaved ``ProjectNotificationPreference()`` carries the model defaults, so
|
|
363
|
+
callers can pass a fresh instance for a recipient who has no row yet.
|
|
364
|
+
"""
|
|
365
|
+
# Per-project kill-switch (#589) overrides the matrix entirely.
|
|
366
|
+
if pref.paused:
|
|
367
|
+
return False
|
|
368
|
+
if not _matrix_cell(pref.matrix, event_type, channel):
|
|
369
|
+
return False
|
|
370
|
+
if channel not in _QUIET_HOURS_EXEMPT_CHANNELS and pref.quiet_hours_enabled:
|
|
371
|
+
now_local = now.astimezone(tz).time()
|
|
372
|
+
if _in_quiet_window(now_local, pref.quiet_hours_from, pref.quiet_hours_until):
|
|
373
|
+
return False
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def should_deliver(
|
|
378
|
+
user: Any,
|
|
379
|
+
project: Any,
|
|
380
|
+
event_type: str,
|
|
381
|
+
channel: str,
|
|
382
|
+
*,
|
|
383
|
+
now: datetime.datetime | None = None,
|
|
384
|
+
workspace: Any = None,
|
|
385
|
+
) -> bool:
|
|
386
|
+
"""Whether a project-scoped notification should be delivered to ``user``.
|
|
387
|
+
|
|
388
|
+
The routing source of truth for project notifications (#674). Loads — and
|
|
389
|
+
lazily creates — the user's :class:`ProjectNotificationPreference` row, then
|
|
390
|
+
applies, in order: the per-project pause kill-switch (#589), the matrix cell
|
|
391
|
+
(defaults overlaid for missing keys), and the quiet-hours window (transient
|
|
392
|
+
channels only — the in-app inbox is always recorded). ``now`` defaults to the
|
|
393
|
+
current time and is injectable for testing.
|
|
394
|
+
|
|
395
|
+
Call before sending at any project-scoped dispatch site. For a fan-out to
|
|
396
|
+
many recipients, batch-load rows and reuse :func:`_preference_allows` rather
|
|
397
|
+
than calling this once per recipient. If you must loop this instead, pass
|
|
398
|
+
``workspace`` — otherwise every iteration re-reads the workspace singleton on
|
|
399
|
+
top of the per-recipient ``get_or_create``, turning one query per recipient
|
|
400
|
+
into two (#3377).
|
|
401
|
+
"""
|
|
402
|
+
if now is None:
|
|
403
|
+
now = timezone.now()
|
|
404
|
+
pref, _ = ProjectNotificationPreference.objects.get_or_create(project=project, user=user)
|
|
405
|
+
return _preference_allows(
|
|
406
|
+
pref,
|
|
407
|
+
event_type=event_type,
|
|
408
|
+
channel=channel,
|
|
409
|
+
now=now,
|
|
410
|
+
tz=_project_timezone(project, workspace=workspace),
|
|
411
|
+
) and _dnd_allows(user, event_type, channel)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
# Account-wide Do-Not-Disturb gate (#1707, ADR-0292)
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def get_or_create_notification_settings(user: Any) -> UserNotificationSettings:
|
|
420
|
+
"""Return the user's account-wide notification settings, creating the row on
|
|
421
|
+
first access.
|
|
422
|
+
|
|
423
|
+
Mirrors :func:`get_or_create_default_preferences` — no backfill on user
|
|
424
|
+
create; the absence of a row reads as DND off. This is the authoritative
|
|
425
|
+
accessor used by the read/write endpoint and the single-user delivery gate.
|
|
426
|
+
``user`` is typed ``Any`` to match the request-context accessors in this
|
|
427
|
+
module (``should_deliver`` / ``_get_or_create_pref``): the ``IsAuthenticated``
|
|
428
|
+
gate on the view guarantees a real ``User`` at runtime.
|
|
429
|
+
"""
|
|
430
|
+
settings_row, _ = UserNotificationSettings.objects.get_or_create(user=user)
|
|
431
|
+
return settings_row
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def load_dnd_user_ids(user_ids: Any) -> set[Any]:
|
|
435
|
+
"""Return the subset of ``user_ids`` that currently have DND enabled.
|
|
436
|
+
|
|
437
|
+
One query for a whole fan-out (no N+1, no write-amplifying get_or_create) —
|
|
438
|
+
a user with no settings row is simply absent from the result, which reads as
|
|
439
|
+
DND off. Pair with :func:`_dnd_silences` in a batch dispatch loop.
|
|
440
|
+
"""
|
|
441
|
+
return set(
|
|
442
|
+
UserNotificationSettings.objects.filter(user_id__in=user_ids, dnd_enabled=True).values_list(
|
|
443
|
+
"user_id", flat=True
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _dnd_silences(event_type: str, channel: str, *, dnd_enabled: bool) -> bool:
|
|
449
|
+
"""Whether account-wide DND holds back this ``(event_type, channel)`` delivery.
|
|
450
|
+
|
|
451
|
+
Pure — takes the already-resolved DND flag so a batch fan-out can load the DND
|
|
452
|
+
set once (see :func:`load_dnd_user_ids`) and evaluate each recipient without a
|
|
453
|
+
query. DND silences ONLY transient channels (email/push) and ONLY for events
|
|
454
|
+
outside :data:`DND_BYPASS_EVENTS`; the in-app inbox row (``_DND_EXEMPT_CHANNELS``)
|
|
455
|
+
and the four bypass events always deliver — the safety contract is one
|
|
456
|
+
frozenset checked at one gate, so it cannot drift or swallow a blocker.
|
|
457
|
+
"""
|
|
458
|
+
if not dnd_enabled:
|
|
459
|
+
return False
|
|
460
|
+
if channel in _DND_EXEMPT_CHANNELS:
|
|
461
|
+
return False
|
|
462
|
+
# A non-exempt (transient) channel under DND: silence it unless the event is
|
|
463
|
+
# on the always-through safety list.
|
|
464
|
+
return event_type not in DND_BYPASS_EVENTS
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _dnd_allows(user: Any, event_type: str, channel: str) -> bool:
|
|
468
|
+
"""Single-user DND gate for :func:`should_deliver`.
|
|
469
|
+
|
|
470
|
+
Resolves (and lazily creates) the caller's settings row, then delegates to the
|
|
471
|
+
pure :func:`_dnd_silences` predicate. For a fan-out to many recipients, use
|
|
472
|
+
:func:`load_dnd_user_ids` + :func:`_dnd_silences` instead of calling this per
|
|
473
|
+
recipient.
|
|
474
|
+
"""
|
|
475
|
+
settings_row = get_or_create_notification_settings(user)
|
|
476
|
+
return not _dnd_silences(event_type, channel, dnd_enabled=settings_row.dnd_enabled)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
# Mention fan-out
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class MentionParseResult(NamedTuple):
|
|
485
|
+
"""Result of resolving parsed mentions against project membership."""
|
|
486
|
+
|
|
487
|
+
user_targets: list[UserType]
|
|
488
|
+
group_targets: list[tuple[str, list[UserType]]] # (group_key, members)
|
|
489
|
+
skipped_users: list[str] # usernames that didn't resolve / not project members
|
|
490
|
+
skipped_groups: list[str] # group keys that were unknown or too large
|
|
491
|
+
# Non-account external stakeholders reached by a resolved @program-stakeholders
|
|
492
|
+
# mention (#1658, ADR-0264). Additive and SEPARATE from group_targets — these
|
|
493
|
+
# rows have no User, so create_mention_notifications never writes Mention /
|
|
494
|
+
# Notification rows for them and no email is sent (delivery deferred to #1675).
|
|
495
|
+
# Default is a shared empty list, only ever read, never mutated in place — the
|
|
496
|
+
# NamedTuple is immutable and every producer builds a fresh list (RUF012 flags
|
|
497
|
+
# the mutable default, but the shared-mutation footgun does not apply here).
|
|
498
|
+
external_targets: list[ExternalStakeholder] = [] # noqa: RUF012
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _load_project_udg_members(
|
|
502
|
+
project_id: uuid.UUID | str, names_lower: list[str]
|
|
503
|
+
) -> dict[str, list[UserType]]:
|
|
504
|
+
"""Project-scoped user-defined mention groups (ADR-0212), keyed by lowercased name.
|
|
505
|
+
|
|
506
|
+
Members are snapshotted at write time and filtered to those still on the project.
|
|
507
|
+
"""
|
|
508
|
+
from trueppm_api.apps.access.models import ProjectMembership, UserDefinedMentionGroup
|
|
509
|
+
|
|
510
|
+
active_member_ids = set(
|
|
511
|
+
ProjectMembership.objects.filter(project_id=project_id, is_deleted=False).values_list(
|
|
512
|
+
"user_id", flat=True
|
|
513
|
+
)
|
|
514
|
+
)
|
|
515
|
+
resolved: dict[str, list[UserType]] = {}
|
|
516
|
+
udg_groups = (
|
|
517
|
+
UserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
|
|
518
|
+
.filter(project_id=project_id, name_lower__in=names_lower, is_deleted=False)
|
|
519
|
+
.prefetch_related("members")
|
|
520
|
+
)
|
|
521
|
+
for group in udg_groups:
|
|
522
|
+
resolved[group.name.lower()] = [
|
|
523
|
+
member for member in group.members.all() if member.pk in active_member_ids
|
|
524
|
+
]
|
|
525
|
+
return resolved
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _load_program_udg_members(
|
|
529
|
+
project_id: uuid.UUID | str, names_lower: list[str]
|
|
530
|
+
) -> dict[str, list[UserType]]:
|
|
531
|
+
"""Program-scoped user-defined mention groups (ADR-0248, #516), keyed by lowercased name.
|
|
532
|
+
|
|
533
|
+
Returns empty for a standalone project (no program). Members are filtered to those
|
|
534
|
+
still on any project in the program.
|
|
535
|
+
"""
|
|
536
|
+
from trueppm_api.apps.access.models import (
|
|
537
|
+
ProgramUserDefinedMentionGroup,
|
|
538
|
+
ProjectMembership,
|
|
539
|
+
)
|
|
540
|
+
from trueppm_api.apps.projects.models import Project
|
|
541
|
+
|
|
542
|
+
program_id = Project.objects.filter(pk=project_id).values_list("program_id", flat=True).first()
|
|
543
|
+
if program_id is None:
|
|
544
|
+
return {}
|
|
545
|
+
program_member_ids = set(
|
|
546
|
+
ProjectMembership.objects.filter(
|
|
547
|
+
project__program_id=program_id,
|
|
548
|
+
project__is_deleted=False,
|
|
549
|
+
is_deleted=False,
|
|
550
|
+
).values_list("user_id", flat=True)
|
|
551
|
+
)
|
|
552
|
+
resolved: dict[str, list[UserType]] = {}
|
|
553
|
+
pudg_groups = (
|
|
554
|
+
ProgramUserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
|
|
555
|
+
.filter(program_id=program_id, name_lower__in=names_lower, is_deleted=False)
|
|
556
|
+
.prefetch_related("members")
|
|
557
|
+
)
|
|
558
|
+
for pgroup in pudg_groups:
|
|
559
|
+
resolved[pgroup.name.lower()] = [
|
|
560
|
+
member for member in pgroup.members.all() if member.pk in program_member_ids
|
|
561
|
+
]
|
|
562
|
+
return resolved
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _resolve_udg_groups_by_lower(
|
|
566
|
+
project_id: uuid.UUID | str, unresolved: list[str]
|
|
567
|
+
) -> dict[str, list[UserType]]:
|
|
568
|
+
"""Reinterpret non-member @names as user-defined groups (ADR-0212, ADR-0248).
|
|
569
|
+
|
|
570
|
+
Batch-resolved in one query set per scope (not per-token) to keep the synchronous
|
|
571
|
+
comment-create path off the N+1. Precedence is member → project group → program
|
|
572
|
+
group: a project group shadows a same-named program group, so only names still
|
|
573
|
+
unresolved after the project step fall through to the program step.
|
|
574
|
+
"""
|
|
575
|
+
groups_by_lower = _load_project_udg_members(project_id, [name.lower() for name in unresolved])
|
|
576
|
+
still_unresolved = [name for name in unresolved if name.lower() not in groups_by_lower]
|
|
577
|
+
if still_unresolved:
|
|
578
|
+
program_groups = _load_program_udg_members(
|
|
579
|
+
project_id, [name.lower() for name in still_unresolved]
|
|
580
|
+
)
|
|
581
|
+
for key, members in program_groups.items():
|
|
582
|
+
groups_by_lower[key] = members
|
|
583
|
+
return groups_by_lower
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _resolve_user_mentions(
|
|
587
|
+
parsed: Sequence[ParsedMention], project_id: uuid.UUID | str
|
|
588
|
+
) -> tuple[list[UserType], list[tuple[str, list[UserType]]], list[str]]:
|
|
589
|
+
"""Resolve @user tokens to project members, user-defined groups, or skipped names.
|
|
590
|
+
|
|
591
|
+
A real project member always wins on an exact name collision; a non-member name is
|
|
592
|
+
promoted to a user-defined group target when one exists (see
|
|
593
|
+
:func:`_resolve_udg_groups_by_lower`), else recorded in ``skipped_users``.
|
|
594
|
+
"""
|
|
595
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
596
|
+
|
|
597
|
+
user_targets: list[UserType] = []
|
|
598
|
+
group_targets: list[tuple[str, list[UserType]]] = []
|
|
599
|
+
skipped_users: list[str] = []
|
|
600
|
+
|
|
601
|
+
requested_usernames = [m.value for m in parsed if m.kind == "user"]
|
|
602
|
+
if not requested_usernames:
|
|
603
|
+
return user_targets, group_targets, skipped_users
|
|
604
|
+
|
|
605
|
+
member_users = list(
|
|
606
|
+
User.objects.filter(
|
|
607
|
+
username__in=requested_usernames,
|
|
608
|
+
pk__in=ProjectMembership.objects.filter(
|
|
609
|
+
project_id=project_id, is_deleted=False
|
|
610
|
+
).values_list("user_id", flat=True),
|
|
611
|
+
)
|
|
612
|
+
)
|
|
613
|
+
resolved_by_name = {u.username: u for u in member_users}
|
|
614
|
+
unresolved = [name for name in requested_usernames if name not in resolved_by_name]
|
|
615
|
+
groups_by_lower = _resolve_udg_groups_by_lower(project_id, unresolved) if unresolved else {}
|
|
616
|
+
for name in requested_usernames:
|
|
617
|
+
if name in resolved_by_name:
|
|
618
|
+
user_targets.append(resolved_by_name[name])
|
|
619
|
+
elif name.lower() in groups_by_lower:
|
|
620
|
+
group_targets.append((name.lower(), groups_by_lower[name.lower()]))
|
|
621
|
+
else:
|
|
622
|
+
skipped_users.append(name)
|
|
623
|
+
return user_targets, group_targets, skipped_users
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _resolve_group_mentions(
|
|
627
|
+
parsed: Sequence[ParsedMention],
|
|
628
|
+
project_id: uuid.UUID | str,
|
|
629
|
+
actor_role: int | None,
|
|
630
|
+
) -> tuple[list[tuple[str, list[UserType]]], list[str]]:
|
|
631
|
+
"""Resolve @group tokens via ``resolve_group_members``.
|
|
632
|
+
|
|
633
|
+
``@all`` / ``@program-all`` are unbounded fan-outs and require actor_role >= ADMIN
|
|
634
|
+
(ADR-0075 locked constraint #2); unknown or oversized groups land in
|
|
635
|
+
``skipped_groups`` with a structured marker.
|
|
636
|
+
"""
|
|
637
|
+
from trueppm_api.apps.access.models import Role
|
|
638
|
+
|
|
639
|
+
group_targets: list[tuple[str, list[UserType]]] = []
|
|
640
|
+
skipped_groups: list[str] = []
|
|
641
|
+
for m in parsed:
|
|
642
|
+
if m.kind != "group":
|
|
643
|
+
continue
|
|
644
|
+
key = m.value
|
|
645
|
+
if key in ("all", "program-all") and (actor_role is None or actor_role < Role.ADMIN):
|
|
646
|
+
skipped_groups.append(key)
|
|
647
|
+
continue
|
|
648
|
+
try:
|
|
649
|
+
member_ids = resolve_group_members(project_id, key)
|
|
650
|
+
except (InvalidGroupKeyError, GroupTooLargeError):
|
|
651
|
+
skipped_groups.append(key)
|
|
652
|
+
continue
|
|
653
|
+
# resolve_group_members annotates its result list[UUID], but the `pk`
|
|
654
|
+
# lookup is typed Iterable[str|int] under django-stubs; stringify so the
|
|
655
|
+
# ids satisfy the lookup type (Django coerces them back to the User pk).
|
|
656
|
+
members = list(User.objects.filter(pk__in=[str(uid) for uid in member_ids]))
|
|
657
|
+
group_targets.append((key, members))
|
|
658
|
+
return group_targets, skipped_groups
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def resolve_parsed_mentions(
|
|
662
|
+
parsed: Sequence[ParsedMention],
|
|
663
|
+
project_id: uuid.UUID | str,
|
|
664
|
+
*,
|
|
665
|
+
actor_role: int | None = None,
|
|
666
|
+
) -> MentionParseResult:
|
|
667
|
+
"""Turn parser output into validated, project-scoped resolution.
|
|
668
|
+
|
|
669
|
+
- User mentions are filtered to current project members; non-members go to
|
|
670
|
+
`skipped_users` so the caller can surface a structured 400.
|
|
671
|
+
- A `@name` that resolves to neither a member nor an auto-group key
|
|
672
|
+
is reinterpreted as a **user-defined group** (ADR-0212, #515):
|
|
673
|
+
the parser is pure and cannot know a name is a group, so this project-aware
|
|
674
|
+
step promotes it to a group target when a live
|
|
675
|
+
`UserDefinedMentionGroup` with that name exists in the project. A real
|
|
676
|
+
member always wins on an exact name collision.
|
|
677
|
+
- Group mentions go through `resolve_group_members`; `@all` and
|
|
678
|
+
`@program-all` require actor_role >= ADMIN (ADR-0075 locked constraint
|
|
679
|
+
#2). Oversized groups land in `skipped_groups` with a structured marker.
|
|
680
|
+
"""
|
|
681
|
+
user_targets, user_group_targets, skipped_users = _resolve_user_mentions(parsed, project_id)
|
|
682
|
+
auto_group_targets, skipped_groups = _resolve_group_mentions(parsed, project_id, actor_role)
|
|
683
|
+
# User-token-resolved groups precede auto-groups, preserving the original
|
|
684
|
+
# single-list append order (user block first, then the @group loop).
|
|
685
|
+
group_targets = user_group_targets + auto_group_targets
|
|
686
|
+
|
|
687
|
+
# External stakeholders (#1658, ADR-0264) are the non-account arm of a resolved
|
|
688
|
+
# @program-stakeholders mention. Resolve them ONLY when that group actually
|
|
689
|
+
# resolved (it is present in group_targets — a standalone project skips it), and
|
|
690
|
+
# keep them on a distinct field: they have no User account, so they are never
|
|
691
|
+
# unioned into the User-keyed group_targets and never produce Notification rows.
|
|
692
|
+
external_targets: list[ExternalStakeholder] = []
|
|
693
|
+
if any(group_key == "program-stakeholders" for group_key, _ in group_targets):
|
|
694
|
+
from trueppm_api.apps.access.groups import resolve_external_stakeholders
|
|
695
|
+
|
|
696
|
+
external_targets = resolve_external_stakeholders(project_id)
|
|
697
|
+
|
|
698
|
+
return MentionParseResult(
|
|
699
|
+
user_targets=user_targets,
|
|
700
|
+
group_targets=group_targets,
|
|
701
|
+
skipped_users=skipped_users,
|
|
702
|
+
skipped_groups=skipped_groups,
|
|
703
|
+
external_targets=external_targets,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _build_mention_rows(
|
|
708
|
+
*,
|
|
709
|
+
task_comment: TaskComment,
|
|
710
|
+
mentioner: UserType,
|
|
711
|
+
parsed_result: MentionParseResult,
|
|
712
|
+
project_id: uuid.UUID | str,
|
|
713
|
+
scope: MentionScope,
|
|
714
|
+
) -> tuple[list[Mention], dict[str, list[UserType]]]:
|
|
715
|
+
"""Build the unsaved Mention rows and the per-group member index.
|
|
716
|
+
|
|
717
|
+
One Mention row per direct user, then one per group (preserving "@scrum-team was
|
|
718
|
+
mentioned" semantics); the returned index maps each group key to its members for
|
|
719
|
+
the later per-member fan-out. Order matches the original append sequence: direct
|
|
720
|
+
user mentions first, group mentions second.
|
|
721
|
+
"""
|
|
722
|
+
mentions: list[Mention] = []
|
|
723
|
+
for user in parsed_result.user_targets:
|
|
724
|
+
mentions.append(
|
|
725
|
+
Mention(
|
|
726
|
+
mentioner=mentioner,
|
|
727
|
+
mentioned_user=user,
|
|
728
|
+
task_comment=task_comment,
|
|
729
|
+
project_id=project_id,
|
|
730
|
+
scope=scope,
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
group_member_index: dict[str, list[UserType]] = {}
|
|
734
|
+
for group_key, members in parsed_result.group_targets:
|
|
735
|
+
mentions.append(
|
|
736
|
+
Mention(
|
|
737
|
+
mentioner=mentioner,
|
|
738
|
+
mentioned_group_key=group_key,
|
|
739
|
+
task_comment=task_comment,
|
|
740
|
+
project_id=project_id,
|
|
741
|
+
scope=scope,
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
group_member_index[group_key] = members
|
|
745
|
+
return mentions, group_member_index
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _load_udg_email_settings(
|
|
749
|
+
project_id: uuid.UUID | str, mentioned_group_keys: list[str]
|
|
750
|
+
) -> tuple[dict[str, bool], set[tuple[int | str, str]]]:
|
|
751
|
+
"""Load per-group email defaults and mute sets for mentioned user-defined groups.
|
|
752
|
+
|
|
753
|
+
User-defined group routing (ADR-0212 §5). Auto-group keys (@admins, @scrum-team, …)
|
|
754
|
+
simply don't match and keep their existing global-toggle behavior. The returned
|
|
755
|
+
``udg_email_default`` maps a lowercased group name to its manager-set email default;
|
|
756
|
+
``udg_mutes`` is the set of ``(user_id, group_name)`` pairs whose owner muted that
|
|
757
|
+
group. Keys in ``mentioned_group_keys`` are lowercased (see resolve_parsed_mentions),
|
|
758
|
+
so match on ``Lower(name)`` — group names are case-insensitive mention keys even
|
|
759
|
+
though the display name preserves the manager's casing.
|
|
760
|
+
|
|
761
|
+
Program-scoped user-defined groups (ADR-0248, #516) load first so a same-named
|
|
762
|
+
project group overwrites the email default (project wins, matching the resolution
|
|
763
|
+
precedence). Mutes union across scopes — a member who muted either same-named group
|
|
764
|
+
is defensively suppressed (a negligible, documented name-collision case).
|
|
765
|
+
"""
|
|
766
|
+
udg_email_default: dict[str, bool] = {}
|
|
767
|
+
udg_mutes: set[tuple[int | str, str]] = set()
|
|
768
|
+
if not mentioned_group_keys:
|
|
769
|
+
return udg_email_default, udg_mutes
|
|
770
|
+
|
|
771
|
+
from trueppm_api.apps.access.models import (
|
|
772
|
+
ProgramUserDefinedMentionGroup,
|
|
773
|
+
UserDefinedMentionGroup,
|
|
774
|
+
)
|
|
775
|
+
from trueppm_api.apps.projects.models import Project
|
|
776
|
+
|
|
777
|
+
program_id = Project.objects.filter(pk=project_id).values_list("program_id", flat=True).first()
|
|
778
|
+
if program_id is not None:
|
|
779
|
+
pudg_groups = (
|
|
780
|
+
ProgramUserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
|
|
781
|
+
.filter(program_id=program_id, name_lower__in=mentioned_group_keys, is_deleted=False)
|
|
782
|
+
.prefetch_related("muted_by")
|
|
783
|
+
)
|
|
784
|
+
for pgroup in pudg_groups:
|
|
785
|
+
key = pgroup.name.lower()
|
|
786
|
+
udg_email_default[key] = pgroup.email_default_on
|
|
787
|
+
for muter in pgroup.muted_by.all():
|
|
788
|
+
udg_mutes.add((muter.pk, key))
|
|
789
|
+
udg_groups = (
|
|
790
|
+
UserDefinedMentionGroup.objects.annotate(name_lower=Lower("name"))
|
|
791
|
+
.filter(project_id=project_id, name_lower__in=mentioned_group_keys, is_deleted=False)
|
|
792
|
+
.prefetch_related("muted_by")
|
|
793
|
+
)
|
|
794
|
+
for group in udg_groups:
|
|
795
|
+
key = group.name.lower()
|
|
796
|
+
udg_email_default[key] = group.email_default_on
|
|
797
|
+
for muter in group.muted_by.all():
|
|
798
|
+
udg_mutes.add((muter.pk, key))
|
|
799
|
+
return udg_email_default, udg_mutes
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _add_group_recipients(
|
|
803
|
+
recipients: dict[int | str, Mention],
|
|
804
|
+
group_mention: Mention,
|
|
805
|
+
members: list[UserType],
|
|
806
|
+
udg_mutes: set[tuple[int | str, str]],
|
|
807
|
+
mentioner_pk: int | str,
|
|
808
|
+
) -> None:
|
|
809
|
+
"""Fan a group mention out to its members, mutating ``recipients`` in place.
|
|
810
|
+
|
|
811
|
+
Skips the mentioner (don't ping yourself) and any member who muted THIS
|
|
812
|
+
user-defined group (ADR-0212); ``setdefault`` means a member already claimed by a
|
|
813
|
+
direct @mention or an earlier un-muted group is left untouched.
|
|
814
|
+
"""
|
|
815
|
+
group_key = group_mention.mentioned_group_key
|
|
816
|
+
for member in members:
|
|
817
|
+
if member.pk == mentioner_pk:
|
|
818
|
+
continue
|
|
819
|
+
if (member.pk, group_key) in udg_mutes:
|
|
820
|
+
continue
|
|
821
|
+
recipients.setdefault(member.pk, group_mention)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _resolve_mention_recipients(
|
|
825
|
+
mentions: list[Mention],
|
|
826
|
+
group_member_index: dict[str, list[UserType]],
|
|
827
|
+
udg_mutes: set[tuple[int | str, str]],
|
|
828
|
+
mentioner: UserType,
|
|
829
|
+
) -> dict[int | str, Mention]:
|
|
830
|
+
"""Dedup mentions to one source Mention per recipient.
|
|
831
|
+
|
|
832
|
+
A user mentioned directly AND via a group gets one notification; the direct-mention
|
|
833
|
+
row wins (overwriting a prior group-source entry) so the UI shows "@you". Per-group
|
|
834
|
+
mutes (ADR-0212) drop a member for the muting group only — they may still be added
|
|
835
|
+
by a different, un-muted group. The mentioner is never notified.
|
|
836
|
+
"""
|
|
837
|
+
recipients: dict[int | str, Mention] = {}
|
|
838
|
+
for mention in mentions:
|
|
839
|
+
if mention.mentioned_user_id:
|
|
840
|
+
recipients[mention.mentioned_user_id] = mention
|
|
841
|
+
for mention in mentions:
|
|
842
|
+
if mention.mentioned_group_key:
|
|
843
|
+
_add_group_recipients(
|
|
844
|
+
recipients,
|
|
845
|
+
mention,
|
|
846
|
+
group_member_index[mention.mentioned_group_key],
|
|
847
|
+
udg_mutes,
|
|
848
|
+
mentioner.pk,
|
|
849
|
+
)
|
|
850
|
+
# Drop the mentioner from direct mentions too (don't ping yourself)
|
|
851
|
+
recipients.pop(mentioner.pk, None)
|
|
852
|
+
return recipients
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
class _MentionRoutingContext(NamedTuple):
|
|
856
|
+
"""Batch-loaded, per-fan-out routing state for mention notification delivery."""
|
|
857
|
+
|
|
858
|
+
project_tz: ZoneInfo
|
|
859
|
+
project_prefs: dict[int | str, ProjectNotificationPreference]
|
|
860
|
+
global_email: dict[int | str, dict[str, bool]]
|
|
861
|
+
source_project_member_ids: set[Any]
|
|
862
|
+
dnd_user_ids: set[Any]
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _load_mention_routing_context(
|
|
866
|
+
project_id: uuid.UUID | str, recipient_ids: list[int | str]
|
|
867
|
+
) -> _MentionRoutingContext:
|
|
868
|
+
"""Batch-load the per-recipient routing state in a fixed number of queries.
|
|
869
|
+
|
|
870
|
+
- Project timezone + each recipient's ProjectNotificationPreference row (#674):
|
|
871
|
+
evaluated per recipient without an N+1; a recipient with no row evaluates against
|
|
872
|
+
an unsaved instance carrying the model defaults (subsumes the #589 pause check).
|
|
873
|
+
- Global per-user mention email preference (#311): a mention emails only when BOTH
|
|
874
|
+
the project matrix and this global toggle allow it.
|
|
875
|
+
- Source-project membership set (cross-project email read boundary, ADR-0248 §5 /
|
|
876
|
+
ADR-0240 §5): the email render path embeds the raw comment body, so email is
|
|
877
|
+
suppressed for any recipient not a current member of the source project; they
|
|
878
|
+
still get the redacted in-app row.
|
|
879
|
+
- Account-wide DND set (#1707): a mention is not a bypass event, so DND silences its
|
|
880
|
+
email while the durable in-app inbox row still lands.
|
|
881
|
+
"""
|
|
882
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
883
|
+
from trueppm_api.apps.projects.models import Project
|
|
884
|
+
|
|
885
|
+
project = Project.objects.filter(pk=project_id).only("timezone").first()
|
|
886
|
+
project_tz = _project_timezone(project)
|
|
887
|
+
project_prefs: dict[int | str, ProjectNotificationPreference] = {
|
|
888
|
+
proj_pref.user_id: proj_pref
|
|
889
|
+
for proj_pref in ProjectNotificationPreference.objects.filter(
|
|
890
|
+
project_id=project_id, user_id__in=recipient_ids
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
global_email: dict[int | str, dict[str, bool]] = {}
|
|
894
|
+
for global_pref in NotificationPreference.objects.filter(
|
|
895
|
+
user_id__in=recipient_ids, channel=NotificationChannel.EMAIL
|
|
896
|
+
):
|
|
897
|
+
global_email.setdefault(global_pref.user_id, {})[global_pref.event_type] = (
|
|
898
|
+
global_pref.enabled
|
|
899
|
+
)
|
|
900
|
+
source_project_member_ids = set(
|
|
901
|
+
ProjectMembership.objects.filter(
|
|
902
|
+
project_id=project_id, is_deleted=False, user_id__in=recipient_ids
|
|
903
|
+
).values_list("user_id", flat=True)
|
|
904
|
+
)
|
|
905
|
+
dnd_user_ids = load_dnd_user_ids(recipient_ids)
|
|
906
|
+
return _MentionRoutingContext(
|
|
907
|
+
project_tz=project_tz,
|
|
908
|
+
project_prefs=project_prefs,
|
|
909
|
+
global_email=global_email,
|
|
910
|
+
source_project_member_ids=source_project_member_ids,
|
|
911
|
+
dnd_user_ids=dnd_user_ids,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _build_mention_notification(
|
|
916
|
+
*,
|
|
917
|
+
user_id: int | str,
|
|
918
|
+
source_mention: Mention,
|
|
919
|
+
context: _MentionRoutingContext,
|
|
920
|
+
udg_email_default: dict[str, bool],
|
|
921
|
+
project_id: uuid.UUID | str,
|
|
922
|
+
now: datetime.datetime,
|
|
923
|
+
) -> Notification | None:
|
|
924
|
+
"""Build one recipient's Notification row, or None if the in-app gate blocks it.
|
|
925
|
+
|
|
926
|
+
The in-app inbox row is matrix-gated only; quiet hours never drop the durable record
|
|
927
|
+
(see _QUIET_HOURS_EXEMPT_CHANNELS). The email gate layers the project matrix
|
|
928
|
+
(comment_mention/email) + quiet hours with the per-source opt-in:
|
|
929
|
+
- user-defined group (ADR-0212 §5): the group manager's per-group email default is
|
|
930
|
+
the self-contained opt-in — a mute already removed the recipient from the in-app
|
|
931
|
+
gate above, so reaching here means un-muted; email follows the group default.
|
|
932
|
+
- direct mention / auto-group: the existing global per-user toggle (#311) —
|
|
933
|
+
MENTION_INDIVIDUAL / MENTION_GROUP — governs, default OFF.
|
|
934
|
+
then the cross-project read boundary and account-wide DND (#1707).
|
|
935
|
+
"""
|
|
936
|
+
event_type_project = ProjectNotificationEventType.COMMENT_MENTION.value
|
|
937
|
+
pref = context.project_prefs.get(user_id) or ProjectNotificationPreference()
|
|
938
|
+
if not _preference_allows(
|
|
939
|
+
pref,
|
|
940
|
+
event_type=event_type_project,
|
|
941
|
+
channel=ProjectNotificationChannel.IN_APP.value,
|
|
942
|
+
now=now,
|
|
943
|
+
tz=context.project_tz,
|
|
944
|
+
):
|
|
945
|
+
return None
|
|
946
|
+
global_event = (
|
|
947
|
+
NotificationEventType.MENTION_INDIVIDUAL.value
|
|
948
|
+
if source_mention.mentioned_user_id
|
|
949
|
+
else NotificationEventType.MENTION_GROUP.value
|
|
950
|
+
)
|
|
951
|
+
source_group_key = source_mention.mentioned_group_key
|
|
952
|
+
if source_group_key and source_group_key in udg_email_default:
|
|
953
|
+
per_user_opt_in = udg_email_default[source_group_key]
|
|
954
|
+
else:
|
|
955
|
+
per_user_opt_in = context.global_email.get(user_id, {}).get(global_event, False)
|
|
956
|
+
email_pending = (
|
|
957
|
+
_preference_allows(
|
|
958
|
+
pref,
|
|
959
|
+
event_type=event_type_project,
|
|
960
|
+
channel=ProjectNotificationChannel.EMAIL.value,
|
|
961
|
+
now=now,
|
|
962
|
+
tz=context.project_tz,
|
|
963
|
+
)
|
|
964
|
+
and per_user_opt_in
|
|
965
|
+
and user_id in context.source_project_member_ids
|
|
966
|
+
and not _dnd_silences(
|
|
967
|
+
global_event,
|
|
968
|
+
ProjectNotificationChannel.EMAIL.value,
|
|
969
|
+
dnd_enabled=user_id in context.dnd_user_ids,
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
return Notification(
|
|
973
|
+
recipient_id=user_id,
|
|
974
|
+
mention=source_mention,
|
|
975
|
+
project_id=project_id,
|
|
976
|
+
email_pending=email_pending,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def create_mention_notifications(
|
|
981
|
+
*,
|
|
982
|
+
task_comment: TaskComment,
|
|
983
|
+
mentioner: UserType,
|
|
984
|
+
parsed_result: MentionParseResult,
|
|
985
|
+
project_id: uuid.UUID | str,
|
|
986
|
+
scope: MentionScope = MentionScope.PROJECT_VISIBLE,
|
|
987
|
+
now: datetime.datetime | None = None,
|
|
988
|
+
) -> int:
|
|
989
|
+
"""Create Mention + Notification rows from a parsed comment.
|
|
990
|
+
|
|
991
|
+
Returns the number of Notification rows created. Direct mentions and
|
|
992
|
+
group members are deduplicated per-recipient (one user mentioned both
|
|
993
|
+
directly and via @scrum-team gets one Notification, not two).
|
|
994
|
+
|
|
995
|
+
Each recipient is gated by their ProjectNotificationPreference for the
|
|
996
|
+
`comment_mention` event (#674): the in-app row is created only if the matrix
|
|
997
|
+
`in_app` cell is on, and `email_pending` is set only if the matrix `email`
|
|
998
|
+
cell is on AND outside quiet hours AND the user's global mention email
|
|
999
|
+
preference is on. `now` is injectable so quiet-hours behavior is testable.
|
|
1000
|
+
|
|
1001
|
+
All writes happen in the caller's transaction — the response can include
|
|
1002
|
+
the count immediately. Email delivery is best-effort via `email_pending`
|
|
1003
|
+
+ the `drain_notification_emails` Beat task (not this function).
|
|
1004
|
+
"""
|
|
1005
|
+
if now is None:
|
|
1006
|
+
now = timezone.now()
|
|
1007
|
+
|
|
1008
|
+
mentions, group_member_index = _build_mention_rows(
|
|
1009
|
+
task_comment=task_comment,
|
|
1010
|
+
mentioner=mentioner,
|
|
1011
|
+
parsed_result=parsed_result,
|
|
1012
|
+
project_id=project_id,
|
|
1013
|
+
scope=scope,
|
|
1014
|
+
)
|
|
1015
|
+
udg_email_default, udg_mutes = _load_udg_email_settings(
|
|
1016
|
+
project_id, list(group_member_index.keys())
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
if not mentions:
|
|
1020
|
+
return 0
|
|
1021
|
+
|
|
1022
|
+
Mention.objects.bulk_create(mentions)
|
|
1023
|
+
|
|
1024
|
+
recipients = _resolve_mention_recipients(mentions, group_member_index, udg_mutes, mentioner)
|
|
1025
|
+
if not recipients:
|
|
1026
|
+
return 0
|
|
1027
|
+
|
|
1028
|
+
context = _load_mention_routing_context(project_id, list(recipients.keys()))
|
|
1029
|
+
|
|
1030
|
+
notifications: list[Notification] = []
|
|
1031
|
+
for user_id, source_mention in recipients.items():
|
|
1032
|
+
notification = _build_mention_notification(
|
|
1033
|
+
user_id=user_id,
|
|
1034
|
+
source_mention=source_mention,
|
|
1035
|
+
context=context,
|
|
1036
|
+
udg_email_default=udg_email_default,
|
|
1037
|
+
project_id=project_id,
|
|
1038
|
+
now=now,
|
|
1039
|
+
)
|
|
1040
|
+
if notification is not None:
|
|
1041
|
+
notifications.append(notification)
|
|
1042
|
+
if not notifications:
|
|
1043
|
+
return 0
|
|
1044
|
+
Notification.objects.bulk_create(notifications)
|
|
1045
|
+
return len(notifications)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _build_event_notification_gate(
|
|
1049
|
+
event_type: str, unique_ids: set[int | str]
|
|
1050
|
+
) -> tuple[Callable[[int | str, str], bool], set[Any]]:
|
|
1051
|
+
"""Per-recipient (event_type, channel) gating shared by every
|
|
1052
|
+
``create_event_notifications*`` fan-out: one ``NotificationPreference``
|
|
1053
|
+
query plus one DND lookup over the unique recipient set, regardless of how
|
|
1054
|
+
many rows or projects the caller is about to write.
|
|
1055
|
+
|
|
1056
|
+
Returns the ``_allows(user_id, channel)`` predicate (stored preference,
|
|
1057
|
+
falling back to ``DEFAULT_PREFERENCES``) and the set of recipients with
|
|
1058
|
+
account-wide DND enabled (#1707), for the caller to pass to
|
|
1059
|
+
``_dnd_silences`` per row.
|
|
1060
|
+
"""
|
|
1061
|
+
defaults = {(et, ch): enabled for et, ch, enabled in DEFAULT_PREFERENCES}
|
|
1062
|
+
stored: dict[int | str, dict[str, bool]] = {}
|
|
1063
|
+
for pref in NotificationPreference.objects.filter(
|
|
1064
|
+
user_id__in=unique_ids, event_type=event_type
|
|
1065
|
+
):
|
|
1066
|
+
stored.setdefault(pref.user_id, {})[pref.channel] = pref.enabled
|
|
1067
|
+
|
|
1068
|
+
def _allows(user_id: int | str, channel: str) -> bool:
|
|
1069
|
+
per_user = stored.get(user_id, {})
|
|
1070
|
+
if channel in per_user:
|
|
1071
|
+
return per_user[channel]
|
|
1072
|
+
return defaults.get((event_type, channel), False)
|
|
1073
|
+
|
|
1074
|
+
# Account-wide DND (#1707) silences email for non-bypass events. Loaded once
|
|
1075
|
+
# over the recipient set; the in-app inbox row below is never gated by DND.
|
|
1076
|
+
dnd_user_ids = load_dnd_user_ids(unique_ids)
|
|
1077
|
+
return _allows, dnd_user_ids
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def create_event_notifications(
|
|
1081
|
+
*,
|
|
1082
|
+
event_type: str,
|
|
1083
|
+
recipient_ids: Sequence[int | str | None],
|
|
1084
|
+
subject: str,
|
|
1085
|
+
body: str,
|
|
1086
|
+
project_id: uuid.UUID | str | None,
|
|
1087
|
+
task_id: uuid.UUID | str | None = None,
|
|
1088
|
+
) -> int:
|
|
1089
|
+
"""Create event-sourced Notification rows for an own-task event (#639).
|
|
1090
|
+
|
|
1091
|
+
Gated by each recipient's **global** ``NotificationPreference`` for
|
|
1092
|
+
``(event_type, channel)`` — the per-user toggles on the User → Settings →
|
|
1093
|
+
Notifications page — falling back to ``DEFAULT_PREFERENCES`` for users who
|
|
1094
|
+
have never visited that page (no stored rows). Mirrors the mention path's
|
|
1095
|
+
coupling (ADR-0085 §4): the in-app inbox row is the durable record, created
|
|
1096
|
+
only when ``in_app`` is enabled; ``email_pending`` is set additionally when
|
|
1097
|
+
``email`` is enabled (default OFF — Priya's VoC blocker). A recipient who has
|
|
1098
|
+
turned ``in_app`` off for this event opts out of both channels.
|
|
1099
|
+
|
|
1100
|
+
The ``subject``/``body`` are rendered by the caller at dispatch time and
|
|
1101
|
+
frozen onto each row, so the drain can send the email without re-deriving it
|
|
1102
|
+
from a (possibly later-mutated or deleted) source object.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
event_type: A ``NotificationEventType`` value (e.g. ``"task.assigned"``).
|
|
1106
|
+
recipient_ids: User PKs to notify; ``None`` and duplicates are dropped,
|
|
1107
|
+
so callers may pass the actor without special-casing.
|
|
1108
|
+
subject: Pre-rendered email subject line.
|
|
1109
|
+
body: Pre-rendered plain-text email body.
|
|
1110
|
+
project_id: The project the event occurred on (scopes the inbox row).
|
|
1111
|
+
``None`` only for account-scoped rows that genuinely have no owning
|
|
1112
|
+
project — the ADR-0663 scheduled digests, which span the recipient's
|
|
1113
|
+
whole membership set. Every event- and mention-sourced caller passes a
|
|
1114
|
+
real project.
|
|
1115
|
+
task_id: Optional deep-link target — the task/milestone the inbox row
|
|
1116
|
+
should link to (#497/#861). ``None`` for events with no task anchor.
|
|
1117
|
+
|
|
1118
|
+
Returns:
|
|
1119
|
+
The number of Notification rows created.
|
|
1120
|
+
"""
|
|
1121
|
+
unique_ids = {rid for rid in recipient_ids if rid is not None}
|
|
1122
|
+
if not unique_ids:
|
|
1123
|
+
return 0
|
|
1124
|
+
|
|
1125
|
+
_allows, dnd_user_ids = _build_event_notification_gate(event_type, unique_ids)
|
|
1126
|
+
|
|
1127
|
+
notifications: list[Notification] = []
|
|
1128
|
+
for user_id in unique_ids:
|
|
1129
|
+
if not _allows(user_id, NotificationChannel.IN_APP.value):
|
|
1130
|
+
continue
|
|
1131
|
+
notifications.append(
|
|
1132
|
+
Notification(
|
|
1133
|
+
recipient_id=user_id,
|
|
1134
|
+
event_type=event_type,
|
|
1135
|
+
subject=subject,
|
|
1136
|
+
body=body,
|
|
1137
|
+
project_id=project_id,
|
|
1138
|
+
task_id=task_id,
|
|
1139
|
+
email_pending=_allows(user_id, NotificationChannel.EMAIL.value)
|
|
1140
|
+
and not _dnd_silences(
|
|
1141
|
+
event_type,
|
|
1142
|
+
NotificationChannel.EMAIL.value,
|
|
1143
|
+
dnd_enabled=user_id in dnd_user_ids,
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
)
|
|
1147
|
+
if not notifications:
|
|
1148
|
+
return 0
|
|
1149
|
+
Notification.objects.bulk_create(notifications)
|
|
1150
|
+
return len(notifications)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
#: Rows per INSERT for the event fan-outs below.
|
|
1154
|
+
#:
|
|
1155
|
+
#: ``Notification`` carries 17 concrete columns, so a single unbounded
|
|
1156
|
+
#: ``bulk_create`` crosses Postgres's 65535 bind-parameter ceiling at ~3,850 rows and
|
|
1157
|
+
#: raises. That was unreachable while every fan-out was scoped to one project's
|
|
1158
|
+
#: recipients; batching a program-matrix apply across up to ``MAX_BULK_TARGETS``
|
|
1159
|
+
#: projects into one call makes it reachable, and the emit's ``except Exception``
|
|
1160
|
+
#: would swallow the failure whole and drop every notice silently (#3335).
|
|
1161
|
+
NOTIFICATION_BULK_BATCH_SIZE = 500
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def create_event_notifications_batch(
|
|
1165
|
+
*,
|
|
1166
|
+
event_type: str,
|
|
1167
|
+
project_id: uuid.UUID | str,
|
|
1168
|
+
rows: Sequence[tuple[int | str | None, str, str, uuid.UUID | str | None]],
|
|
1169
|
+
) -> int:
|
|
1170
|
+
"""Create per-recipient event Notification rows for a batch of
|
|
1171
|
+
``(recipient_id, subject, body, task_id)`` tuples in ONE preference lookup
|
|
1172
|
+
and one ``bulk_create``.
|
|
1173
|
+
|
|
1174
|
+
Same gating as ``create_event_notifications`` — each row is written only when
|
|
1175
|
+
the recipient's ``NotificationPreference`` (falling back to
|
|
1176
|
+
``DEFAULT_PREFERENCES``) enables ``in_app`` for ``event_type``, and
|
|
1177
|
+
``email_pending`` is set when ``email`` is enabled. Use this instead of
|
|
1178
|
+
calling ``create_event_notifications`` in a loop when one event fans out to
|
|
1179
|
+
many recipients with a *different* per-row subject/body/deep-link (e.g.
|
|
1180
|
+
sprint-close carryover, ADR-0232 #1470): a loop would issue one preference
|
|
1181
|
+
query and one insert *per row* — and repeat the identical query for a
|
|
1182
|
+
recipient who appears more than once — whereas this collapses to a single
|
|
1183
|
+
``NotificationPreference`` query over the unique recipient set plus one
|
|
1184
|
+
``bulk_create``.
|
|
1185
|
+
|
|
1186
|
+
Args:
|
|
1187
|
+
event_type: A ``NotificationEventType`` value.
|
|
1188
|
+
project_id: The project the event occurred on (scopes every row).
|
|
1189
|
+
rows: ``(recipient_id, subject, body, task_id)`` tuples. ``None``
|
|
1190
|
+
recipients are skipped; ``task_id`` may be ``None`` for a row with no
|
|
1191
|
+
single task anchor (e.g. a multi-task summary).
|
|
1192
|
+
|
|
1193
|
+
Every row lands on the same ``project_id``. When one dispatch spans several
|
|
1194
|
+
projects, call :func:`create_event_notifications_multi_project` — which this
|
|
1195
|
+
delegates to — rather than looping this one, or the preference scan, the DND
|
|
1196
|
+
scan and the insert all repeat per project.
|
|
1197
|
+
|
|
1198
|
+
Returns:
|
|
1199
|
+
The number of Notification rows created.
|
|
1200
|
+
"""
|
|
1201
|
+
return create_event_notifications_multi_project(
|
|
1202
|
+
event_type=event_type,
|
|
1203
|
+
rows=[(row[0], project_id, row[1], row[2], row[3]) for row in rows],
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def create_event_notifications_multi_project(
|
|
1208
|
+
*,
|
|
1209
|
+
event_type: str,
|
|
1210
|
+
rows: Sequence[
|
|
1211
|
+
tuple[int | str | None, uuid.UUID | str | None, str, str, uuid.UUID | str | None]
|
|
1212
|
+
],
|
|
1213
|
+
) -> int:
|
|
1214
|
+
"""Create per-recipient event Notification rows spanning **many** projects in
|
|
1215
|
+
one preference lookup, one DND lookup and one ``bulk_create``.
|
|
1216
|
+
|
|
1217
|
+
The project-spanning form of :func:`create_event_notifications_batch`, which
|
|
1218
|
+
delegates here: ``Notification`` already carries ``project_id`` per row, so a
|
|
1219
|
+
fan-out over a batch of projects has no reason to issue one insert (and one
|
|
1220
|
+
preference scan, and one DND scan) per project. The program settings matrix
|
|
1221
|
+
applies one field map to as many as ``MAX_BULK_TARGETS`` projects and emits a
|
|
1222
|
+
surface notice for each; looping the single-project form there cost three
|
|
1223
|
+
queries per project for no gain (#3335).
|
|
1224
|
+
|
|
1225
|
+
Gating is identical and per **recipient**, not per project — the
|
|
1226
|
+
``NotificationPreference`` matrix and account-wide DND are account-scoped, so
|
|
1227
|
+
one lookup over the union of recipients answers every row.
|
|
1228
|
+
|
|
1229
|
+
Args:
|
|
1230
|
+
event_type: A ``NotificationEventType`` value.
|
|
1231
|
+
rows: ``(recipient_id, project_id, subject, body, task_id)`` tuples.
|
|
1232
|
+
``None`` recipients are skipped; ``task_id`` may be ``None`` for a row
|
|
1233
|
+
with no single task anchor.
|
|
1234
|
+
|
|
1235
|
+
Returns:
|
|
1236
|
+
The number of Notification rows created.
|
|
1237
|
+
"""
|
|
1238
|
+
unique_ids = {row[0] for row in rows if row[0] is not None}
|
|
1239
|
+
if not unique_ids:
|
|
1240
|
+
return 0
|
|
1241
|
+
|
|
1242
|
+
_allows, dnd_user_ids = _build_event_notification_gate(event_type, unique_ids)
|
|
1243
|
+
|
|
1244
|
+
notifications: list[Notification] = []
|
|
1245
|
+
for recipient_id, project_id, subject, body, task_id in rows:
|
|
1246
|
+
if recipient_id is None or not _allows(recipient_id, NotificationChannel.IN_APP.value):
|
|
1247
|
+
continue
|
|
1248
|
+
notifications.append(
|
|
1249
|
+
Notification(
|
|
1250
|
+
recipient_id=recipient_id,
|
|
1251
|
+
event_type=event_type,
|
|
1252
|
+
subject=subject,
|
|
1253
|
+
body=body,
|
|
1254
|
+
project_id=project_id,
|
|
1255
|
+
task_id=task_id,
|
|
1256
|
+
email_pending=_allows(recipient_id, NotificationChannel.EMAIL.value)
|
|
1257
|
+
and not _dnd_silences(
|
|
1258
|
+
event_type,
|
|
1259
|
+
NotificationChannel.EMAIL.value,
|
|
1260
|
+
dnd_enabled=recipient_id in dnd_user_ids,
|
|
1261
|
+
),
|
|
1262
|
+
)
|
|
1263
|
+
)
|
|
1264
|
+
if not notifications:
|
|
1265
|
+
return 0
|
|
1266
|
+
Notification.objects.bulk_create(notifications, batch_size=NOTIFICATION_BULK_BATCH_SIZE)
|
|
1267
|
+
return len(notifications)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
# Default per-board stale threshold, mirrored on Project.stale_task_threshold_days
|
|
1271
|
+
# so a Project that predates the field (or has it cleared) still gets a sane cutoff.
|
|
1272
|
+
DEFAULT_STALE_TASK_THRESHOLD_DAYS = 7
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _stale_pref_allows(
|
|
1276
|
+
stored: dict[Any, dict[str, bool]],
|
|
1277
|
+
defaults: dict[tuple[str, str], bool],
|
|
1278
|
+
event: str,
|
|
1279
|
+
user_id: Any,
|
|
1280
|
+
channel: str,
|
|
1281
|
+
) -> bool:
|
|
1282
|
+
"""Resolve one (user, channel) delivery decision against stored prefs + defaults.
|
|
1283
|
+
|
|
1284
|
+
A module-level helper (not a closure) so it does not capture the per-project loop
|
|
1285
|
+
variables in :func:`create_stale_task_notifications` — same fall-back semantics as
|
|
1286
|
+
:func:`create_event_notifications`: stored preference wins, else the default.
|
|
1287
|
+
"""
|
|
1288
|
+
per_user = stored.get(user_id, {})
|
|
1289
|
+
if channel in per_user:
|
|
1290
|
+
return per_user[channel]
|
|
1291
|
+
return defaults.get((event, channel), False)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _build_stale_notification_rows(
|
|
1295
|
+
*,
|
|
1296
|
+
candidates: list[tuple[Any, str, Any]],
|
|
1297
|
+
already_notified: set[tuple[Any, Any]],
|
|
1298
|
+
stored: dict[Any, dict[str, bool]],
|
|
1299
|
+
defaults: dict[tuple[str, str], bool],
|
|
1300
|
+
event: str,
|
|
1301
|
+
dnd_user_ids: set[Any],
|
|
1302
|
+
threshold: int,
|
|
1303
|
+
project_id: Any,
|
|
1304
|
+
) -> list[Notification]:
|
|
1305
|
+
"""Build the unsaved stale-task Notification rows for one project.
|
|
1306
|
+
|
|
1307
|
+
Extracted from :func:`create_stale_task_notifications` so the per-project driver
|
|
1308
|
+
loop stays flat; this owns the per-candidate skip/allow filtering and message
|
|
1309
|
+
construction. Skips any ``(assignee, task)`` already carrying an unread nudge and
|
|
1310
|
+
any assignee whose in-app preference is off — identical semantics to the inline
|
|
1311
|
+
loop it replaces.
|
|
1312
|
+
"""
|
|
1313
|
+
rows: list[Notification] = []
|
|
1314
|
+
for task_id, name, assignee_id in candidates:
|
|
1315
|
+
if (assignee_id, task_id) in already_notified:
|
|
1316
|
+
continue
|
|
1317
|
+
if not _stale_pref_allows(
|
|
1318
|
+
stored, defaults, event, assignee_id, NotificationChannel.IN_APP.value
|
|
1319
|
+
):
|
|
1320
|
+
continue
|
|
1321
|
+
# Task.name is CharField(max_length=512) but Notification.subject is
|
|
1322
|
+
# max_length=255. bulk_create bypasses field validation, so an over-long
|
|
1323
|
+
# name would raise a Postgres DataError and abort the whole nightly scan
|
|
1324
|
+
# (this project and every later one). Truncate the name used in the
|
|
1325
|
+
# subject well under the limit, mirroring _sanitize_snippet's bounding.
|
|
1326
|
+
display_name = name if len(name) <= 200 else name[:200].rstrip() + "…"
|
|
1327
|
+
subject = f'"{display_name}" has gone stale'
|
|
1328
|
+
body = (
|
|
1329
|
+
f'Your task "{display_name}" has sat in the same status for more than '
|
|
1330
|
+
f"{threshold} days. If it is still active, move it forward; "
|
|
1331
|
+
f"otherwise update its status so the board reflects reality."
|
|
1332
|
+
)
|
|
1333
|
+
rows.append(
|
|
1334
|
+
Notification(
|
|
1335
|
+
recipient_id=assignee_id,
|
|
1336
|
+
event_type=event,
|
|
1337
|
+
subject=subject,
|
|
1338
|
+
body=body,
|
|
1339
|
+
project_id=project_id,
|
|
1340
|
+
task_id=task_id,
|
|
1341
|
+
email_pending=_stale_pref_allows(
|
|
1342
|
+
stored, defaults, event, assignee_id, NotificationChannel.EMAIL.value
|
|
1343
|
+
)
|
|
1344
|
+
and not _dnd_silences(
|
|
1345
|
+
event,
|
|
1346
|
+
NotificationChannel.EMAIL.value,
|
|
1347
|
+
dnd_enabled=assignee_id in dnd_user_ids,
|
|
1348
|
+
),
|
|
1349
|
+
)
|
|
1350
|
+
)
|
|
1351
|
+
return rows
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def create_stale_task_notifications(
|
|
1355
|
+
*,
|
|
1356
|
+
now: datetime.datetime | None = None,
|
|
1357
|
+
) -> int:
|
|
1358
|
+
"""Scan every project for stale non-terminal tasks and nudge their assignees.
|
|
1359
|
+
|
|
1360
|
+
ADR-0200. A task is *stale* when it has sat in its current status for more than
|
|
1361
|
+
the owning project's ``stale_task_threshold_days`` (default 7) and that status is
|
|
1362
|
+
non-terminal — every ``TaskStatus`` except ``COMPLETE``. Staleness is defined by
|
|
1363
|
+
*status column*, not ``percent_complete``: a card in ``REVIEW`` coerces
|
|
1364
|
+
``percent_complete`` to 100 (it is functionally done, pending sign-off) yet is the
|
|
1365
|
+
flagship "task I forgot in Review" case, so it must stay in scope. The threshold is
|
|
1366
|
+
per-project, so the cutoff is evaluated one project at a time.
|
|
1367
|
+
|
|
1368
|
+
Only the task's **assignee** is notified — an unassigned card has no single owner
|
|
1369
|
+
to nudge (those are surfaced by the board-card chip instead). Delivery is gated by
|
|
1370
|
+
each assignee's global ``NotificationPreference`` for ``(task.stale, channel)``,
|
|
1371
|
+
exactly like :func:`create_event_notifications`, but resolved and written in bulk
|
|
1372
|
+
to keep the daily scan off the N+1 path.
|
|
1373
|
+
|
|
1374
|
+
Idempotent: a ``(recipient, task)`` that already has an **unread, un-archived**
|
|
1375
|
+
``task.stale`` notification is skipped, so re-running the scan (retry, manual
|
|
1376
|
+
re-queue, or the next daily tick) creates zero duplicates.
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
now: Injected clock for deterministic testing; defaults to ``timezone.now()``.
|
|
1380
|
+
|
|
1381
|
+
Returns:
|
|
1382
|
+
The total number of Notification rows created across all projects.
|
|
1383
|
+
"""
|
|
1384
|
+
from trueppm_api.apps.projects.lifecycle import visible_projects
|
|
1385
|
+
from trueppm_api.apps.projects.models import Project, Task, TaskStatus
|
|
1386
|
+
|
|
1387
|
+
now = now or timezone.now()
|
|
1388
|
+
event = NotificationEventType.TASK_STALE.value
|
|
1389
|
+
defaults = {(et, ch): enabled for et, ch, enabled in DEFAULT_PREFERENCES}
|
|
1390
|
+
non_terminal = [s for s in TaskStatus.values if s != TaskStatus.COMPLETE]
|
|
1391
|
+
|
|
1392
|
+
total = 0
|
|
1393
|
+
# Drafts are excluded (#2962/#3128): this is an install-wide nightly fan-out, and
|
|
1394
|
+
# every card in a plan nobody has committed to is "stale" by construction — the
|
|
1395
|
+
# plan is still being written. Nudging an assignee about it would be the exclusion
|
|
1396
|
+
# list's stated failure, delivered daily.
|
|
1397
|
+
for project in (
|
|
1398
|
+
visible_projects(Project.objects.filter(is_deleted=False))
|
|
1399
|
+
.only("id", "stale_task_threshold_days")
|
|
1400
|
+
.iterator()
|
|
1401
|
+
):
|
|
1402
|
+
threshold = project.stale_task_threshold_days or DEFAULT_STALE_TASK_THRESHOLD_DAYS
|
|
1403
|
+
cutoff = now - datetime.timedelta(days=threshold)
|
|
1404
|
+
candidates = list(
|
|
1405
|
+
Task.objects.filter(
|
|
1406
|
+
project_id=project.id,
|
|
1407
|
+
is_deleted=False,
|
|
1408
|
+
status__in=non_terminal,
|
|
1409
|
+
status_changed_at__lt=cutoff,
|
|
1410
|
+
assignee_id__isnull=False,
|
|
1411
|
+
).values_list("id", "name", "assignee_id")
|
|
1412
|
+
)
|
|
1413
|
+
if not candidates:
|
|
1414
|
+
continue
|
|
1415
|
+
|
|
1416
|
+
task_ids = [row[0] for row in candidates]
|
|
1417
|
+
already_notified = set(
|
|
1418
|
+
Notification.objects.filter(
|
|
1419
|
+
event_type=event,
|
|
1420
|
+
is_read=False,
|
|
1421
|
+
is_archived=False,
|
|
1422
|
+
task_id__in=task_ids,
|
|
1423
|
+
).values_list("recipient_id", "task_id")
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
assignee_ids = {row[2] for row in candidates}
|
|
1427
|
+
stored: dict[Any, dict[str, bool]] = {}
|
|
1428
|
+
for pref in NotificationPreference.objects.filter(
|
|
1429
|
+
user_id__in=assignee_ids, event_type=event
|
|
1430
|
+
):
|
|
1431
|
+
stored.setdefault(pref.user_id, {})[pref.channel] = pref.enabled
|
|
1432
|
+
# Account-wide DND (#1707): task.stale is not a bypass event, so DND
|
|
1433
|
+
# suppresses its email nudge while the durable in-app row still lands.
|
|
1434
|
+
dnd_user_ids = load_dnd_user_ids(assignee_ids)
|
|
1435
|
+
|
|
1436
|
+
rows = _build_stale_notification_rows(
|
|
1437
|
+
candidates=candidates,
|
|
1438
|
+
already_notified=already_notified,
|
|
1439
|
+
stored=stored,
|
|
1440
|
+
defaults=defaults,
|
|
1441
|
+
event=event,
|
|
1442
|
+
dnd_user_ids=dnd_user_ids,
|
|
1443
|
+
threshold=threshold,
|
|
1444
|
+
project_id=project.id,
|
|
1445
|
+
)
|
|
1446
|
+
if rows:
|
|
1447
|
+
# batch_size caps the INSERT statement size so a pathologically large
|
|
1448
|
+
# single-project backlog is chunked rather than one unbounded statement.
|
|
1449
|
+
Notification.objects.bulk_create(rows, batch_size=500)
|
|
1450
|
+
total += len(rows)
|
|
1451
|
+
return total
|