trueppm-api 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +27 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +346 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +483 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +184 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_squashed_0019_release_0_4.py +302 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +143 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/minter_role.py +38 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +3263 -0
- trueppm_api/apps/access/serializers.py +1007 -0
- trueppm_api/apps/access/services.py +878 -0
- trueppm_api/apps/access/signals.py +171 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +96 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1965 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0001_squashed_0003_release_0_4.py +344 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +109 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0001_squashed_0003_release_0_4.py +90 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +459 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +178 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +863 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +597 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_squashed_0014_release_0_4.py +225 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/0014_tasklink_created_by.py +27 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +635 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +528 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +734 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1158 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +280 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +551 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1276 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +439 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +462 -0
- trueppm_api/apps/notifications/email_backend.py +350 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +299 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_squashed_0011_release_0_4.py +204 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1148 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +1094 -0
- trueppm_api/apps/notifications/services.py +1489 -0
- trueppm_api/apps/notifications/tasks.py +762 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +919 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +566 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +993 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +537 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_squashed_0009_release_0_4.py +128 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +541 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +458 -0
- trueppm_api/apps/projects/backfill.py +459 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +212 -0
- trueppm_api/apps/projects/config_notice.py +1330 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +132 -0
- trueppm_api/apps/projects/decisions_views.py +97 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +423 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4278 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2001 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1827 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1909 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1410 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/key_views.py +468 -0
- trueppm_api/apps/projects/keys.py +205 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +514 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +406 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +192 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migration_helpers.py +75 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5659 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +59 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +75 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +76 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0106_squashed_0158_release_0_4.py +2357 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/0154_object_keys.py +184 -0
- trueppm_api/apps/projects/migrations/0155_task_milestone_at_day_end.py +20 -0
- trueppm_api/apps/projects/migrations/0156_program_import_job_replaced_project_ids.py +17 -0
- trueppm_api/apps/projects/migrations/0157_forecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/0158_baselinetask_finish_at_day_start.py +17 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +9209 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +903 -0
- trueppm_api/apps/projects/program_schedule.py +767 -0
- trueppm_api/apps/projects/program_views.py +3445 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +492 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +891 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/demo_landing_overlay.py +532 -0
- trueppm_api/apps/projects/seed/exporter.py +1717 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2764 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +128 -0
- trueppm_api/apps/projects/seed/replay.py +1695 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1169 -0
- trueppm_api/apps/projects/serializers.py +12904 -0
- trueppm_api/apps/projects/services.py +7525 -0
- trueppm_api/apps/projects/share_serializers.py +142 -0
- trueppm_api/apps/projects/share_services.py +373 -0
- trueppm_api/apps/projects/share_views.py +395 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +910 -0
- trueppm_api/apps/projects/signal_privacy_views.py +485 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +288 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1244 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1979 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +261 -0
- trueppm_api/apps/projects/template_views.py +829 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1016 -0
- trueppm_api/apps/projects/utilization.py +909 -0
- trueppm_api/apps/projects/views.py +21225 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0014_squashed_0015_release_0_4.py +47 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1752 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +144 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/finish_reading.py +198 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/mc_admission.py +181 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0010_squashed_0016_release_0_4.py +106 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/0015_projectforecastsnapshot_cpm_finish_at_day_start.py +17 -0
- trueppm_api/apps/scheduling/migrations/0016_montecarlorun_finish_readings.py +32 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +542 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +334 -0
- trueppm_api/apps/scheduling/serializers.py +723 -0
- trueppm_api/apps/scheduling/services.py +1251 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +2248 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2908 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +446 -0
- trueppm_api/apps/sso/services.py +1473 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +369 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +548 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0003_squashed_0005_release_0_4.py +77 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/0006_boardevent_per_project_seq.py +77 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +272 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +488 -0
- trueppm_api/apps/sync/serializers.py +688 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +556 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +957 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +159 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/0004_backfill_default_teams_on_upgrade.py +49 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +440 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0001_squashed_0004_release_0_4.py +167 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +211 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +597 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +279 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +249 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +100 -0
- trueppm_api/apps/webhooks/migrations/0010_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/0011_webhookeditor.py +48 -0
- trueppm_api/apps/webhooks/migrations/0012_alter_webhook_disabled_at.py +21 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +437 -0
- trueppm_api/apps/webhooks/serializers.py +326 -0
- trueppm_api/apps/webhooks/tasks.py +680 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +533 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +371 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +254 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_squashed_0027_release_0_4.py +199 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +967 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +616 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1581 -0
- trueppm_api/asgi.py +49 -0
- trueppm_api/celery.py +40 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +934 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/demo_read_only.py +314 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +1221 -0
- trueppm_api/core/password_reset.py +513 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +1034 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +514 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/core/worker_selfheal.py +205 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2605 -0
- trueppm_api/settings/dev.py +296 -0
- trueppm_api/settings/prod.py +265 -0
- trueppm_api/urls.py +217 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0.dist-info/METADATA +273 -0
- trueppm_api-0.4.0.dist-info/RECORD +799 -0
- trueppm_api-0.4.0.dist-info/WHEEL +4 -0
- trueppm_api-0.4.0.dist-info/licenses/LICENSE +201 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICE +7 -0
- trueppm_api-0.4.0.dist-info/licenses/NOTICES +104 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/GPL-3.0.txt +674 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/LGPL-3.0.txt +165 -0
- trueppm_api-0.4.0.dist-info/licenses/licenses/MPL-2.0.txt +373 -0
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
"""DRF serializers for the notifications app (ADR-0075)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import zoneinfo
|
|
8
|
+
from functools import partial
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from drf_spectacular.utils import extend_schema_field
|
|
12
|
+
from rest_framework import serializers
|
|
13
|
+
|
|
14
|
+
from trueppm_api.apps.projects.schema_migrations import migrate_payload
|
|
15
|
+
|
|
16
|
+
from .categories import category_for
|
|
17
|
+
from .delivery_limits import EMAIL_MAX_BATCH_SIZE
|
|
18
|
+
from .models import (
|
|
19
|
+
PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS,
|
|
20
|
+
PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS,
|
|
21
|
+
EmailSecurity,
|
|
22
|
+
EmailTransportMode,
|
|
23
|
+
Mention,
|
|
24
|
+
Notification,
|
|
25
|
+
NotificationPreference,
|
|
26
|
+
ProjectNotificationChannel,
|
|
27
|
+
ProjectNotificationEventType,
|
|
28
|
+
ProjectNotificationPreference,
|
|
29
|
+
UserNotificationSettings,
|
|
30
|
+
WorkspaceEmailSettings,
|
|
31
|
+
project_notification_channel_delivery,
|
|
32
|
+
project_notification_event_delivery,
|
|
33
|
+
)
|
|
34
|
+
from .schema_migrations import SURFACE_PROJECT_NOTIFICATION_MATRIX
|
|
35
|
+
from .services import (
|
|
36
|
+
QUIET_HOURS_TZ_SOURCE_FALLBACK,
|
|
37
|
+
QUIET_HOURS_TZ_SOURCE_PROJECT,
|
|
38
|
+
QUIET_HOURS_TZ_SOURCE_SERVER,
|
|
39
|
+
QUIET_HOURS_TZ_SOURCE_WORKSPACE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# DKIM selector: a single DNS label component set (letters, digits, dot, dash,
|
|
45
|
+
# underscore), capped to the model's 63-char column. Restricting this prevents
|
|
46
|
+
# DNS-name injection into the deliverability health lookup (security review M3).
|
|
47
|
+
_DKIM_SELECTOR_RE = re.compile(r"^[A-Za-z0-9._-]{1,63}$")
|
|
48
|
+
|
|
49
|
+
# Transports that require a stored/submitted credential. SMTP is the one
|
|
50
|
+
# exception with a carve-out: it is the only transport that may run with NO
|
|
51
|
+
# credential at all (see `_is_no_auth_smtp`, #4190) — an internal relay that
|
|
52
|
+
# accepts unauthenticated connections (Postfix `mynetworks`, an in-cluster
|
|
53
|
+
# relay), which the docs page has always named as a valid "Security: None"
|
|
54
|
+
# use case without any in-app way to configure it. SendGrid and SES are
|
|
55
|
+
# provider-issued credentials with no unauthenticated mode, so they keep
|
|
56
|
+
# requiring one unconditionally.
|
|
57
|
+
_CREDENTIAL_MODES = {
|
|
58
|
+
EmailTransportMode.SMTP,
|
|
59
|
+
EmailTransportMode.SENDGRID,
|
|
60
|
+
EmailTransportMode.SES,
|
|
61
|
+
}
|
|
62
|
+
# Transports where the admin supplies the relay host (SendGrid's host is fixed).
|
|
63
|
+
_HOST_REQUIRED_MODES = {EmailTransportMode.SMTP, EmailTransportMode.SES}
|
|
64
|
+
# Transports where the admin supplies the SMTP AUTH username. SendGrid is excluded
|
|
65
|
+
# because its username is server-fixed to the literal "apikey" (the key travels as
|
|
66
|
+
# the password), and CLOUD stores no credentials at all. SMTP's entry here is
|
|
67
|
+
# skipped when `_is_no_auth_smtp` holds (#4190).
|
|
68
|
+
_USERNAME_REQUIRED_MODES = {EmailTransportMode.SMTP, EmailTransportMode.SES}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _effective(attrs: dict[str, Any], instance: Any, field: str, default: Any = "") -> Any:
|
|
72
|
+
"""Value ``field`` will hold after this write.
|
|
73
|
+
|
|
74
|
+
A PATCH only carries the fields being changed, so validation has to reason
|
|
75
|
+
about the *merged* record: incoming value if the request set one, otherwise
|
|
76
|
+
whatever is already stored.
|
|
77
|
+
"""
|
|
78
|
+
if field in attrs:
|
|
79
|
+
return attrs[field]
|
|
80
|
+
return getattr(instance, field, default)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_no_auth_smtp(*, mode: Any, username: str, incoming_password: str) -> bool:
|
|
84
|
+
"""True when this save is an explicit, credential-free SMTP config (#4190).
|
|
85
|
+
|
|
86
|
+
Only SMTP may run unauthenticated — a relay on a trusted network that
|
|
87
|
+
accepts unauthenticated connections (Postfix ``mynetworks``, an in-cluster
|
|
88
|
+
relay). Both ``username`` AND ``incoming_password`` must be blank.
|
|
89
|
+
``incoming_password`` is deliberately the *submitted* value, not the
|
|
90
|
+
merged/effective one: password carries rotate-vs-keep secrecy (an
|
|
91
|
+
omitted/blank submission normally means "keep the stored secret"), so
|
|
92
|
+
reading the effective value here would read "I didn't touch the password
|
|
93
|
+
field" as "no auth" even when a credential is already stored under the
|
|
94
|
+
row — exactly the stale-secret survival this fix closes. ``username`` has
|
|
95
|
+
no such secrecy, so its merged/effective value is safe to use elsewhere in
|
|
96
|
+
this module; this function is the one place that must not.
|
|
97
|
+
"""
|
|
98
|
+
return mode == EmailTransportMode.SMTP and not username and not incoming_password
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _assert_transport_fields(
|
|
102
|
+
*,
|
|
103
|
+
mode: Any,
|
|
104
|
+
host: str,
|
|
105
|
+
username: str,
|
|
106
|
+
effective_pw: str,
|
|
107
|
+
needs_fresh_password: bool,
|
|
108
|
+
no_auth: bool,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Reject a transport config that is missing something the transport needs.
|
|
111
|
+
|
|
112
|
+
Runs before the live probe so a config that cannot possibly work is refused
|
|
113
|
+
without opening a connection. ``no_auth`` (#4190) short-circuits the
|
|
114
|
+
username/password checks for the one transport that may legitimately run
|
|
115
|
+
with neither: a Custom SMTP relay on a trusted network that accepts
|
|
116
|
+
unauthenticated connections. It is never true for SendGrid/SES, which keep
|
|
117
|
+
requiring a credential unconditionally.
|
|
118
|
+
"""
|
|
119
|
+
# Reusing a stale secret across a transport switch is a footgun — a
|
|
120
|
+
# SendGrid API key is not an SES password (security review M2). Not
|
|
121
|
+
# applicable when switching *into* a no-auth SMTP config: there is no
|
|
122
|
+
# password to re-enter because none is being kept (validate() forces
|
|
123
|
+
# effective_pw to "" and update() clears the stored ciphertext).
|
|
124
|
+
if needs_fresh_password and mode in _CREDENTIAL_MODES:
|
|
125
|
+
raise serializers.ValidationError(
|
|
126
|
+
{"password": "Re-enter the password when changing the transport."}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if mode in _HOST_REQUIRED_MODES and not host:
|
|
130
|
+
raise serializers.ValidationError({"host": "Host is required for this transport."})
|
|
131
|
+
|
|
132
|
+
if no_auth:
|
|
133
|
+
# An explicit, unambiguous choice: both username and password are
|
|
134
|
+
# blank. Django's SMTP backend already skips login() when both are
|
|
135
|
+
# empty, so there is nothing further to validate — the probe below
|
|
136
|
+
# opens a plain unauthenticated connection.
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# SMTP AUTH is a (username, password) pair, so a blank username paired
|
|
140
|
+
# with a non-blank password is not a weaker credential — it is a
|
|
141
|
+
# credential that can never be used (Django's SMTP backend calls login()
|
|
142
|
+
# only when both are truthy), so the probe would open an unauthenticated
|
|
143
|
+
# connection that succeeds and persist a config whose every real send then
|
|
144
|
+
# fails 530 Authentication Required. Requiring the pair here is what makes
|
|
145
|
+
# the validate-before-persist gate honest. (A blank username with a blank
|
|
146
|
+
# password is the `no_auth` branch above, not this one.)
|
|
147
|
+
if mode in _USERNAME_REQUIRED_MODES and not username:
|
|
148
|
+
hint = (
|
|
149
|
+
" Leave both Username and Password blank instead to use no "
|
|
150
|
+
"authentication (Custom SMTP only)."
|
|
151
|
+
if mode == EmailTransportMode.SMTP
|
|
152
|
+
else ""
|
|
153
|
+
)
|
|
154
|
+
raise serializers.ValidationError(
|
|
155
|
+
{
|
|
156
|
+
"username": (
|
|
157
|
+
"A username is required for this transport — the mail server "
|
|
158
|
+
"authenticates as this account. It is usually the full email "
|
|
159
|
+
"address that owns the password or app password." + hint
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if not effective_pw:
|
|
165
|
+
raise serializers.ValidationError(
|
|
166
|
+
{"password": "A password is required for this transport."}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _probe_or_raise(
|
|
171
|
+
*,
|
|
172
|
+
mode: Any,
|
|
173
|
+
host: str,
|
|
174
|
+
port: Any,
|
|
175
|
+
security: Any,
|
|
176
|
+
username: str,
|
|
177
|
+
password: str,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Validate-before-persist: open the candidate transport now.
|
|
180
|
+
|
|
181
|
+
Distinguishes an SSRF-egress-blocked host from every other connect failure
|
|
182
|
+
(#4189): the two used to share one generic "check the host, port, security,
|
|
183
|
+
and credentials" message, which sent an operator whose relay is simply on a
|
|
184
|
+
private network — and whose credentials are fine — looking in the wrong
|
|
185
|
+
place. ``EmailHostNotAllowedError``'s message is curated by
|
|
186
|
+
``email_backend._assert_host_public`` to never leak the DNS-resolved
|
|
187
|
+
address, so it is safe to surface to the client verbatim. Every other
|
|
188
|
+
failure still gets the generic message only — never the underlying
|
|
189
|
+
smtplib exception (M1).
|
|
190
|
+
"""
|
|
191
|
+
from .email_backend import EmailHostNotAllowedError, EmailTransportError, probe_transport
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
probe_transport(
|
|
195
|
+
transport_mode=str(mode),
|
|
196
|
+
host=host,
|
|
197
|
+
port=int(port),
|
|
198
|
+
security=str(security),
|
|
199
|
+
username=username,
|
|
200
|
+
password=password,
|
|
201
|
+
)
|
|
202
|
+
except EmailHostNotAllowedError as exc:
|
|
203
|
+
logger.info(
|
|
204
|
+
"email transport probe blocked by egress guard (host=%s port=%s): %s",
|
|
205
|
+
host,
|
|
206
|
+
port,
|
|
207
|
+
exc,
|
|
208
|
+
)
|
|
209
|
+
raise serializers.ValidationError({"non_field_errors": [str(exc)]}) from exc
|
|
210
|
+
except EmailTransportError as exc:
|
|
211
|
+
# Curated message only — never surface the exception object. The
|
|
212
|
+
# SSRF-guarded host path can carry the DNS-resolved internal address
|
|
213
|
+
# (scrubbed from the client at email_backend._assert_host_public, and
|
|
214
|
+
# caught above before reaching here); log the detail server-side
|
|
215
|
+
# (CodeQL py/stack-trace-exposure).
|
|
216
|
+
logger.info("email transport probe failed (host=%s port=%s): %s", host, port, exc)
|
|
217
|
+
raise serializers.ValidationError(
|
|
218
|
+
{
|
|
219
|
+
"non_field_errors": [
|
|
220
|
+
"Could not connect to the mail server with these settings. "
|
|
221
|
+
"Check the host, port, security, and credentials."
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
) from exc
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class MentionAuthorSerializer(serializers.Serializer[Any]):
|
|
228
|
+
"""Nested read-only user summary for Mention.mentioner / mentioned_user."""
|
|
229
|
+
|
|
230
|
+
id = serializers.UUIDField(read_only=True)
|
|
231
|
+
username = serializers.CharField(read_only=True)
|
|
232
|
+
display_name = serializers.SerializerMethodField()
|
|
233
|
+
|
|
234
|
+
def get_display_name(self, obj: Any) -> str:
|
|
235
|
+
name: str = obj.get_full_name() or obj.username
|
|
236
|
+
return name
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class MentionSerializer(serializers.ModelSerializer[Mention]):
|
|
240
|
+
"""Read-only serializer for Mention.
|
|
241
|
+
|
|
242
|
+
Mentions are append-only — no write path through this serializer. Created
|
|
243
|
+
indirectly via TaskCommentSerializer.create + services.create_mention_notifications.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
mentioner = MentionAuthorSerializer(read_only=True)
|
|
247
|
+
mentioned_user = MentionAuthorSerializer(read_only=True)
|
|
248
|
+
|
|
249
|
+
class Meta:
|
|
250
|
+
model = Mention
|
|
251
|
+
fields = [
|
|
252
|
+
"id",
|
|
253
|
+
"mentioner",
|
|
254
|
+
"mentioned_user",
|
|
255
|
+
"mentioned_group_key",
|
|
256
|
+
"scope",
|
|
257
|
+
"task_comment",
|
|
258
|
+
"created_at",
|
|
259
|
+
]
|
|
260
|
+
read_only_fields = fields
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
PROJECT_DELETED_EVENT = "project.deleted"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def recipient_member_project_sets(user: Any) -> tuple[set[Any], set[Any]]:
|
|
267
|
+
"""Return ``(live, including_trashed)`` project-id sets for ``user``'s memberships.
|
|
268
|
+
|
|
269
|
+
One query serves both: ``live`` floors out projects in Trash (#4577 — a trashed
|
|
270
|
+
project confers no role), ``including_trashed`` keeps them for the single event
|
|
271
|
+
(``project.deleted``) whose whole purpose is to tell the member about the trash.
|
|
272
|
+
Revoked memberships are excluded from both.
|
|
273
|
+
"""
|
|
274
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
275
|
+
|
|
276
|
+
live: set[Any] = set()
|
|
277
|
+
everything: set[Any] = set()
|
|
278
|
+
for project_id, trashed in ProjectMembership.objects.filter(
|
|
279
|
+
user=user, is_deleted=False
|
|
280
|
+
).values_list("project_id", "project__is_deleted"):
|
|
281
|
+
everything.add(project_id)
|
|
282
|
+
if not trashed:
|
|
283
|
+
live.add(project_id)
|
|
284
|
+
return live, everything
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class NotificationSerializer(serializers.ModelSerializer[Notification]):
|
|
288
|
+
"""Per-recipient inbox row.
|
|
289
|
+
|
|
290
|
+
Writes are limited to is_read / is_archived (PATCH-only on the viewset).
|
|
291
|
+
All other fields are read-only.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
mention = MentionSerializer(read_only=True, allow_null=True)
|
|
295
|
+
snippet = serializers.SerializerMethodField()
|
|
296
|
+
task_id = serializers.SerializerMethodField()
|
|
297
|
+
# Derived read-only category (ADR-0216 §3) — mentions | tasks | signals |
|
|
298
|
+
# project. Sourced from categories.category_for so the field and the
|
|
299
|
+
# ?category= filter share one mapping and never drift.
|
|
300
|
+
category = serializers.SerializerMethodField()
|
|
301
|
+
|
|
302
|
+
def get_category(self, obj: Notification) -> str:
|
|
303
|
+
return category_for(obj)
|
|
304
|
+
|
|
305
|
+
def get_snippet(self, obj: Notification) -> str:
|
|
306
|
+
"""Short preview of the source mention's body.
|
|
307
|
+
|
|
308
|
+
Truncated to 200 chars. Returns empty string if the source is
|
|
309
|
+
unavailable (e.g. comment soft-deleted).
|
|
310
|
+
|
|
311
|
+
Cross-project gate (#514): the snippet is a project member's-eyes view
|
|
312
|
+
of the source comment body, so it is returned ONLY to a recipient who
|
|
313
|
+
is a *current* member of the mention's source project. Program-scoped
|
|
314
|
+
auto-groups (`@program-*`) fan a mention out to members of sibling
|
|
315
|
+
projects who are NOT members of the source project — for them the row
|
|
316
|
+
still surfaces (they know they were pinged) but the body is redacted, so
|
|
317
|
+
a program ping never leaks one project's comment content to another
|
|
318
|
+
project's team. This also honors the standing rule that a departed
|
|
319
|
+
member no longer sees the body. Project membership is the read boundary
|
|
320
|
+
in OSS; the program is a coordination unit, not a shared-content unit.
|
|
321
|
+
"""
|
|
322
|
+
mention = obj.mention
|
|
323
|
+
if mention is None:
|
|
324
|
+
return ""
|
|
325
|
+
comment = mention.task_comment
|
|
326
|
+
if comment is None or comment.is_deleted:
|
|
327
|
+
return ""
|
|
328
|
+
if not self._recipient_can_see_project(obj):
|
|
329
|
+
return ""
|
|
330
|
+
body = comment.body or ""
|
|
331
|
+
return body[:200]
|
|
332
|
+
|
|
333
|
+
def _recipient_can_see_project(self, obj: Notification) -> bool:
|
|
334
|
+
"""True if the notification's recipient currently belongs to ``obj.project``.
|
|
335
|
+
|
|
336
|
+
Generalizes the mention-only #514 gate (formerly
|
|
337
|
+
``_recipient_can_see_source``, keyed on ``mention.project_id``) to every
|
|
338
|
+
notification, mention- or event-sourced (#3510): ``Notification.project_id``
|
|
339
|
+
is set at dispatch for both — the event-sourced fan-outs in ``services.py``
|
|
340
|
+
write it directly, and the mention fan-out (``_build_mention_notification``)
|
|
341
|
+
writes the same ``project_id`` the mention's source project resolved to — so
|
|
342
|
+
one project-membership check now backs the mention ``snippet``
|
|
343
|
+
redaction, the ``subject``/``body``/``project`` redaction in
|
|
344
|
+
:meth:`to_representation`, and — since #3674 — the ``task_id`` /
|
|
345
|
+
``mention.task_comment`` object-reference redaction alongside it. A
|
|
346
|
+
notification with no project at all (the
|
|
347
|
+
ADR-0663 account-scoped digest rows, which span the recipient's whole
|
|
348
|
+
membership set rather than one project) has no project boundary to check
|
|
349
|
+
against, so it is always visible.
|
|
350
|
+
|
|
351
|
+
The set of the recipient's member projects is resolved once per response
|
|
352
|
+
and memoized on the serializer context, so the inbox list stays O(1)
|
|
353
|
+
queries rather than one membership query per row. Falls open only when
|
|
354
|
+
there is no request/user context at all (e.g. a bare unit test
|
|
355
|
+
serializing without a request) — every real read path (the viewset)
|
|
356
|
+
supplies it.
|
|
357
|
+
"""
|
|
358
|
+
project_id = obj.project_id
|
|
359
|
+
if project_id is None:
|
|
360
|
+
return True
|
|
361
|
+
member_project_ids = self.context.get("member_project_ids")
|
|
362
|
+
if member_project_ids is None:
|
|
363
|
+
request = self.context.get("request")
|
|
364
|
+
user = getattr(request, "user", None) if request is not None else None
|
|
365
|
+
if user is None or not getattr(user, "is_authenticated", False):
|
|
366
|
+
return True # no user context to gate against — preserve behavior
|
|
367
|
+
member_project_ids, trashed_inclusive = recipient_member_project_sets(user)
|
|
368
|
+
# DRF's serializer context is a plain dict at runtime (typed Mapping
|
|
369
|
+
# in the stubs); memoize so the list path resolves membership once.
|
|
370
|
+
ctx = cast(dict[str, Any], self.context)
|
|
371
|
+
ctx["member_project_ids"] = member_project_ids
|
|
372
|
+
ctx["member_project_ids_incl_trashed"] = trashed_inclusive
|
|
373
|
+
if obj.event_type == PROJECT_DELETED_EVENT:
|
|
374
|
+
# The "project was deleted" notice is written *because* the project is
|
|
375
|
+
# now in Trash, so the trashed-project floor would blank the one message
|
|
376
|
+
# that tells the member where to restore it. The revoked-membership
|
|
377
|
+
# gate still applies to it.
|
|
378
|
+
return project_id in self.context.get(
|
|
379
|
+
"member_project_ids_incl_trashed", member_project_ids
|
|
380
|
+
)
|
|
381
|
+
return project_id in member_project_ids
|
|
382
|
+
|
|
383
|
+
def to_representation(self, instance: Notification) -> dict[str, Any]:
|
|
384
|
+
"""Redact ``subject``/``body``/``project`` (#3510) + the source object refs (#3674).
|
|
385
|
+
|
|
386
|
+
``subject`` and ``body`` are free text rendered from the project and task
|
|
387
|
+
names at dispatch time (see the ``Notification(...)`` fan-outs in
|
|
388
|
+
``services.py``), and ``project`` is the FK to the row's source project.
|
|
389
|
+
All three are frozen at dispatch and, before this change, were served
|
|
390
|
+
unredacted forever after — including to a user removed from the project,
|
|
391
|
+
who kept an inbox that went on naming that project's tasks indefinitely.
|
|
392
|
+
``snippet`` already redacted the same class of content one field over
|
|
393
|
+
(#514); this extends that same treatment to the rest of the row instead
|
|
394
|
+
of re-flooring the whole read against current membership.
|
|
395
|
+
|
|
396
|
+
Retention position (#3510): a delivered notification is a record of a
|
|
397
|
+
message that was sent, not a live view of the project — so the row is
|
|
398
|
+
kept and its content is redacted, matching ``snippet``'s existing
|
|
399
|
+
behavior. Redacted values reuse shapes the client already handles:
|
|
400
|
+
``subject``/``body`` blank to ``""`` (their normal non-null default), and
|
|
401
|
+
``project`` becomes ``None`` — already the shape of an ADR-0663
|
|
402
|
+
account-scoped digest row with no single owning project. No schema
|
|
403
|
+
change, no new client-side branch.
|
|
404
|
+
|
|
405
|
+
Object references (#3674). ``mention.task_comment`` — a raw comment UUID
|
|
406
|
+
— is redacted to ``None`` on the same gate, as is ``task_id`` (in
|
|
407
|
+
:meth:`get_task_id`). The rest of the nested ``mention`` object is
|
|
408
|
+
deliberately KEPT. The dividing line is not which population is reading
|
|
409
|
+
(see below) but what the field *is*:
|
|
410
|
+
|
|
411
|
+
* **Redacted — opaque handles into the source project's content graph.**
|
|
412
|
+
``task_id`` and ``mention.task_comment`` are UUIDs. They say nothing to
|
|
413
|
+
a human and exist only to be handed back to another endpoint, which is
|
|
414
|
+
precisely the residual-IDOR surface #3674 was filed on. Nobody who
|
|
415
|
+
fails this gate can spend one: ``TaskViewSet`` and the comment routes
|
|
416
|
+
floor on current membership. So redacting them removes an object
|
|
417
|
+
reference and zero capability.
|
|
418
|
+
* **Kept — the "you were pinged" signal, which is not a project-content
|
|
419
|
+
reference.** ``mentioner`` is the author's username/display name;
|
|
420
|
+
``mentioned_user`` is null (group mention) or the recipient themselves
|
|
421
|
+
(``_resolve_mention_recipients`` keys a direct mention by its own
|
|
422
|
+
target); ``mentioned_group_key`` is a group the recipient belongs to —
|
|
423
|
+
that is *how* they were reached; ``scope`` is a 3-value enum. None of
|
|
424
|
+
these name a task, a project, or a comment. ``mention.id`` is kept with
|
|
425
|
+
them: it is a handle, but to an object no endpoint serves, so keeping it
|
|
426
|
+
grants nothing.
|
|
427
|
+
|
|
428
|
+
Why this does NOT need to distinguish the two populations that reach this
|
|
429
|
+
branch. ``_recipient_can_see_project`` is one predicate over two groups:
|
|
430
|
+
a member whose access was revoked, and a sibling-project member pinged
|
|
431
|
+
through a ``@program-*`` auto-group who was never a member of the source
|
|
432
|
+
project and was never intentionally denied (the #514 population). They
|
|
433
|
+
differ in intent, but they are identical in capability — both are gated
|
|
434
|
+
out of every endpoint a task or comment UUID could be spent at — and the
|
|
435
|
+
fields where their intents *would* diverge are exactly the ones kept
|
|
436
|
+
above. So the honest #514 promise, "the row still surfaces, they know
|
|
437
|
+
they were pinged", survives intact: a redacted mention row still renders
|
|
438
|
+
"Authoria mentioned @program-pms". What it loses is a deep link that was
|
|
439
|
+
already dead for that reader (the client short-circuits on
|
|
440
|
+
``project === null`` before it ever reads ``task_id``). Widening the
|
|
441
|
+
redaction to ``mentioner``/``mentioned_group_key`` WOULD reverse #514 —
|
|
442
|
+
it would leave a row that says only "something happened" — and would
|
|
443
|
+
need a discriminator this row does not carry; that is not done here and
|
|
444
|
+
is not needed for the leak #3674 names.
|
|
445
|
+
"""
|
|
446
|
+
data = super().to_representation(instance)
|
|
447
|
+
if not self._recipient_can_see_project(instance):
|
|
448
|
+
data["subject"] = ""
|
|
449
|
+
data["body"] = ""
|
|
450
|
+
data["project"] = None
|
|
451
|
+
mention = data.get("mention")
|
|
452
|
+
if mention is not None:
|
|
453
|
+
# Reuses a shape the client already handles: a mention row whose
|
|
454
|
+
# source comment was soft-deleted serializes with a null FK and a
|
|
455
|
+
# blank snippet, which is exactly what a redacted row now looks
|
|
456
|
+
# like. No schema change, no new client-side branch.
|
|
457
|
+
mention["task_comment"] = None
|
|
458
|
+
return data
|
|
459
|
+
|
|
460
|
+
def get_task_id(self, obj: Notification) -> str | None:
|
|
461
|
+
# Deliberately comment-only, not a docstring: drf-spectacular publishes a
|
|
462
|
+
# SerializerMethodField's docstring as the field `description` in
|
|
463
|
+
# docs/api/openapi.json, which would put this internal reasoning (and
|
|
464
|
+
# issue numbers) in the public contract.
|
|
465
|
+
#
|
|
466
|
+
# Redacted on the same membership predicate as `snippet` (#514) and
|
|
467
|
+
# `subject`/`body`/`project` (#3510) — see `to_representation` for why a
|
|
468
|
+
# raw task UUID goes while the mention's identity fields stay. Checked
|
|
469
|
+
# before the FK is read so a redacted row costs no attribute walk.
|
|
470
|
+
if not self._recipient_can_see_project(obj):
|
|
471
|
+
return None
|
|
472
|
+
# Event-sourced rows (#497/#861) carry a direct deep-link FK; mention
|
|
473
|
+
# rows resolve their task through the source comment. Prefer the explicit
|
|
474
|
+
# FK so the inbox row links to the affected task/milestone either way.
|
|
475
|
+
if obj.task_id is not None:
|
|
476
|
+
return str(obj.task_id)
|
|
477
|
+
mention = obj.mention
|
|
478
|
+
if mention is None or mention.task_comment is None:
|
|
479
|
+
return None
|
|
480
|
+
return str(mention.task_comment.task_id)
|
|
481
|
+
|
|
482
|
+
class Meta:
|
|
483
|
+
model = Notification
|
|
484
|
+
# event_type/subject/body are the event-sourced inbox payload (#639,
|
|
485
|
+
# #497, #861). They were inbox-invisible before — a mention row leaves
|
|
486
|
+
# them blank and renders from `snippet`; an event row has no mention, so
|
|
487
|
+
# without these fields the client could not show its title/preview and
|
|
488
|
+
# fell back to "mentioned you / comment unavailable".
|
|
489
|
+
fields = [
|
|
490
|
+
"id",
|
|
491
|
+
"recipient",
|
|
492
|
+
"mention",
|
|
493
|
+
"event_type",
|
|
494
|
+
"subject",
|
|
495
|
+
"body",
|
|
496
|
+
"project",
|
|
497
|
+
"is_read",
|
|
498
|
+
"is_archived",
|
|
499
|
+
"snoozed_until",
|
|
500
|
+
"category",
|
|
501
|
+
"created_at",
|
|
502
|
+
"read_at",
|
|
503
|
+
"snippet",
|
|
504
|
+
"task_id",
|
|
505
|
+
]
|
|
506
|
+
read_only_fields = [
|
|
507
|
+
"id",
|
|
508
|
+
"recipient",
|
|
509
|
+
"mention",
|
|
510
|
+
"event_type",
|
|
511
|
+
"subject",
|
|
512
|
+
"body",
|
|
513
|
+
"project",
|
|
514
|
+
# snoozed_until is written by the dedicated snooze action, never PATCH.
|
|
515
|
+
"snoozed_until",
|
|
516
|
+
"category",
|
|
517
|
+
"created_at",
|
|
518
|
+
"read_at",
|
|
519
|
+
"snippet",
|
|
520
|
+
"task_id",
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class NotificationPreferenceSerializer(serializers.ModelSerializer[NotificationPreference]):
|
|
525
|
+
"""Per-user (event_type, channel) toggle.
|
|
526
|
+
|
|
527
|
+
PATCH on the viewset only modifies `enabled` — event_type / channel /
|
|
528
|
+
user are immutable.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
class Meta:
|
|
532
|
+
model = NotificationPreference
|
|
533
|
+
fields = ["id", "event_type", "channel", "enabled", "updated_at"]
|
|
534
|
+
read_only_fields = ["id", "event_type", "channel", "updated_at"]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ---------------------------------------------------------------------------
|
|
538
|
+
# Project-scoped preferences (#522)
|
|
539
|
+
# ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
_VALID_EVENT_TYPES = {choice.value for choice in ProjectNotificationEventType}
|
|
543
|
+
_VALID_CHANNELS = {choice.value for choice in ProjectNotificationChannel}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
#: The published shape of the notification matrix (#3364).
|
|
547
|
+
#:
|
|
548
|
+
#: A bare ``JSONField`` carries no schema hint, so drf-spectacular publishes it as
|
|
549
|
+
#: ``{}`` — "any JSON at all". That is not a small imprecision on this field: it is
|
|
550
|
+
#: the only thing ``PATCH .../notification-preferences/`` exists to write, and the
|
|
551
|
+
#: field below rejects every key outside the two enumerations with a 400. Declaring
|
|
552
|
+
#: the body while leaving its one field untyped would tell an integrator "send some
|
|
553
|
+
#: JSON" and 400 on every guess — the same gap #3364 closes one level up.
|
|
554
|
+
#:
|
|
555
|
+
#: Derived from the enums rather than restated, so a new event type or channel
|
|
556
|
+
#: reaches the published contract without a second edit that can be forgotten.
|
|
557
|
+
#:
|
|
558
|
+
#: ``propertyNames`` is JSON Schema, not OpenAPI 3.0 Schema Object — this project
|
|
559
|
+
#: publishes ``openapi: 3.0.3`` (see ``docs/api/openapi.json``), whose meta-schema
|
|
560
|
+
#: has no such keyword and rejects the whole object under ``additionalProperties``'
|
|
561
|
+
#: ``oneOf`` the moment it appears anywhere in the tree. The two enumerations are
|
|
562
|
+
#: small and closed, so each key is spelled out explicitly under ``properties``
|
|
563
|
+
#: instead, with ``additionalProperties: False`` doing the same "no unknown key"
|
|
564
|
+
#: rejection ``propertyNames`` would have — this is what "9×4 grid" means literally.
|
|
565
|
+
_PROJECT_NOTIFICATION_MATRIX_SCHEMA: dict[str, Any] = {
|
|
566
|
+
"type": "object",
|
|
567
|
+
"description": (
|
|
568
|
+
"Per-event, per-channel routing grid. Keys are event types; each value is "
|
|
569
|
+
"an object of channel to boolean. A PATCH may send a partial grid — the "
|
|
570
|
+
"supplied cells are merged onto the stored ones, so a single toggle need "
|
|
571
|
+
"not repost the whole matrix. Any key outside these two enumerations, or "
|
|
572
|
+
"any non-boolean leaf, is rejected with a 400."
|
|
573
|
+
),
|
|
574
|
+
"properties": {
|
|
575
|
+
event_type: {
|
|
576
|
+
"type": "object",
|
|
577
|
+
"properties": {channel: {"type": "boolean"} for channel in sorted(_VALID_CHANNELS)},
|
|
578
|
+
"additionalProperties": False,
|
|
579
|
+
}
|
|
580
|
+
for event_type in sorted(_VALID_EVENT_TYPES)
|
|
581
|
+
},
|
|
582
|
+
"additionalProperties": False,
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@extend_schema_field(_PROJECT_NOTIFICATION_MATRIX_SCHEMA)
|
|
587
|
+
class _ProjectNotificationMatrixField(serializers.JSONField):
|
|
588
|
+
"""JSON matrix `{event_type: {channel: bool}}` with strict key validation.
|
|
589
|
+
|
|
590
|
+
Rejects unknown event types and channels at the serializer layer so a
|
|
591
|
+
typo in the client doesn't silently corrupt the stored matrix. Non-bool
|
|
592
|
+
leaves are also rejected — there is no "tri-state" toggle.
|
|
593
|
+
"""
|
|
594
|
+
|
|
595
|
+
def to_internal_value(self, data: Any) -> dict[str, dict[str, bool]]:
|
|
596
|
+
value = super().to_internal_value(data)
|
|
597
|
+
if not isinstance(value, dict):
|
|
598
|
+
raise serializers.ValidationError("matrix must be a JSON object")
|
|
599
|
+
validated: dict[str, dict[str, bool]] = {}
|
|
600
|
+
for event_type, channels in value.items():
|
|
601
|
+
if event_type not in _VALID_EVENT_TYPES:
|
|
602
|
+
raise serializers.ValidationError(f"Unknown event_type: {event_type!r}")
|
|
603
|
+
if not isinstance(channels, dict):
|
|
604
|
+
raise serializers.ValidationError(
|
|
605
|
+
f"matrix[{event_type!r}] must be an object of channel→bool"
|
|
606
|
+
)
|
|
607
|
+
row: dict[str, bool] = {}
|
|
608
|
+
for channel, enabled in channels.items():
|
|
609
|
+
if channel not in _VALID_CHANNELS:
|
|
610
|
+
raise serializers.ValidationError(f"Unknown channel: {channel!r}")
|
|
611
|
+
if not isinstance(enabled, bool):
|
|
612
|
+
raise serializers.ValidationError(
|
|
613
|
+
f"matrix[{event_type!r}][{channel!r}] must be a boolean"
|
|
614
|
+
)
|
|
615
|
+
row[channel] = enabled
|
|
616
|
+
validated[event_type] = row
|
|
617
|
+
return validated
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class ProjectNotificationPreferenceSerializer(
|
|
621
|
+
serializers.ModelSerializer[ProjectNotificationPreference]
|
|
622
|
+
):
|
|
623
|
+
"""Per-(project, user) notification preferences.
|
|
624
|
+
|
|
625
|
+
The full matrix and quiet-hours window are returned in one document; the
|
|
626
|
+
PATCH path merges the supplied matrix onto the stored one so a partial
|
|
627
|
+
toggle does not have to repost the full 9×4 grid.
|
|
628
|
+
|
|
629
|
+
On read, matrix is run through the forward-migration registry
|
|
630
|
+
(``schema_migrations.migrate_payload``, ADR-0086 / ADR-0204, #1916): a
|
|
631
|
+
stale stored payload is upgraded to the current shape before any client
|
|
632
|
+
sees it, mirroring ``BoardSavedViewSerializer``. The view layer's
|
|
633
|
+
``_merge_matrix`` overlay still runs afterward (it also drops unknown
|
|
634
|
+
garbage keys, #675 — a distinct concern from the version upgrade), so this
|
|
635
|
+
is defense-in-depth for any caller that reads this serializer directly
|
|
636
|
+
rather than through ``ProjectNotificationPreferenceView``.
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
matrix = _ProjectNotificationMatrixField()
|
|
640
|
+
# The window is stored as a bare wall-clock range, so without these two the
|
|
641
|
+
# governed user cannot tell what "22:00" means (#3377). Workspace.timezone is
|
|
642
|
+
# itself member-readable (IsWorkspaceAdmin admits any role on safe methods), so
|
|
643
|
+
# this is not a disclosure question — it is that resolving the window client-side
|
|
644
|
+
# means re-implementing a four-tier chain across three models and keeping it in
|
|
645
|
+
# sync, and the winning *tier* is not derivable from the values at all. If
|
|
646
|
+
# GET /workspace/ is ever narrowed to IsWorkspaceAdminStrict (#1724), revisit
|
|
647
|
+
# whether these two should follow it; today they expose nothing new.
|
|
648
|
+
quiet_hours_timezone = serializers.SerializerMethodField()
|
|
649
|
+
quiet_hours_timezone_source = serializers.SerializerMethodField()
|
|
650
|
+
|
|
651
|
+
class Meta:
|
|
652
|
+
model = ProjectNotificationPreference
|
|
653
|
+
fields = [
|
|
654
|
+
"matrix",
|
|
655
|
+
"schema_version",
|
|
656
|
+
"paused",
|
|
657
|
+
"quiet_hours_enabled",
|
|
658
|
+
"quiet_hours_from",
|
|
659
|
+
"quiet_hours_until",
|
|
660
|
+
"quiet_hours_timezone",
|
|
661
|
+
"quiet_hours_timezone_source",
|
|
662
|
+
"updated_at",
|
|
663
|
+
]
|
|
664
|
+
# quiet_hours_timezone* are SerializerMethodFields — inherently read-only, and
|
|
665
|
+
# DRF forbids naming a declared field here.
|
|
666
|
+
read_only_fields = ["schema_version", "updated_at"]
|
|
667
|
+
|
|
668
|
+
def _resolved_quiet_hours_tz(self, instance: ProjectNotificationPreference) -> tuple[str, str]:
|
|
669
|
+
"""``(IANA name, tier)`` for this row's window, memoized per project.
|
|
670
|
+
|
|
671
|
+
Memoized because both method fields call it for the same row; keyed on the
|
|
672
|
+
project so a serializer instance reused across projects cannot report one
|
|
673
|
+
project's zone for another.
|
|
674
|
+
|
|
675
|
+
``instance.project`` costs no query because the view primes the FK cache —
|
|
676
|
+
``get_or_create`` populates ``fields_cache`` on its create branch but not on
|
|
677
|
+
the get branch, which is the common one.
|
|
678
|
+
|
|
679
|
+
The workspace tier is left to the resolver rather than pre-loaded into
|
|
680
|
+
``context``. Pre-loading it would be wrong twice over on this endpoint: it
|
|
681
|
+
queries even when the project sets its own timezone and the chain never
|
|
682
|
+
reaches the workspace, and — because the natural pre-load is
|
|
683
|
+
``Workspace.load()``, a get_or_create — a *read* of this endpoint on an
|
|
684
|
+
install with no singleton would create the row and then report
|
|
685
|
+
``source="workspace"``, where the dispatcher (which never writes) would have
|
|
686
|
+
reported ``"server"``. The whole point of these fields is to say what dispatch
|
|
687
|
+
does. ``context["workspace"]`` is still honored for a caller that has one in
|
|
688
|
+
hand, matching ``projects.bulk_settings``.
|
|
689
|
+
"""
|
|
690
|
+
from trueppm_api.apps.notifications.services import resolve_quiet_hours_timezone
|
|
691
|
+
|
|
692
|
+
cache: dict[Any, tuple[str, str]] = getattr(self, "_quiet_hours_tz_cache", {})
|
|
693
|
+
key = instance.project_id
|
|
694
|
+
if key not in cache:
|
|
695
|
+
zone, source = resolve_quiet_hours_timezone(
|
|
696
|
+
instance.project, workspace=self.context.get("workspace")
|
|
697
|
+
)
|
|
698
|
+
cache[key] = (zone.key, source)
|
|
699
|
+
self._quiet_hours_tz_cache = cache
|
|
700
|
+
return cache[key]
|
|
701
|
+
|
|
702
|
+
@extend_schema_field(serializers.CharField())
|
|
703
|
+
def get_quiet_hours_timezone(self, instance: ProjectNotificationPreference) -> str:
|
|
704
|
+
"""IANA zone the stored ``quiet_hours_from``/``_until`` are interpreted in."""
|
|
705
|
+
return self._resolved_quiet_hours_tz(instance)[0]
|
|
706
|
+
|
|
707
|
+
@extend_schema_field(
|
|
708
|
+
serializers.ChoiceField(
|
|
709
|
+
choices=[
|
|
710
|
+
QUIET_HOURS_TZ_SOURCE_PROJECT,
|
|
711
|
+
QUIET_HOURS_TZ_SOURCE_WORKSPACE,
|
|
712
|
+
QUIET_HOURS_TZ_SOURCE_SERVER,
|
|
713
|
+
QUIET_HOURS_TZ_SOURCE_FALLBACK,
|
|
714
|
+
]
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
def get_quiet_hours_timezone_source(self, instance: ProjectNotificationPreference) -> str:
|
|
718
|
+
"""Which tier supplied it: ``project``, ``workspace``, ``server`` or ``fallback``.
|
|
719
|
+
|
|
720
|
+
Provenance, not decoration — ``project`` vs ``workspace`` is the difference
|
|
721
|
+
between "my project lead set this" and "the workspace admin did", and
|
|
722
|
+
``fallback`` says the chain found nothing usable and landed on UTC.
|
|
723
|
+
"""
|
|
724
|
+
return self._resolved_quiet_hours_tz(instance)[1]
|
|
725
|
+
|
|
726
|
+
def to_representation(self, instance: ProjectNotificationPreference) -> dict[str, Any]:
|
|
727
|
+
"""Upgrade the stored matrix to the current shape on read.
|
|
728
|
+
|
|
729
|
+
The stored payload's version is the model's ``schema_version`` column
|
|
730
|
+
(rows predating the convention carry the migration default of 1 and
|
|
731
|
+
are already at the current 9-event shape, so the chain is a no-op for
|
|
732
|
+
them). Passing it explicitly keeps the column as the single source of
|
|
733
|
+
truth for "which shape is this" rather than sniffing the payload.
|
|
734
|
+
"""
|
|
735
|
+
data = super().to_representation(instance)
|
|
736
|
+
matrix = data.get("matrix")
|
|
737
|
+
if isinstance(matrix, dict):
|
|
738
|
+
upgraded, version = migrate_payload(
|
|
739
|
+
SURFACE_PROJECT_NOTIFICATION_MATRIX,
|
|
740
|
+
matrix,
|
|
741
|
+
from_version=instance.schema_version,
|
|
742
|
+
)
|
|
743
|
+
# schema_version is exposed as a sibling metadata field, not mixed
|
|
744
|
+
# into the matrix event/channel grid — keep matrix the clean grid.
|
|
745
|
+
upgraded.pop("schema_version", None)
|
|
746
|
+
data["matrix"] = upgraded
|
|
747
|
+
data["schema_version"] = version
|
|
748
|
+
return data
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
#: The published shape of ``event_delivery`` (#3396, #3399).
|
|
752
|
+
#:
|
|
753
|
+
#: Enumerated key-by-key rather than left as ``additionalProperties: {type: boolean}``
|
|
754
|
+
#: because the key set is closed and derivable: it is exactly
|
|
755
|
+
#: :data:`PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS`, the same tuple
|
|
756
|
+
#: :func:`project_notification_event_delivery` iterates. Deriving it is the pin
|
|
757
|
+
#: #3399 asked for — the declaration cannot report a different set of events than
|
|
758
|
+
#: the classification does, because there is only one list. An open
|
|
759
|
+
#: ``additionalProperties`` map would have typed the *values* and said nothing about
|
|
760
|
+
#: which rows a client can expect, which is the half a client actually indexes into.
|
|
761
|
+
#:
|
|
762
|
+
#: The booleans themselves are deliberately NOT pinned to today's classification.
|
|
763
|
+
#: Which rows are wired is runtime state that moves as dispatchers land
|
|
764
|
+
#: (TODO(#3016)); baking ``comment_mention: true`` into the schema would turn the
|
|
765
|
+
#: contract into a lie on the day the second dispatcher ships.
|
|
766
|
+
#:
|
|
767
|
+
#: ``propertyNames`` is JSON Schema and not OpenAPI 3.0 — see
|
|
768
|
+
#: :data:`_PROJECT_NOTIFICATION_MATRIX_SCHEMA` for why this project cannot use it.
|
|
769
|
+
_PROJECT_NOTIFICATION_EVENT_DELIVERY_SCHEMA: dict[str, Any] = {
|
|
770
|
+
"type": "object",
|
|
771
|
+
"description": (
|
|
772
|
+
"Which matrix rows are wired to a delivery path. `true` means toggling that "
|
|
773
|
+
"event's channels changes what gets delivered; `false` means the row is "
|
|
774
|
+
"rendered and stored but nothing dispatches it yet, so a client should label "
|
|
775
|
+
"it rather than imply a delivery that never happens. Reported as a server "
|
|
776
|
+
"fact so every client does not hard-code the same list and drift from it."
|
|
777
|
+
),
|
|
778
|
+
"properties": {
|
|
779
|
+
event_type: {"type": "boolean"}
|
|
780
|
+
for event_type in PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS
|
|
781
|
+
},
|
|
782
|
+
"required": list(PROJECT_NOTIFICATION_DELIVERY_REPORTED_EVENTS),
|
|
783
|
+
"additionalProperties": False,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
#: The published shape of ``channel_delivery`` (#3378).
|
|
788
|
+
#:
|
|
789
|
+
#: Same construction and the same reasoning as
|
|
790
|
+
#: :data:`_PROJECT_NOTIFICATION_EVENT_DELIVERY_SCHEMA` one axis over: the key set is
|
|
791
|
+
#: closed and derivable — it is exactly
|
|
792
|
+
#: :data:`PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS`, the tuple
|
|
793
|
+
#: :func:`project_notification_channel_delivery` iterates — so the declaration
|
|
794
|
+
#: cannot report a different set of channels than the matrix renders.
|
|
795
|
+
#:
|
|
796
|
+
#: The booleans are again NOT pinned to today's answer. Which channels deliver is
|
|
797
|
+
#: runtime state that moves when an ADR-0049 ``NOTIFICATION_CHANNELS`` registration
|
|
798
|
+
#: lands (TODO(#3252)); baking ``slack: false`` into the schema would turn the
|
|
799
|
+
#: contract into a lie on the day one does.
|
|
800
|
+
_PROJECT_NOTIFICATION_CHANNEL_DELIVERY_SCHEMA: dict[str, Any] = {
|
|
801
|
+
"type": "object",
|
|
802
|
+
"description": (
|
|
803
|
+
"Which matrix columns TruePPM has a delivery path for. `true` means an "
|
|
804
|
+
"enabled cell in that column can reach you once its event is also "
|
|
805
|
+
"dispatched; `false` means the "
|
|
806
|
+
"column is rendered and the preference stored, but nothing delivers on it "
|
|
807
|
+
"yet and no setting anywhere turns it on, so a client should label it "
|
|
808
|
+
"rather than imply a delivery that never happens. Orthogonal to "
|
|
809
|
+
"`event_delivery`: a cell is live only when its event is dispatched AND "
|
|
810
|
+
"its channel delivers. Server-global — it does not vary by user or project, "
|
|
811
|
+
"and rides on this per-user document only so a client can label the whole "
|
|
812
|
+
"grid in one render. Reported as a server fact so every client does not "
|
|
813
|
+
"hard-code the same list and drift from it."
|
|
814
|
+
),
|
|
815
|
+
"properties": {
|
|
816
|
+
channel: {"type": "boolean"} for channel in PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS
|
|
817
|
+
},
|
|
818
|
+
"required": list(PROJECT_NOTIFICATION_DELIVERY_REPORTED_CHANNELS),
|
|
819
|
+
"additionalProperties": False,
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class ProjectNotificationPreferenceDocumentSerializer(ProjectNotificationPreferenceSerializer):
|
|
824
|
+
"""What ``GET``/``PATCH .../notification-preferences/`` actually returns (#3396, #3399).
|
|
825
|
+
|
|
826
|
+
The stored row's fields **plus** ``event_delivery`` and ``channel_delivery``,
|
|
827
|
+
which the view adds on both methods and which are not model fields. Declaring
|
|
828
|
+
the plain serializer as the ``200`` would have been the obvious fix and a worse
|
|
829
|
+
one: the schema would be self-consistent, ``api:schema-drift`` would pass, and a
|
|
830
|
+
generated SDK would silently drop the two keys a client needs to label the rows
|
|
831
|
+
nothing dispatches and the columns nothing delivers on. A declared lie is more
|
|
832
|
+
dangerous than a declared absence.
|
|
833
|
+
|
|
834
|
+
Subclasses rather than restating the field list, so a field added to the parent
|
|
835
|
+
reaches the published response without a second edit. Only the two delivery maps
|
|
836
|
+
are named here, because only they are the difference.
|
|
837
|
+
"""
|
|
838
|
+
|
|
839
|
+
event_delivery = serializers.SerializerMethodField()
|
|
840
|
+
channel_delivery = serializers.SerializerMethodField()
|
|
841
|
+
|
|
842
|
+
class Meta(ProjectNotificationPreferenceSerializer.Meta):
|
|
843
|
+
fields = [
|
|
844
|
+
*ProjectNotificationPreferenceSerializer.Meta.fields,
|
|
845
|
+
"event_delivery",
|
|
846
|
+
"channel_delivery",
|
|
847
|
+
]
|
|
848
|
+
|
|
849
|
+
@extend_schema_field(_PROJECT_NOTIFICATION_EVENT_DELIVERY_SCHEMA)
|
|
850
|
+
def get_event_delivery(self, instance: ProjectNotificationPreference) -> dict[str, bool]:
|
|
851
|
+
"""Row-independent: it reports server wiring, not this user's preferences."""
|
|
852
|
+
return project_notification_event_delivery()
|
|
853
|
+
|
|
854
|
+
@extend_schema_field(_PROJECT_NOTIFICATION_CHANNEL_DELIVERY_SCHEMA)
|
|
855
|
+
def get_channel_delivery(self, instance: ProjectNotificationPreference) -> dict[str, bool]:
|
|
856
|
+
"""Column-independent, and row-independent: it reports which channels the
|
|
857
|
+
server delivers on at all, not anything about this user or project."""
|
|
858
|
+
return project_notification_channel_delivery()
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
class UserNotificationSettingsSerializer(serializers.ModelSerializer[UserNotificationSettings]):
|
|
862
|
+
"""Account-wide notification settings (#1707, ADR-0292; digest slot ADR-0663).
|
|
863
|
+
|
|
864
|
+
``dnd_enabled`` plus the weekly digest slot (``digest_weekday`` /
|
|
865
|
+
``digest_hour`` / ``digest_timezone``) are writable; ``updated_at`` is
|
|
866
|
+
read-only. The endpoint is self-scoped (the row is always the caller's own), so
|
|
867
|
+
there is no ``user`` field to expose or spoof.
|
|
868
|
+
|
|
869
|
+
The slot fields are range- and tz-validated rather than clamped: a bad value
|
|
870
|
+
must surface as a 400 the user can act on. Silently coercing hour 25 to 23
|
|
871
|
+
would leave someone waiting for a digest that arrives at the wrong time with no
|
|
872
|
+
indication anything was wrong.
|
|
873
|
+
"""
|
|
874
|
+
|
|
875
|
+
class Meta:
|
|
876
|
+
model = UserNotificationSettings
|
|
877
|
+
fields = [
|
|
878
|
+
"dnd_enabled",
|
|
879
|
+
"digest_weekday",
|
|
880
|
+
"digest_hour",
|
|
881
|
+
"digest_timezone",
|
|
882
|
+
"updated_at",
|
|
883
|
+
]
|
|
884
|
+
read_only_fields = ["updated_at"]
|
|
885
|
+
|
|
886
|
+
def validate_digest_weekday(self, value: int) -> int:
|
|
887
|
+
"""0=Monday .. 6=Sunday, matching Python's ``date.weekday()``."""
|
|
888
|
+
if not 0 <= value <= 6:
|
|
889
|
+
raise serializers.ValidationError("Must be between 0 (Monday) and 6 (Sunday).")
|
|
890
|
+
return value
|
|
891
|
+
|
|
892
|
+
def validate_digest_hour(self, value: int) -> int:
|
|
893
|
+
"""Local hour of day, 0-23."""
|
|
894
|
+
if not 0 <= value <= 23:
|
|
895
|
+
raise serializers.ValidationError("Must be between 0 and 23.")
|
|
896
|
+
return value
|
|
897
|
+
|
|
898
|
+
def validate_digest_timezone(self, value: str) -> str:
|
|
899
|
+
"""An IANA key, or blank to use the server timezone.
|
|
900
|
+
|
|
901
|
+
Validated against the running interpreter's tz database so a typo is a 400
|
|
902
|
+
at save time rather than a digest that silently never fires.
|
|
903
|
+
"""
|
|
904
|
+
if value and value not in zoneinfo.available_timezones():
|
|
905
|
+
raise serializers.ValidationError(f"Unknown timezone: {value!r}")
|
|
906
|
+
return value
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
class WorkspaceEmailSettingsSerializer(serializers.ModelSerializer[WorkspaceEmailSettings]):
|
|
910
|
+
"""Read/write serializer for the workspace SMTP singleton (#712, ADR-0213).
|
|
911
|
+
|
|
912
|
+
The password is **write-only** and never echoed — the response carries only
|
|
913
|
+
``password_is_set``. On update, an omitted/blank ``password`` keeps the
|
|
914
|
+
stored secret (rotate-vs-keep); a non-empty value replaces it. The
|
|
915
|
+
validate-before-persist probe opens the candidate transport before ``save``
|
|
916
|
+
so a bad configuration cannot lock the workspace out of mail. All admin-
|
|
917
|
+
supplied network/header inputs are validated against SSRF and header
|
|
918
|
+
injection (security review H1/M2/M3).
|
|
919
|
+
|
|
920
|
+
Custom SMTP may be saved with **no authentication** — both ``username`` and
|
|
921
|
+
``password`` blank (#4190) — for a relay on a trusted network that accepts
|
|
922
|
+
unauthenticated connections (Postfix ``mynetworks``, an in-cluster relay).
|
|
923
|
+
This is the in-app counterpart to the ``EMAIL_*`` env fallback, which has
|
|
924
|
+
always supported it. SendGrid and SES keep requiring a credential
|
|
925
|
+
unconditionally — they have no unauthenticated mode. See
|
|
926
|
+
``_assert_transport_fields`` / the ``no_auth`` branch of ``validate()``.
|
|
927
|
+
"""
|
|
928
|
+
|
|
929
|
+
# Write-only credential. required=False so other fields can be edited
|
|
930
|
+
# without re-submitting it; allow_blank so "" explicitly means "keep".
|
|
931
|
+
password = serializers.CharField(
|
|
932
|
+
write_only=True,
|
|
933
|
+
required=False,
|
|
934
|
+
allow_blank=True,
|
|
935
|
+
max_length=4096,
|
|
936
|
+
trim_whitespace=False,
|
|
937
|
+
style={"input_type": "password"},
|
|
938
|
+
)
|
|
939
|
+
password_is_set = serializers.BooleanField(read_only=True)
|
|
940
|
+
|
|
941
|
+
# Rows one drain tick may claim (#2860 rebound this from "addresses per message",
|
|
942
|
+
# which nothing implemented). The drain caps every tick at EMAIL_MAX_BATCH_SIZE
|
|
943
|
+
# regardless, because a tick runs under a 25 s soft time limit — so a larger value
|
|
944
|
+
# was accepted, persisted, echoed back and then silently discarded (#2887 item 2).
|
|
945
|
+
# Rejecting it out loud is the whole point: a control that reports a value it does
|
|
946
|
+
# not apply is the dead-control failure mode wearing a different hat. ``0`` means
|
|
947
|
+
# "no explicit cap" and falls back to EMAIL_MAX_BATCH_SIZE, which is why min_value
|
|
948
|
+
# is 0 rather than 1.
|
|
949
|
+
max_recipients = serializers.IntegerField(
|
|
950
|
+
required=False,
|
|
951
|
+
min_value=0,
|
|
952
|
+
max_value=EMAIL_MAX_BATCH_SIZE,
|
|
953
|
+
help_text=(
|
|
954
|
+
f"Queued emails one drain tick sends, 1-{EMAIL_MAX_BATCH_SIZE}. "
|
|
955
|
+
f"0 means no explicit cap ({EMAIL_MAX_BATCH_SIZE} per tick)."
|
|
956
|
+
),
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
class Meta:
|
|
960
|
+
model = WorkspaceEmailSettings
|
|
961
|
+
fields = [
|
|
962
|
+
"transport_mode",
|
|
963
|
+
"host",
|
|
964
|
+
"port",
|
|
965
|
+
"security",
|
|
966
|
+
"username",
|
|
967
|
+
"password",
|
|
968
|
+
"password_is_set",
|
|
969
|
+
"from_name",
|
|
970
|
+
"from_email",
|
|
971
|
+
"reply_to",
|
|
972
|
+
"dkim_selector",
|
|
973
|
+
"max_recipients",
|
|
974
|
+
"throttle_per_min",
|
|
975
|
+
"updated_at",
|
|
976
|
+
]
|
|
977
|
+
read_only_fields = ["updated_at"]
|
|
978
|
+
|
|
979
|
+
# -- field-level validation ------------------------------------------------
|
|
980
|
+
|
|
981
|
+
def validate_port(self, value: int) -> int:
|
|
982
|
+
if not (1 <= value <= 65535):
|
|
983
|
+
raise serializers.ValidationError("Port must be between 1 and 65535.")
|
|
984
|
+
return value
|
|
985
|
+
|
|
986
|
+
def validate_from_name(self, value: str) -> str:
|
|
987
|
+
# Header-injection guard: a CRLF in the From display name could inject
|
|
988
|
+
# extra headers. Django's sanitizer catches raw CRLF, but reject here
|
|
989
|
+
# for a clear message (security review M3).
|
|
990
|
+
if "\r" in value or "\n" in value:
|
|
991
|
+
raise serializers.ValidationError("From name must not contain line breaks.")
|
|
992
|
+
return value
|
|
993
|
+
|
|
994
|
+
def validate_dkim_selector(self, value: str) -> str:
|
|
995
|
+
if value and not _DKIM_SELECTOR_RE.match(value):
|
|
996
|
+
raise serializers.ValidationError(
|
|
997
|
+
"DKIM selector may contain only letters, digits, '.', '_', and '-'."
|
|
998
|
+
)
|
|
999
|
+
return value
|
|
1000
|
+
|
|
1001
|
+
# -- object-level validation + persist ------------------------------------
|
|
1002
|
+
|
|
1003
|
+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
|
1004
|
+
instance = self.instance
|
|
1005
|
+
# Bound rather than nested: a closure's own branching is charged to its
|
|
1006
|
+
# enclosing function, and a module-level resolver is testable alone.
|
|
1007
|
+
eff = partial(_effective, attrs, instance)
|
|
1008
|
+
|
|
1009
|
+
mode = eff("transport_mode", EmailTransportMode.CLOUD)
|
|
1010
|
+
if mode == EmailTransportMode.CLOUD:
|
|
1011
|
+
# Nothing to probe — fall back to the global backend at send time.
|
|
1012
|
+
return attrs
|
|
1013
|
+
|
|
1014
|
+
host = (eff("host") or "").strip()
|
|
1015
|
+
port = eff("port", 587)
|
|
1016
|
+
security = eff("security", EmailSecurity.TLS)
|
|
1017
|
+
username = (eff("username") or "").strip()
|
|
1018
|
+
|
|
1019
|
+
incoming_pw = attrs.get("password", "")
|
|
1020
|
+
stored_pw = instance.get_password() if instance is not None else ""
|
|
1021
|
+
mode_changed = instance is not None and instance.transport_mode != mode
|
|
1022
|
+
|
|
1023
|
+
# SMTP is the one transport that may run with no SMTP AUTH at all — a
|
|
1024
|
+
# relay on a trusted network that accepts unauthenticated connections
|
|
1025
|
+
# (Postfix `mynetworks`, an in-cluster relay), which the docs page has
|
|
1026
|
+
# always named as a valid use for "Security: None" without any in-app
|
|
1027
|
+
# way to configure it (#4190). A blank username carries no rotate-vs-
|
|
1028
|
+
# keep secrecy (unlike password), so it always reflects what will
|
|
1029
|
+
# actually be persisted — but password does, so the "no auth" signal
|
|
1030
|
+
# has to be read off the *incoming* password, not the merged one: an
|
|
1031
|
+
# operator who blanks the username while leaving password untouched
|
|
1032
|
+
# (because a no-auth relay needs no password) must not have a stale
|
|
1033
|
+
# credential from a previous auth-enabled config silently survive the
|
|
1034
|
+
# switch. `_should_clear_password` tells `update()` to blank the
|
|
1035
|
+
# stored ciphertext in that case even though no password was submitted
|
|
1036
|
+
# this request — the alternative (leaving it in place) is exactly the
|
|
1037
|
+
# "silently keep sending an old password" failure this closes.
|
|
1038
|
+
no_auth = _is_no_auth_smtp(mode=mode, username=username, incoming_password=incoming_pw)
|
|
1039
|
+
if no_auth:
|
|
1040
|
+
effective_pw = ""
|
|
1041
|
+
self._should_clear_password = bool(stored_pw)
|
|
1042
|
+
else:
|
|
1043
|
+
effective_pw = incoming_pw or stored_pw
|
|
1044
|
+
self._should_clear_password = False
|
|
1045
|
+
|
|
1046
|
+
_assert_transport_fields(
|
|
1047
|
+
mode=mode,
|
|
1048
|
+
host=host,
|
|
1049
|
+
username=username,
|
|
1050
|
+
effective_pw=effective_pw,
|
|
1051
|
+
needs_fresh_password=mode_changed and not incoming_pw and not no_auth,
|
|
1052
|
+
no_auth=no_auth,
|
|
1053
|
+
)
|
|
1054
|
+
_probe_or_raise(
|
|
1055
|
+
mode=mode,
|
|
1056
|
+
host=host,
|
|
1057
|
+
port=port,
|
|
1058
|
+
security=security,
|
|
1059
|
+
username=username,
|
|
1060
|
+
password=effective_pw,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
return attrs
|
|
1064
|
+
|
|
1065
|
+
def update(
|
|
1066
|
+
self, instance: WorkspaceEmailSettings, validated_data: dict[str, Any]
|
|
1067
|
+
) -> WorkspaceEmailSettings:
|
|
1068
|
+
password = validated_data.pop("password", None)
|
|
1069
|
+
for field, value in validated_data.items():
|
|
1070
|
+
setattr(instance, field, value)
|
|
1071
|
+
# Rotate only when a non-empty password was submitted; blank keeps the
|
|
1072
|
+
# stored ciphertext untouched (never overwrite with b"").
|
|
1073
|
+
if password:
|
|
1074
|
+
# This is a third-party SMTP relay credential (Fernet-encrypted at
|
|
1075
|
+
# rest), not a Django auth password — validate_password() enforces
|
|
1076
|
+
# user-account policy (min length, common-password blocklist) that is
|
|
1077
|
+
# meaningless for a provider-issued secret the operator cannot change.
|
|
1078
|
+
# nosemgrep: unvalidated-password
|
|
1079
|
+
instance.set_password(password)
|
|
1080
|
+
elif getattr(self, "_should_clear_password", False):
|
|
1081
|
+
# The operator switched this SMTP transport to "no authentication"
|
|
1082
|
+
# (#4190) without submitting a new password — clear whatever secret
|
|
1083
|
+
# was stored under the previous auth-enabled config so it can never
|
|
1084
|
+
# be silently reused if a username is added back later without also
|
|
1085
|
+
# re-entering a password. Set in validate(), consumed once here.
|
|
1086
|
+
# Same non-auth SMTP credential as the call above, so validate_password()
|
|
1087
|
+
# does not apply; an empty value clears the stored ciphertext.
|
|
1088
|
+
# nosemgrep: unvalidated-password
|
|
1089
|
+
instance.set_password("")
|
|
1090
|
+
request = self.context.get("request")
|
|
1091
|
+
if request is not None and getattr(request, "user", None) is not None:
|
|
1092
|
+
instance.updated_by = request.user
|
|
1093
|
+
instance.save()
|
|
1094
|
+
return instance
|