trueppm-api 0.4.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trueppm_api/__init__.py +14 -0
- trueppm_api/apps/access/__init__.py +0 -0
- trueppm_api/apps/access/apps.py +19 -0
- trueppm_api/apps/access/groups.py +518 -0
- trueppm_api/apps/access/management/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/__init__.py +0 -0
- trueppm_api/apps/access/management/commands/create_admin.py +164 -0
- trueppm_api/apps/access/management/commands/migrate_locked.py +96 -0
- trueppm_api/apps/access/management/commands/revoke_api_tokens.py +209 -0
- trueppm_api/apps/access/migrations/0001_initial.py +61 -0
- trueppm_api/apps/access/migrations/0001_squashed_0012_programmembership_role_title.py +167 -0
- trueppm_api/apps/access/migrations/0002_alter_projectmembership_id.py +29 -0
- trueppm_api/apps/access/migrations/0003_projectmembership_soft_delete.py +24 -0
- trueppm_api/apps/access/migrations/0004_alter_projectmembership_role.py +34 -0
- trueppm_api/apps/access/migrations/0005_program_entity_and_membership.py +76 -0
- trueppm_api/apps/access/migrations/0006_role_ordinal_spacing.py +113 -0
- trueppm_api/apps/access/migrations/0007_projectmembership_joined_at_and_more.py +35 -0
- trueppm_api/apps/access/migrations/0008_projectmembership_source_group.py +25 -0
- trueppm_api/apps/access/migrations/0009_projectmembership_pm_proj_serverver_idx.py +20 -0
- trueppm_api/apps/access/migrations/0010_programmembership_joined_at_and_more.py +38 -0
- trueppm_api/apps/access/migrations/0011_alter_programmembership_unique_together_and_more.py +36 -0
- trueppm_api/apps/access/migrations/0012_programmembership_role_title.py +17 -0
- trueppm_api/apps/access/migrations/0013_userdefinedmentiongroup.py +82 -0
- trueppm_api/apps/access/migrations/0014_programmembership_progm_serverver_idx.py +19 -0
- trueppm_api/apps/access/migrations/0015_programuserdefinedmentiongroup.py +86 -0
- trueppm_api/apps/access/migrations/0016_externalstakeholder.py +67 -0
- trueppm_api/apps/access/migrations/0017_viewer_ordinal_one.py +132 -0
- trueppm_api/apps/access/migrations/0018_programmembership_sync_seq_and_more.py +52 -0
- trueppm_api/apps/access/migrations/0019_programmembership_reinstated_at_and_more.py +22 -0
- trueppm_api/apps/access/migrations/__init__.py +0 -0
- trueppm_api/apps/access/models.py +588 -0
- trueppm_api/apps/access/permissions.py +2837 -0
- trueppm_api/apps/access/serializers.py +946 -0
- trueppm_api/apps/access/services.py +667 -0
- trueppm_api/apps/access/signals.py +103 -0
- trueppm_api/apps/access/tasks.py +50 -0
- trueppm_api/apps/access/throttles.py +95 -0
- trueppm_api/apps/access/urls.py +191 -0
- trueppm_api/apps/access/views.py +1931 -0
- trueppm_api/apps/agents/__init__.py +6 -0
- trueppm_api/apps/agents/apps.py +11 -0
- trueppm_api/apps/agents/canonical.py +73 -0
- trueppm_api/apps/agents/deferred.py +108 -0
- trueppm_api/apps/agents/management/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/__init__.py +0 -0
- trueppm_api/apps/agents/management/commands/audit_prune.py +137 -0
- trueppm_api/apps/agents/management/commands/audit_verify.py +101 -0
- trueppm_api/apps/agents/middleware.py +44 -0
- trueppm_api/apps/agents/migrations/0001_initial.py +231 -0
- trueppm_api/apps/agents/migrations/0002_agentactioncheckpoint.py +71 -0
- trueppm_api/apps/agents/migrations/0003_agentactionrefusaldetail.py +58 -0
- trueppm_api/apps/agents/migrations/__init__.py +0 -0
- trueppm_api/apps/agents/models.py +371 -0
- trueppm_api/apps/agents/refusal.py +103 -0
- trueppm_api/apps/agents/serializers.py +85 -0
- trueppm_api/apps/agents/services.py +397 -0
- trueppm_api/apps/agents/signals.py +30 -0
- trueppm_api/apps/agents/urls.py +15 -0
- trueppm_api/apps/agents/views.py +108 -0
- trueppm_api/apps/csvimport/__init__.py +12 -0
- trueppm_api/apps/csvimport/apps.py +11 -0
- trueppm_api/apps/csvimport/mapping.py +363 -0
- trueppm_api/apps/csvimport/migrations/0001_initial.py +74 -0
- trueppm_api/apps/csvimport/migrations/0002_schedule_outline_batch_operations.py +37 -0
- trueppm_api/apps/csvimport/migrations/0003_csvimportrequest_date_order_and_more.py +26 -0
- trueppm_api/apps/csvimport/migrations/__init__.py +0 -0
- trueppm_api/apps/csvimport/models.py +111 -0
- trueppm_api/apps/csvimport/parser.py +1825 -0
- trueppm_api/apps/csvimport/services.py +48 -0
- trueppm_api/apps/csvimport/tasks.py +418 -0
- trueppm_api/apps/csvimport/template.py +37 -0
- trueppm_api/apps/csvimport/urls.py +50 -0
- trueppm_api/apps/csvimport/views.py +793 -0
- trueppm_api/apps/history/__init__.py +0 -0
- trueppm_api/apps/history/apps.py +12 -0
- trueppm_api/apps/history/changelog.py +359 -0
- trueppm_api/apps/history/diff_policy.py +177 -0
- trueppm_api/apps/history/serializers.py +104 -0
- trueppm_api/apps/history/signals.py +82 -0
- trueppm_api/apps/history/tasks.py +105 -0
- trueppm_api/apps/history/urls.py +38 -0
- trueppm_api/apps/history/views.py +443 -0
- trueppm_api/apps/idempotency/__init__.py +0 -0
- trueppm_api/apps/idempotency/apps.py +11 -0
- trueppm_api/apps/idempotency/migrations/0001_initial.py +71 -0
- trueppm_api/apps/idempotency/migrations/__init__.py +0 -0
- trueppm_api/apps/idempotency/mixins.py +226 -0
- trueppm_api/apps/idempotency/models.py +75 -0
- trueppm_api/apps/idempotency/tasks.py +61 -0
- trueppm_api/apps/integrations/__init__.py +13 -0
- trueppm_api/apps/integrations/apps.py +59 -0
- trueppm_api/apps/integrations/connections.py +806 -0
- trueppm_api/apps/integrations/encryption.py +92 -0
- trueppm_api/apps/integrations/external_sources.py +837 -0
- trueppm_api/apps/integrations/git_automation_services.py +231 -0
- trueppm_api/apps/integrations/git_webhook_auth.py +243 -0
- trueppm_api/apps/integrations/http.py +403 -0
- trueppm_api/apps/integrations/me_work.py +218 -0
- trueppm_api/apps/integrations/migrations/0001_initial.py +64 -0
- trueppm_api/apps/integrations/migrations/0001_squashed_0006_tasklink_description_tasklink_preview_type_and_more.py +187 -0
- trueppm_api/apps/integrations/migrations/0002_tasklink.py +67 -0
- trueppm_api/apps/integrations/migrations/0003_tasklink_tasklink_serverver_idx.py +17 -0
- trueppm_api/apps/integrations/migrations/0004_tasklink_custom_title_tasklink_labels.py +25 -0
- trueppm_api/apps/integrations/migrations/0005_boardautomation.py +56 -0
- trueppm_api/apps/integrations/migrations/0006_tasklink_description_tasklink_preview_type_and_more.py +40 -0
- trueppm_api/apps/integrations/migrations/0007_tasklink_created_at.py +23 -0
- trueppm_api/apps/integrations/migrations/0008_integrationcredential_config_externalworkitem.py +66 -0
- trueppm_api/apps/integrations/migrations/0009_externalsyncrequest_and_more.py +96 -0
- trueppm_api/apps/integrations/migrations/0010_externalworkitem_due_date.py +17 -0
- trueppm_api/apps/integrations/migrations/0011_tasklink_sync_seq_tasklink_tasklink_syncseq_idx.py +22 -0
- trueppm_api/apps/integrations/migrations/0012_boardautomation_last_delivery_at_and_more.py +42 -0
- trueppm_api/apps/integrations/migrations/0013_externalsyncrequest_attempt_count_and_more.py +26 -0
- trueppm_api/apps/integrations/migrations/__init__.py +0 -0
- trueppm_api/apps/integrations/models.py +622 -0
- trueppm_api/apps/integrations/notification_channels.py +57 -0
- trueppm_api/apps/integrations/opengraph.py +229 -0
- trueppm_api/apps/integrations/outgoing.py +201 -0
- trueppm_api/apps/integrations/providers.py +519 -0
- trueppm_api/apps/integrations/registry.py +345 -0
- trueppm_api/apps/integrations/serializers.py +453 -0
- trueppm_api/apps/integrations/services.py +171 -0
- trueppm_api/apps/integrations/tasks.py +731 -0
- trueppm_api/apps/integrations/throttles.py +165 -0
- trueppm_api/apps/integrations/urls.py +77 -0
- trueppm_api/apps/integrations/views.py +1106 -0
- trueppm_api/apps/jiraimport/__init__.py +8 -0
- trueppm_api/apps/jiraimport/apps.py +11 -0
- trueppm_api/apps/jiraimport/migrations/0001_initial.py +72 -0
- trueppm_api/apps/jiraimport/migrations/__init__.py +0 -0
- trueppm_api/apps/jiraimport/models.py +70 -0
- trueppm_api/apps/jiraimport/parser.py +350 -0
- trueppm_api/apps/jiraimport/services.py +46 -0
- trueppm_api/apps/jiraimport/tasks.py +277 -0
- trueppm_api/apps/jiraimport/urls.py +15 -0
- trueppm_api/apps/jiraimport/views.py +153 -0
- trueppm_api/apps/msproject/__init__.py +0 -0
- trueppm_api/apps/msproject/apps.py +11 -0
- trueppm_api/apps/msproject/dataclasses.py +153 -0
- trueppm_api/apps/msproject/exporter.py +500 -0
- trueppm_api/apps/msproject/extended_attributes.py +77 -0
- trueppm_api/apps/msproject/importer.py +1095 -0
- trueppm_api/apps/msproject/migrations/0001_import_request.py +77 -0
- trueppm_api/apps/msproject/migrations/0001_squashed_0002_importrequest_creates_project.py +80 -0
- trueppm_api/apps/msproject/migrations/0002_importrequest_creates_project.py +15 -0
- trueppm_api/apps/msproject/migrations/__init__.py +0 -0
- trueppm_api/apps/msproject/models.py +81 -0
- trueppm_api/apps/msproject/parser.py +1231 -0
- trueppm_api/apps/msproject/serializers.py +136 -0
- trueppm_api/apps/msproject/services.py +58 -0
- trueppm_api/apps/msproject/tasks.py +436 -0
- trueppm_api/apps/msproject/urls.py +39 -0
- trueppm_api/apps/msproject/views.py +531 -0
- trueppm_api/apps/notifications/__init__.py +7 -0
- trueppm_api/apps/notifications/apps.py +15 -0
- trueppm_api/apps/notifications/backfill.py +79 -0
- trueppm_api/apps/notifications/categories.py +112 -0
- trueppm_api/apps/notifications/delivery_limits.py +258 -0
- trueppm_api/apps/notifications/digests.py +447 -0
- trueppm_api/apps/notifications/email_backend.py +330 -0
- trueppm_api/apps/notifications/email_health.py +128 -0
- trueppm_api/apps/notifications/migrations/0001_initial.py +207 -0
- trueppm_api/apps/notifications/migrations/0001_squashed_0006_notification_task.py +294 -0
- trueppm_api/apps/notifications/migrations/0002_project_notification_preference.py +67 -0
- trueppm_api/apps/notifications/migrations/0003_projectnotificationpreference_paused.py +17 -0
- trueppm_api/apps/notifications/migrations/0004_clean_unknown_matrix_keys.py +30 -0
- trueppm_api/apps/notifications/migrations/0005_notification_body_notification_event_type_and_more.py +27 -0
- trueppm_api/apps/notifications/migrations/0006_notification_task.py +25 -0
- trueppm_api/apps/notifications/migrations/0007_workspaceemailsettings.py +80 -0
- trueppm_api/apps/notifications/migrations/0008_notification_snoozed_until.py +17 -0
- trueppm_api/apps/notifications/migrations/0009_usernotificationsettings.py +43 -0
- trueppm_api/apps/notifications/migrations/0010_projectnotificationpreference_schema_version.py +20 -0
- trueppm_api/apps/notifications/migrations/0011_usernotificationsettings_digest_hour_and_more.py +96 -0
- trueppm_api/apps/notifications/migrations/__init__.py +0 -0
- trueppm_api/apps/notifications/models.py +1197 -0
- trueppm_api/apps/notifications/receivers.py +155 -0
- trueppm_api/apps/notifications/schema_migrations.py +56 -0
- trueppm_api/apps/notifications/serializers.py +961 -0
- trueppm_api/apps/notifications/services.py +1451 -0
- trueppm_api/apps/notifications/tasks.py +711 -0
- trueppm_api/apps/notifications/throttles.py +98 -0
- trueppm_api/apps/notifications/urls.py +97 -0
- trueppm_api/apps/notifications/views.py +904 -0
- trueppm_api/apps/observability/__init__.py +0 -0
- trueppm_api/apps/observability/apps.py +31 -0
- trueppm_api/apps/observability/logging.py +213 -0
- trueppm_api/apps/observability/migrations/0001_initial.py +35 -0
- trueppm_api/apps/observability/migrations/0001_squashed_0003_alter_purgerun_trigger.py +154 -0
- trueppm_api/apps/observability/migrations/0002_purgerun_retentionpolicy_retentionschedule.py +131 -0
- trueppm_api/apps/observability/migrations/0003_alter_purgerun_trigger.py +21 -0
- trueppm_api/apps/observability/migrations/0004_alter_retentionpolicy_key.py +28 -0
- trueppm_api/apps/observability/migrations/__init__.py +0 -0
- trueppm_api/apps/observability/models.py +141 -0
- trueppm_api/apps/observability/otel/__init__.py +45 -0
- trueppm_api/apps/observability/otel/attributes.py +247 -0
- trueppm_api/apps/observability/otel/export_health.py +533 -0
- trueppm_api/apps/observability/otel/instrumentation.py +351 -0
- trueppm_api/apps/observability/otel/metrics.py +551 -0
- trueppm_api/apps/observability/otel/provider.py +511 -0
- trueppm_api/apps/observability/otel/request_attributes.py +204 -0
- trueppm_api/apps/observability/purge_registry.py +124 -0
- trueppm_api/apps/observability/retention.py +139 -0
- trueppm_api/apps/observability/selectors.py +969 -0
- trueppm_api/apps/observability/serializers.py +142 -0
- trueppm_api/apps/observability/services.py +407 -0
- trueppm_api/apps/observability/tasks.py +266 -0
- trueppm_api/apps/observability/urls.py +27 -0
- trueppm_api/apps/observability/views.py +523 -0
- trueppm_api/apps/profiles/__init__.py +0 -0
- trueppm_api/apps/profiles/apps.py +15 -0
- trueppm_api/apps/profiles/constants.py +54 -0
- trueppm_api/apps/profiles/migrations/0001_initial.py +55 -0
- trueppm_api/apps/profiles/migrations/0001_squashed_0004_userprofile_role_context.py +125 -0
- trueppm_api/apps/profiles/migrations/0002_userprofile_hidden_views.py +17 -0
- trueppm_api/apps/profiles/migrations/0003_projectvisit.py +63 -0
- trueppm_api/apps/profiles/migrations/0004_userprofile_role_context.py +26 -0
- trueppm_api/apps/profiles/migrations/0005_userprofile_schedule_in_deliver.py +20 -0
- trueppm_api/apps/profiles/migrations/0006_userprofile_date_format_userprofile_timezone.py +36 -0
- trueppm_api/apps/profiles/migrations/0007_userpin.py +91 -0
- trueppm_api/apps/profiles/migrations/0008_remove_userprofile_schedule_in_deliver.py +71 -0
- trueppm_api/apps/profiles/migrations/0009_auth_user_email_upper_idx.py +81 -0
- trueppm_api/apps/profiles/migrations/__init__.py +0 -0
- trueppm_api/apps/profiles/models.py +276 -0
- trueppm_api/apps/profiles/serializers.py +199 -0
- trueppm_api/apps/profiles/services.py +466 -0
- trueppm_api/apps/profiles/views.py +124 -0
- trueppm_api/apps/projects/__init__.py +0 -0
- trueppm_api/apps/projects/actual_date_rules.py +163 -0
- trueppm_api/apps/projects/amend.py +133 -0
- trueppm_api/apps/projects/apps.py +38 -0
- trueppm_api/apps/projects/asset_feed.py +459 -0
- trueppm_api/apps/projects/asset_views.py +317 -0
- trueppm_api/apps/projects/attachment_policy.py +278 -0
- trueppm_api/apps/projects/authentication.py +449 -0
- trueppm_api/apps/projects/backfill.py +287 -0
- trueppm_api/apps/projects/backlog_services.py +324 -0
- trueppm_api/apps/projects/backlog_views.py +302 -0
- trueppm_api/apps/projects/batch_operation_views.py +264 -0
- trueppm_api/apps/projects/blocker_services.py +510 -0
- trueppm_api/apps/projects/board_activity.py +462 -0
- trueppm_api/apps/projects/board_activity_views.py +186 -0
- trueppm_api/apps/projects/board_lanes.py +134 -0
- trueppm_api/apps/projects/bulk_settings.py +194 -0
- trueppm_api/apps/projects/bundled_templates.py +215 -0
- trueppm_api/apps/projects/calendar_settings.py +180 -0
- trueppm_api/apps/projects/ceremony_views.py +244 -0
- trueppm_api/apps/projects/commit_moment.py +204 -0
- trueppm_api/apps/projects/config_notice.py +1325 -0
- trueppm_api/apps/projects/custom_field_values.py +266 -0
- trueppm_api/apps/projects/decisions_services.py +93 -0
- trueppm_api/apps/projects/decisions_views.py +94 -0
- trueppm_api/apps/projects/estimation_scale.py +126 -0
- trueppm_api/apps/projects/export_bundle.py +353 -0
- trueppm_api/apps/projects/fixtures/seeds/atlas-platform-launch.json +4256 -0
- trueppm_api/apps/projects/fixtures/seeds/aurora-mobile-app.json +2000 -0
- trueppm_api/apps/projects/fixtures/seeds/bayside-civic-center.json +1825 -0
- trueppm_api/apps/projects/fixtures/seeds/ga-launch.json +1905 -0
- trueppm_api/apps/projects/fixtures/seeds/helios-crm-replacement.json +1409 -0
- trueppm_api/apps/projects/guardrail_policy_source.py +269 -0
- trueppm_api/apps/projects/inbound_sync.py +555 -0
- trueppm_api/apps/projects/iteration_label.py +85 -0
- trueppm_api/apps/projects/lifecycle.py +191 -0
- trueppm_api/apps/projects/management/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/__init__.py +0 -0
- trueppm_api/apps/projects/management/commands/backfill_in_progress_status.py +92 -0
- trueppm_api/apps/projects/management/commands/create_demo_share_link.py +368 -0
- trueppm_api/apps/projects/management/commands/export_program.py +50 -0
- trueppm_api/apps/projects/management/commands/import_seed.py +165 -0
- trueppm_api/apps/projects/management/commands/load_sample_project.py +247 -0
- trueppm_api/apps/projects/management/commands/seed_capacity.py +405 -0
- trueppm_api/apps/projects/management/commands/seed_integration_fixtures.py +178 -0
- trueppm_api/apps/projects/management/commands/shift_sample_dates.py +120 -0
- trueppm_api/apps/projects/mcp_settings.py +305 -0
- trueppm_api/apps/projects/methodology.py +178 -0
- trueppm_api/apps/projects/migrations/0001_initial.py +208 -0
- trueppm_api/apps/projects/migrations/0001_squashed_0094_project_decisions_policy.py +5595 -0
- trueppm_api/apps/projects/migrations/0002_alter_uuid_pk_serialize_false.py +57 -0
- trueppm_api/apps/projects/migrations/0003_rename_task_project_index.py +25 -0
- trueppm_api/apps/projects/migrations/0004_soft_delete_and_dependency_versioned.py +70 -0
- trueppm_api/apps/projects/migrations/0005_task_assignee.py +33 -0
- trueppm_api/apps/projects/migrations/0006_task_utilization_index.py +31 -0
- trueppm_api/apps/projects/migrations/0007_task_is_milestone.py +28 -0
- trueppm_api/apps/projects/migrations/0008_baseline.py +119 -0
- trueppm_api/apps/projects/migrations/0009_historicaldependency_historicalproject_and_more.py +210 -0
- trueppm_api/apps/projects/migrations/0010_risk.py +229 -0
- trueppm_api/apps/projects/migrations/0011_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0012_task_status.py +27 -0
- trueppm_api/apps/projects/migrations/0013_task_planned_start.py +24 -0
- trueppm_api/apps/projects/migrations/0014_short_hex_ids.py +74 -0
- trueppm_api/apps/projects/migrations/0015_backfill_short_ids.py +48 -0
- trueppm_api/apps/projects/migrations/0016_task_actual_dates.py +42 -0
- trueppm_api/apps/projects/migrations/0017_boardcolumnconfig.py +43 -0
- trueppm_api/apps/projects/migrations/0018_estimation_governance.py +62 -0
- trueppm_api/apps/projects/migrations/0019_backfill_wbs_paths.py +28 -0
- trueppm_api/apps/projects/migrations/0020_task_status_5col.py +65 -0
- trueppm_api/apps/projects/migrations/0021_boardsavedview.py +64 -0
- trueppm_api/apps/projects/migrations/0022_task_status_changed_at_priority_rank.py +35 -0
- trueppm_api/apps/projects/migrations/0023_risk_framework_fields.py +96 -0
- trueppm_api/apps/projects/migrations/0024_riskcomment.py +52 -0
- trueppm_api/apps/projects/migrations/0025_sprint.py +352 -0
- trueppm_api/apps/projects/migrations/0026_project_methodology.py +30 -0
- trueppm_api/apps/projects/migrations/0027_sprint_retro.py +89 -0
- trueppm_api/apps/projects/migrations/0028_normalize_notes_field.py +42 -0
- trueppm_api/apps/projects/migrations/0029_complete_implies_full_progress.py +38 -0
- trueppm_api/apps/projects/migrations/0030_review_implies_full_progress.py +37 -0
- trueppm_api/apps/projects/migrations/0031_task_remaining_points.py +22 -0
- trueppm_api/apps/projects/migrations/0032_task_is_subtask_and_sprint_scope_change.py +90 -0
- trueppm_api/apps/projects/migrations/0033_task_my_work_index.py +38 -0
- trueppm_api/apps/projects/migrations/0034_inbound_task_sync.py +313 -0
- trueppm_api/apps/projects/migrations/0035_retro_versioned_and_suggested_assignee.py +173 -0
- trueppm_api/apps/projects/migrations/0036_program_entity_and_membership.py +157 -0
- trueppm_api/apps/projects/migrations/0037_sprint_capacity_points.py +20 -0
- trueppm_api/apps/projects/migrations/0038_taskcomment_commentreaction_commentacknowledgement_and_more.py +233 -0
- trueppm_api/apps/projects/migrations/0039_apitoken_and_more.py +88 -0
- trueppm_api/apps/projects/migrations/0040_program_general_fields.py +119 -0
- trueppm_api/apps/projects/migrations/0041_ceremony_template_and_phase_gate_config.py +210 -0
- trueppm_api/apps/projects/migrations/0041_project_general_fields.py +106 -0
- trueppm_api/apps/projects/migrations/0042_merge_20260522_0410.py +12 -0
- trueppm_api/apps/projects/migrations/0042_program_rollup_config.py +95 -0
- trueppm_api/apps/projects/migrations/0042_workflow_color_and_custom_fields.py +94 -0
- trueppm_api/apps/projects/migrations/0043_merge_20260522_1118.py +10 -0
- trueppm_api/apps/projects/migrations/0044_merge_20260522_rollup.py +10 -0
- trueppm_api/apps/projects/migrations/0044_project_archive_program_close.py +119 -0
- trueppm_api/apps/projects/migrations/0045_program_risk_policy.py +78 -0
- trueppm_api/apps/projects/migrations/0046_merge_20260523_2248.py +12 -0
- trueppm_api/apps/projects/migrations/0047_apitokenauditentry_program_and_more.py +55 -0
- trueppm_api/apps/projects/migrations/0048_historicalprogram_color_program_color.py +32 -0
- trueppm_api/apps/projects/migrations/0049_backlog_item.py +112 -0
- trueppm_api/apps/projects/migrations/0050_backlog_item_trgm_search.py +26 -0
- trueppm_api/apps/projects/migrations/0051_alter_calendar_working_days_alter_dependency_lag_and_more.py +144 -0
- trueppm_api/apps/projects/migrations/0052_recurring_tasks.py +278 -0
- trueppm_api/apps/projects/migrations/0053_dependency_dep_pred_serverver_idx_and_more.py +56 -0
- trueppm_api/apps/projects/migrations/0054_sprintscopechange_goal_impact_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0055_historicaltask_sprint_pending_and_more.py +51 -0
- trueppm_api/apps/projects/migrations/0056_historicalproject_prioritization_model_and_more.py +276 -0
- trueppm_api/apps/projects/migrations/0057_historicalsprint_binding_committed_snapshot_and_more.py +58 -0
- trueppm_api/apps/projects/migrations/0058_historicaltask_delivery_mode_and_more.py +72 -0
- trueppm_api/apps/projects/migrations/0059_forecastsnapshot.py +80 -0
- trueppm_api/apps/projects/migrations/0060_historicalprojectsignalprivacypolicy_and_more.py +90 -0
- trueppm_api/apps/projects/migrations/0061_baselinetask_story_points.py +17 -0
- trueppm_api/apps/projects/migrations/0062_project_is_sample.py +22 -0
- trueppm_api/apps/projects/migrations/0063_historicalsprint_wip_limit_sprint_wip_limit.py +22 -0
- trueppm_api/apps/projects/migrations/0064_sprinttaskoutcome.py +97 -0
- trueppm_api/apps/projects/migrations/0065_historicalsprint_goal_outcome_sprint_goal_outcome.py +32 -0
- trueppm_api/apps/projects/migrations/0066_historicalproject_lead_project_lead.py +38 -0
- trueppm_api/apps/projects/migrations/0067_sprint_exclude_from_velocity.py +22 -0
- trueppm_api/apps/projects/migrations/0068_project_recalculated_at.py +23 -0
- trueppm_api/apps/projects/migrations/0069_iteration_label.py +22 -0
- trueppm_api/apps/projects/migrations/0070_historicalprogram_iteration_label_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0071_backfill_iteration_label_inherit.py +35 -0
- trueppm_api/apps/projects/migrations/0072_pulseresponse_retroboarditem.py +129 -0
- trueppm_api/apps/projects/migrations/0073_sprinttaskoutcome_demo_ready.py +17 -0
- trueppm_api/apps/projects/migrations/0074_historicaltask_blocked_reason_task_blocked_reason.py +30 -0
- trueppm_api/apps/projects/migrations/0075_risk_decimal_short_id.py +42 -0
- trueppm_api/apps/projects/migrations/0076_sprinttaskoutcome_review_polish.py +45 -0
- trueppm_api/apps/projects/migrations/0077_task_blocker_fields.py +111 -0
- trueppm_api/apps/projects/migrations/0078_alter_risktask_unique_together_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0079_historicalproject_status_date_project_status_date.py +22 -0
- trueppm_api/apps/projects/migrations/0080_alter_historicaltask_type_alter_task_type.py +46 -0
- trueppm_api/apps/projects/migrations/0081_historicalprogram_allow_guests_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0082_project_last_sync_version.py +90 -0
- trueppm_api/apps/projects/migrations/0083_historicalprogram_mc_history_attribution_audience_and_more.py +108 -0
- trueppm_api/apps/projects/migrations/0084_tasknote.py +71 -0
- trueppm_api/apps/projects/migrations/0085_sprint_ix_sprint_velocity.py +33 -0
- trueppm_api/apps/projects/migrations/0086_board_card_search_trgm.py +33 -0
- trueppm_api/apps/projects/migrations/0087_historicalprogram_task_duration_change_percent_policy_and_more.py +155 -0
- trueppm_api/apps/projects/migrations/0088_historicalprogram_allowed_attachment_types_and_more.py +61 -0
- trueppm_api/apps/projects/migrations/0089_signalceilingraiseproposal_signalceilingraisevote_and_more.py +157 -0
- trueppm_api/apps/projects/migrations/0090_historicaltask_proj_histdate_index.py +44 -0
- trueppm_api/apps/projects/migrations/0091_project_board_cadence.py +39 -0
- trueppm_api/apps/projects/migrations/0092_program_target_date.py +22 -0
- trueppm_api/apps/projects/migrations/0093_cross_project_dependency_consent.py +58 -0
- trueppm_api/apps/projects/migrations/0094_project_decisions_policy.py +90 -0
- trueppm_api/apps/projects/migrations/0095_poker_session_vote.py +127 -0
- trueppm_api/apps/projects/migrations/0095_squashed_0097_release_0_3.py +225 -0
- trueppm_api/apps/projects/migrations/0096_crossprojectslipconflict.py +91 -0
- trueppm_api/apps/projects/migrations/0097_backlogitem_backlogitem_tags_gin_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0098_historicalproject_show_baselines_and_more.py +52 -0
- trueppm_api/apps/projects/migrations/0099_apitoken_scopes.py +48 -0
- trueppm_api/apps/projects/migrations/0100_task_dep_serverver_idx_concurrent.py +61 -0
- trueppm_api/apps/projects/migrations/0101_dependency_deleted_at_task_deleted_at.py +22 -0
- trueppm_api/apps/projects/migrations/0102_project_deleted_at_project_proj_isdel_deletedat_idx.py +25 -0
- trueppm_api/apps/projects/migrations/0103_taskdurationchangeevent_task_dur_evt_sprint_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0104_historicalproject_stale_task_threshold_days_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0104_trash_restore_1113.py +38 -0
- trueppm_api/apps/projects/migrations/0105_historical_changelog_indexes.py +62 -0
- trueppm_api/apps/projects/migrations/0106_boardsavedview_schema_version.py +20 -0
- trueppm_api/apps/projects/migrations/0107_task_activity_event.py +70 -0
- trueppm_api/apps/projects/migrations/0108_remove_apitoken_api_token_scope_xor_and_more.py +117 -0
- trueppm_api/apps/projects/migrations/0109_projectexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0110_sharelink.py +123 -0
- trueppm_api/apps/projects/migrations/0111_projectcalendarlayer.py +67 -0
- trueppm_api/apps/projects/migrations/0112_sharelink_expires_at_alter_sharelink_content_kind.py +31 -0
- trueppm_api/apps/projects/migrations/0113_alter_historicaltask_percent_complete_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0114_historicalproject_default_member_role_and_more.py +28 -0
- trueppm_api/apps/projects/migrations/0115_historicalproject_end_date_shift_threshold_days_and_more.py +22 -0
- trueppm_api/apps/projects/migrations/0116_alter_taskactivityevent_event_type.py +28 -0
- trueppm_api/apps/projects/migrations/0117_label_tasklabel_label_tasks_and_more.py +126 -0
- trueppm_api/apps/projects/migrations/0118_programexportjob.py +82 -0
- trueppm_api/apps/projects/migrations/0119_historicalprogram_calendar_program_calendar.py +36 -0
- trueppm_api/apps/projects/migrations/0120_alter_backlogitem_item_type.py +29 -0
- trueppm_api/apps/projects/migrations/0121_task_three_point_estimate_ordered.py +64 -0
- trueppm_api/apps/projects/migrations/0122_task_relation.py +173 -0
- trueppm_api/apps/projects/migrations/0123_remove_historicalproject_agile_features_and_more.py +20 -0
- trueppm_api/apps/projects/migrations/0124_dependency_is_driving.py +17 -0
- trueppm_api/apps/projects/migrations/0125_historicalprogram_estimation_scale_and_more.py +68 -0
- trueppm_api/apps/projects/migrations/0126_projectcustomfield_show_on_card_taskcustomfieldvalue.py +80 -0
- trueppm_api/apps/projects/migrations/0127_historicalprogram_mcp_enabled_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0128_acceptancecriterion_sync_seq_apitoken_sync_seq_and_more.py +192 -0
- trueppm_api/apps/projects/migrations/0129_sharelink_show_milestone_dates.py +20 -0
- trueppm_api/apps/projects/migrations/0130_programimportjob.py +85 -0
- trueppm_api/apps/projects/migrations/0131_historicalprogram_sprint_picker_ready_only_default_and_more.py +32 -0
- trueppm_api/apps/projects/migrations/0132_task_scheduled_start.py +17 -0
- trueppm_api/apps/projects/migrations/0133_project_status_date_floor_armed_at.py +17 -0
- trueppm_api/apps/projects/migrations/0134_task_edited_at_task_seeded_at_task_source_id_and_more.py +65 -0
- trueppm_api/apps/projects/migrations/0135_projecttemplate_templateapplication_and_more.py +175 -0
- trueppm_api/apps/projects/migrations/0136_schedule_outline_batch_operations.py +118 -0
- trueppm_api/apps/projects/migrations/0137_collapse_dual_scope_api_tokens.py +54 -0
- trueppm_api/apps/projects/migrations/0138_apitokenauditentry_api_token_audit_owner_idx.py +18 -0
- trueppm_api/apps/projects/migrations/0139_historicaltask_duration_unit_task_duration_unit.py +32 -0
- trueppm_api/apps/projects/migrations/0140_historicaltask_auto_container_and_more.py +113 -0
- trueppm_api/apps/projects/migrations/0141_historicalproject_draft_started_at_and_more.py +44 -0
- trueppm_api/apps/projects/migrations/0142_baseline_calendar_hours_per_day_and_more.py +31 -0
- trueppm_api/apps/projects/migrations/0143_sprintcloserequest_failure_reason_and_more.py +42 -0
- trueppm_api/apps/projects/migrations/0144_projecttemplate_source_project_and_more.py +35 -0
- trueppm_api/apps/projects/migrations/0145_seed_bundled_templates.py +65 -0
- trueppm_api/apps/projects/migrations/0146_historicaltask_board_lane_task_board_lane.py +32 -0
- trueppm_api/apps/projects/migrations/0147_structuraloperation.py +128 -0
- trueppm_api/apps/projects/migrations/0148_task_unique_task_wbs_path_per_project_live.py +76 -0
- trueppm_api/apps/projects/migrations/0149_drop_workshops_tables.py +42 -0
- trueppm_api/apps/projects/migrations/0150_clear_uncommitted_cpm_output.py +130 -0
- trueppm_api/apps/projects/migrations/0151_config_notice_request.py +70 -0
- trueppm_api/apps/projects/migrations/0152_aggregate_task_wbs_name_idx.py +20 -0
- trueppm_api/apps/projects/migrations/0153_program_sample_anchor_date.py +22 -0
- trueppm_api/apps/projects/migrations/__init__.py +0 -0
- trueppm_api/apps/projects/models.py +8946 -0
- trueppm_api/apps/projects/poker_services.py +154 -0
- trueppm_api/apps/projects/poker_views.py +398 -0
- trueppm_api/apps/projects/product_backlog_services.py +450 -0
- trueppm_api/apps/projects/program_label_services.py +94 -0
- trueppm_api/apps/projects/program_label_views.py +189 -0
- trueppm_api/apps/projects/program_rollup.py +575 -0
- trueppm_api/apps/projects/program_schedule.py +745 -0
- trueppm_api/apps/projects/program_views.py +3220 -0
- trueppm_api/apps/projects/project_templates.py +422 -0
- trueppm_api/apps/projects/receivers.py +234 -0
- trueppm_api/apps/projects/refusal_codes.py +140 -0
- trueppm_api/apps/projects/reorder_services.py +145 -0
- trueppm_api/apps/projects/restructure_hooks.py +54 -0
- trueppm_api/apps/projects/retro_board_services.py +368 -0
- trueppm_api/apps/projects/retro_services.py +285 -0
- trueppm_api/apps/projects/risk_import.py +396 -0
- trueppm_api/apps/projects/row_identity.py +113 -0
- trueppm_api/apps/projects/schema_migrations.py +228 -0
- trueppm_api/apps/projects/schemas/seed_v1.json +493 -0
- trueppm_api/apps/projects/schemas/seed_v2.json +892 -0
- trueppm_api/apps/projects/seed/__init__.py +61 -0
- trueppm_api/apps/projects/seed/exporter.py +1644 -0
- trueppm_api/apps/projects/seed/forecast_backfill.py +247 -0
- trueppm_api/apps/projects/seed/importer.py +2390 -0
- trueppm_api/apps/projects/seed/reanchor.py +551 -0
- trueppm_api/apps/projects/seed/reldates.py +126 -0
- trueppm_api/apps/projects/seed/replace.py +126 -0
- trueppm_api/apps/projects/seed/replay.py +1691 -0
- trueppm_api/apps/projects/seed/replay_ctx.py +41 -0
- trueppm_api/apps/projects/seed/samples.py +427 -0
- trueppm_api/apps/projects/seed/validation.py +1149 -0
- trueppm_api/apps/projects/serializers.py +12310 -0
- trueppm_api/apps/projects/services.py +6966 -0
- trueppm_api/apps/projects/share_serializers.py +132 -0
- trueppm_api/apps/projects/share_services.py +325 -0
- trueppm_api/apps/projects/share_views.py +374 -0
- trueppm_api/apps/projects/sharing_settings.py +187 -0
- trueppm_api/apps/projects/signal_privacy_services.py +909 -0
- trueppm_api/apps/projects/signal_privacy_views.py +476 -0
- trueppm_api/apps/projects/signals.py +132 -0
- trueppm_api/apps/projects/slip_conflict.py +266 -0
- trueppm_api/apps/projects/sprint_cadence.py +453 -0
- trueppm_api/apps/projects/sprint_picker_settings.py +132 -0
- trueppm_api/apps/projects/standup.py +304 -0
- trueppm_api/apps/projects/standup_views.py +99 -0
- trueppm_api/apps/projects/structural_operation_services.py +727 -0
- trueppm_api/apps/projects/structural_operation_views.py +311 -0
- trueppm_api/apps/projects/surface_visibility.py +126 -0
- trueppm_api/apps/projects/task_batch_services.py +379 -0
- trueppm_api/apps/projects/task_bulk.py +1152 -0
- trueppm_api/apps/projects/task_classification.py +561 -0
- trueppm_api/apps/projects/task_classification_defaults.py +95 -0
- trueppm_api/apps/projects/task_duration_settings.py +142 -0
- trueppm_api/apps/projects/task_grouping.py +754 -0
- trueppm_api/apps/projects/tasks.py +1917 -0
- trueppm_api/apps/projects/template_divergence.py +168 -0
- trueppm_api/apps/projects/template_services.py +160 -0
- trueppm_api/apps/projects/template_tasks.py +258 -0
- trueppm_api/apps/projects/template_views.py +799 -0
- trueppm_api/apps/projects/throttles.py +311 -0
- trueppm_api/apps/projects/urls.py +1011 -0
- trueppm_api/apps/projects/utilization.py +885 -0
- trueppm_api/apps/projects/views.py +20572 -0
- trueppm_api/apps/projects/wbs_paths.py +119 -0
- trueppm_api/apps/resources/__init__.py +0 -0
- trueppm_api/apps/resources/apps.py +8 -0
- trueppm_api/apps/resources/capacity.py +121 -0
- trueppm_api/apps/resources/migrations/0001_initial.py +68 -0
- trueppm_api/apps/resources/migrations/0001_squashed_0013_alter_projectresource_unique_together_and_more.py +281 -0
- trueppm_api/apps/resources/migrations/0002_alter_uuid_pk_serialize_false.py +36 -0
- trueppm_api/apps/resources/migrations/0003_resource_soft_delete.py +30 -0
- trueppm_api/apps/resources/migrations/0004_add_taskresource_resource_index.py +25 -0
- trueppm_api/apps/resources/migrations/0005_resource_job_role.py +20 -0
- trueppm_api/apps/resources/migrations/0006_skill.py +34 -0
- trueppm_api/apps/resources/migrations/0007_resource_skill.py +63 -0
- trueppm_api/apps/resources/migrations/0008_project_resource.py +63 -0
- trueppm_api/apps/resources/migrations/0009_task_skill_requirement.py +60 -0
- trueppm_api/apps/resources/migrations/0010_alter_resourceskill_options_and_more.py +20 -0
- trueppm_api/apps/resources/migrations/0011_alter_projectresource_notes.py +17 -0
- trueppm_api/apps/resources/migrations/0012_add_resource_user_fk.py +26 -0
- trueppm_api/apps/resources/migrations/0013_alter_projectresource_unique_together_and_more.py +53 -0
- trueppm_api/apps/resources/migrations/0014_projectresource_sync_seq_resource_sync_seq_and_more.py +37 -0
- trueppm_api/apps/resources/migrations/0015_projectresource_deactivated_with_resource.py +17 -0
- trueppm_api/apps/resources/migrations/__init__.py +0 -0
- trueppm_api/apps/resources/models.py +298 -0
- trueppm_api/apps/resources/serializers.py +426 -0
- trueppm_api/apps/resources/services.py +465 -0
- trueppm_api/apps/resources/urls.py +28 -0
- trueppm_api/apps/resources/views.py +1749 -0
- trueppm_api/apps/scheduling/__init__.py +0 -0
- trueppm_api/apps/scheduling/apps.py +153 -0
- trueppm_api/apps/scheduling/calendars.py +119 -0
- trueppm_api/apps/scheduling/deadletter.py +91 -0
- trueppm_api/apps/scheduling/forecast_history_settings.py +215 -0
- trueppm_api/apps/scheduling/forecast_staleness.py +313 -0
- trueppm_api/apps/scheduling/graph_guard.py +263 -0
- trueppm_api/apps/scheduling/management/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/__init__.py +0 -0
- trueppm_api/apps/scheduling/management/commands/prune_forecast_snapshots.py +53 -0
- trueppm_api/apps/scheduling/migrations/0001_failed_task.py +62 -0
- trueppm_api/apps/scheduling/migrations/0001_squashed_0007_projectforecastsnapshot.py +309 -0
- trueppm_api/apps/scheduling/migrations/0002_schedulerequest.py +78 -0
- trueppm_api/apps/scheduling/migrations/0003_add_schedule_request_reason.py +27 -0
- trueppm_api/apps/scheduling/migrations/0004_velocity_suggestion.py +94 -0
- trueppm_api/apps/scheduling/migrations/0005_montecarlorun.py +61 -0
- trueppm_api/apps/scheduling/migrations/0006_montecarlorun_distribution.py +17 -0
- trueppm_api/apps/scheduling/migrations/0007_projectforecastsnapshot.py +65 -0
- trueppm_api/apps/scheduling/migrations/0008_alter_schedulerequest_reason.py +28 -0
- trueppm_api/apps/scheduling/migrations/0008_folded_schedulerequest_reason.py +34 -0
- trueppm_api/apps/scheduling/migrations/0009_alter_schedulerequest_reason.py +29 -0
- trueppm_api/apps/scheduling/migrations/0010_failedtask_resolution_note_failedtask_resolved_at_and_more.py +43 -0
- trueppm_api/apps/scheduling/migrations/0011_failedtask_project.py +26 -0
- trueppm_api/apps/scheduling/migrations/0012_montecarlorun_diagnostic.py +17 -0
- trueppm_api/apps/scheduling/migrations/0013_montecarlorun_status_date.py +17 -0
- trueppm_api/apps/scheduling/migrations/0014_montecarlorun_plan_version.py +17 -0
- trueppm_api/apps/scheduling/migrations/__init__.py +0 -0
- trueppm_api/apps/scheduling/models.py +516 -0
- trueppm_api/apps/scheduling/receivers.py +160 -0
- trueppm_api/apps/scheduling/risk_premium.py +283 -0
- trueppm_api/apps/scheduling/serializers.py +564 -0
- trueppm_api/apps/scheduling/services.py +1182 -0
- trueppm_api/apps/scheduling/signals.py +56 -0
- trueppm_api/apps/scheduling/tasks.py +1984 -0
- trueppm_api/apps/scheduling/telemetry.py +80 -0
- trueppm_api/apps/scheduling/units.py +35 -0
- trueppm_api/apps/scheduling/urls.py +57 -0
- trueppm_api/apps/scheduling/views.py +2564 -0
- trueppm_api/apps/sso/__init__.py +0 -0
- trueppm_api/apps/sso/apps.py +19 -0
- trueppm_api/apps/sso/extensions.py +130 -0
- trueppm_api/apps/sso/management/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/__init__.py +0 -0
- trueppm_api/apps/sso/management/commands/seed_sso_keycloak.py +172 -0
- trueppm_api/apps/sso/migrations/0001_initial.py +119 -0
- trueppm_api/apps/sso/migrations/0002_remove_oidcprovider_workspace_ssoproviderpolicy_and_more.py +169 -0
- trueppm_api/apps/sso/migrations/__init__.py +0 -0
- trueppm_api/apps/sso/models.py +148 -0
- trueppm_api/apps/sso/serializers.py +437 -0
- trueppm_api/apps/sso/services.py +1385 -0
- trueppm_api/apps/sso/urls.py +47 -0
- trueppm_api/apps/sso/views.py +935 -0
- trueppm_api/apps/sync/__init__.py +0 -0
- trueppm_api/apps/sync/apps.py +15 -0
- trueppm_api/apps/sync/backfill.py +208 -0
- trueppm_api/apps/sync/broadcast.py +320 -0
- trueppm_api/apps/sync/conflict.py +262 -0
- trueppm_api/apps/sync/consumers.py +471 -0
- trueppm_api/apps/sync/migrations/0001_initial.py +59 -0
- trueppm_api/apps/sync/migrations/0001_squashed_0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +77 -0
- trueppm_api/apps/sync/migrations/0002_remove_syncbatch_syncbatch_project_client_batch_uniq_and_more.py +41 -0
- trueppm_api/apps/sync/migrations/0003_boardevent.py +46 -0
- trueppm_api/apps/sync/migrations/0004_seed_sync_seq.py +24 -0
- trueppm_api/apps/sync/migrations/0005_program_sync_sequence.py +35 -0
- trueppm_api/apps/sync/migrations/__init__.py +0 -0
- trueppm_api/apps/sync/models.py +225 -0
- trueppm_api/apps/sync/pagination.py +231 -0
- trueppm_api/apps/sync/sequence.py +371 -0
- trueppm_api/apps/sync/serializers.py +687 -0
- trueppm_api/apps/sync/tasks.py +260 -0
- trueppm_api/apps/sync/throttles.py +175 -0
- trueppm_api/apps/sync/upload.py +501 -0
- trueppm_api/apps/sync/urls.py +17 -0
- trueppm_api/apps/sync/views.py +930 -0
- trueppm_api/apps/sync/ws_auth.py +193 -0
- trueppm_api/apps/taskruns/__init__.py +0 -0
- trueppm_api/apps/taskruns/apps.py +11 -0
- trueppm_api/apps/taskruns/migrations/0001_initial.py +92 -0
- trueppm_api/apps/taskruns/migrations/__init__.py +0 -0
- trueppm_api/apps/taskruns/models.py +68 -0
- trueppm_api/apps/taskruns/serializers.py +92 -0
- trueppm_api/apps/taskruns/tasks.py +60 -0
- trueppm_api/apps/taskruns/tracker.py +286 -0
- trueppm_api/apps/taskruns/urls.py +46 -0
- trueppm_api/apps/taskruns/views.py +195 -0
- trueppm_api/apps/teams/__init__.py +8 -0
- trueppm_api/apps/teams/apps.py +16 -0
- trueppm_api/apps/teams/migrations/0001_initial.py +139 -0
- trueppm_api/apps/teams/migrations/0001_squashed_0002_default_teams.py +142 -0
- trueppm_api/apps/teams/migrations/0002_default_teams.py +62 -0
- trueppm_api/apps/teams/migrations/0003_team_sync_seq_teammembership_sync_seq.py +22 -0
- trueppm_api/apps/teams/migrations/__init__.py +0 -0
- trueppm_api/apps/teams/models.py +145 -0
- trueppm_api/apps/teams/permissions.py +149 -0
- trueppm_api/apps/teams/serializers.py +95 -0
- trueppm_api/apps/teams/services.py +434 -0
- trueppm_api/apps/teams/signals.py +61 -0
- trueppm_api/apps/teams/urls.py +34 -0
- trueppm_api/apps/teams/views.py +201 -0
- trueppm_api/apps/timetracking/__init__.py +0 -0
- trueppm_api/apps/timetracking/apps.py +9 -0
- trueppm_api/apps/timetracking/migrations/0001_initial.py +117 -0
- trueppm_api/apps/timetracking/migrations/0002_timesheetsubmission.py +46 -0
- trueppm_api/apps/timetracking/migrations/0003_timeentry_deleted_at_timeentry_deleted_by.py +31 -0
- trueppm_api/apps/timetracking/migrations/0004_timeentry_sync_seq_and_more.py +24 -0
- trueppm_api/apps/timetracking/migrations/__init__.py +0 -0
- trueppm_api/apps/timetracking/models.py +178 -0
- trueppm_api/apps/timetracking/serializers.py +175 -0
- trueppm_api/apps/timetracking/services.py +117 -0
- trueppm_api/apps/timetracking/urls.py +47 -0
- trueppm_api/apps/timetracking/views.py +526 -0
- trueppm_api/apps/webhooks/__init__.py +0 -0
- trueppm_api/apps/webhooks/apps.py +8 -0
- trueppm_api/apps/webhooks/backfill.py +90 -0
- trueppm_api/apps/webhooks/dispatch.py +144 -0
- trueppm_api/apps/webhooks/migrations/0001_initial.py +146 -0
- trueppm_api/apps/webhooks/migrations/0001_squashed_0007_sprint_lifecycle_events.py +243 -0
- trueppm_api/apps/webhooks/migrations/0002_alter_webhook_events.py +32 -0
- trueppm_api/apps/webhooks/migrations/0003_delivery_status_created_idx.py +17 -0
- trueppm_api/apps/webhooks/migrations/0004_webhook_program_alter_webhook_project_and_more.py +64 -0
- trueppm_api/apps/webhooks/migrations/0005_webhook_delivery_sequence_numbers.py +30 -0
- trueppm_api/apps/webhooks/migrations/0006_webhook_format_alter_webhook_events_and_more.py +61 -0
- trueppm_api/apps/webhooks/migrations/0007_sprint_lifecycle_events.py +63 -0
- trueppm_api/apps/webhooks/migrations/0008_risk_baseline_comment_events.py +73 -0
- trueppm_api/apps/webhooks/migrations/0009_encrypt_webhook_secret_and_failure_counters.py +90 -0
- trueppm_api/apps/webhooks/migrations/__init__.py +0 -0
- trueppm_api/apps/webhooks/models.py +372 -0
- trueppm_api/apps/webhooks/serializers.py +318 -0
- trueppm_api/apps/webhooks/tasks.py +663 -0
- trueppm_api/apps/webhooks/urls.py +22 -0
- trueppm_api/apps/webhooks/views.py +383 -0
- trueppm_api/apps/workflow_engine/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/apps.py +23 -0
- trueppm_api/apps/workflow_engine/migrations/0001_initial.py +230 -0
- trueppm_api/apps/workflow_engine/migrations/0001_squashed_0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +234 -0
- trueppm_api/apps/workflow_engine/migrations/0002_remove_workflowhistoryevent_workflow_history_lookup_idx.py +16 -0
- trueppm_api/apps/workflow_engine/migrations/0003_workflowoutboxrow_workflow_outbox_recovery_idx.py +21 -0
- trueppm_api/apps/workflow_engine/migrations/__init__.py +0 -0
- trueppm_api/apps/workflow_engine/models.py +265 -0
- trueppm_api/apps/workflow_engine/tasks.py +368 -0
- trueppm_api/apps/workspace/__init__.py +0 -0
- trueppm_api/apps/workspace/apps.py +10 -0
- trueppm_api/apps/workspace/backfill.py +111 -0
- trueppm_api/apps/workspace/export.py +242 -0
- trueppm_api/apps/workspace/migrations/0001_initial.py +326 -0
- trueppm_api/apps/workspace/migrations/0001_squashed_0014_workspacemembership_availability_effective_from_and_more.py +829 -0
- trueppm_api/apps/workspace/migrations/0002_remove_workspace_fiscal_year_start_and_more.py +85 -0
- trueppm_api/apps/workspace/migrations/0003_workspaceexportjob.py +73 -0
- trueppm_api/apps/workspace/migrations/0004_alter_workspaceinvite_status.py +27 -0
- trueppm_api/apps/workspace/migrations/0005_workspace_iteration_label_and_more.py +26 -0
- trueppm_api/apps/workspace/migrations/0006_alter_groupmembership_unique_together_and_more.py +45 -0
- trueppm_api/apps/workspace/migrations/0007_workspace_public_sharing_override_policy.py +21 -0
- trueppm_api/apps/workspace/migrations/0008_workspace_mc_history_attribution_audience_and_more.py +44 -0
- trueppm_api/apps/workspace/migrations/0009_workspace_methodology_and_more.py +186 -0
- trueppm_api/apps/workspace/migrations/0010_workspace_logo.py +38 -0
- trueppm_api/apps/workspace/migrations/0011_historicalworkspace_task_duration_change_percent_override_policy_and_more.py +56 -0
- trueppm_api/apps/workspace/migrations/0012_historicalworkspace_allowed_attachment_types_and_more.py +64 -0
- trueppm_api/apps/workspace/migrations/0013_auditevent.py +70 -0
- trueppm_api/apps/workspace/migrations/0014_workspacemembership_availability_effective_from_and_more.py +39 -0
- trueppm_api/apps/workspace/migrations/0015_trash_restore_1113.py +34 -0
- trueppm_api/apps/workspace/migrations/0016_historicalworkspace_calendar_and_more.py +55 -0
- trueppm_api/apps/workspace/migrations/0017_historicalworkspace_estimation_scale_and_more.py +38 -0
- trueppm_api/apps/workspace/migrations/0018_historicalworkspace_feedback_enabled_and_more.py +32 -0
- trueppm_api/apps/workspace/migrations/0019_historicalworkspace_mcp_enabled_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0020_viewer_ordinal_one.py +34 -0
- trueppm_api/apps/workspace/migrations/0021_group_sync_seq_groupmembership_sync_seq_and_more.py +27 -0
- trueppm_api/apps/workspace/migrations/0022_historicalworkspace_sprint_picker_ready_only_default_and_more.py +22 -0
- trueppm_api/apps/workspace/migrations/0023_alter_auditevent_event_type.py +32 -0
- trueppm_api/apps/workspace/migrations/0024_alter_auditevent_event_type.py +33 -0
- trueppm_api/apps/workspace/migrations/0025_alter_auditevent_event_type.py +36 -0
- trueppm_api/apps/workspace/migrations/0026_alter_auditevent_event_type.py +37 -0
- trueppm_api/apps/workspace/migrations/0027_alter_auditevent_event_type.py +42 -0
- trueppm_api/apps/workspace/migrations/__init__.py +0 -0
- trueppm_api/apps/workspace/models.py +857 -0
- trueppm_api/apps/workspace/permissions.py +187 -0
- trueppm_api/apps/workspace/serializers.py +590 -0
- trueppm_api/apps/workspace/services.py +919 -0
- trueppm_api/apps/workspace/signals.py +37 -0
- trueppm_api/apps/workspace/tasks.py +555 -0
- trueppm_api/apps/workspace/urls.py +113 -0
- trueppm_api/apps/workspace/views.py +1502 -0
- trueppm_api/asgi.py +42 -0
- trueppm_api/celery.py +31 -0
- trueppm_api/core/__init__.py +0 -0
- trueppm_api/core/admin_site.py +206 -0
- trueppm_api/core/auth_views.py +916 -0
- trueppm_api/core/constant_time.py +51 -0
- trueppm_api/core/csp.py +54 -0
- trueppm_api/core/db.py +87 -0
- trueppm_api/core/db_session.py +37 -0
- trueppm_api/core/exception_handlers.py +157 -0
- trueppm_api/core/export_downloads.py +56 -0
- trueppm_api/core/extension_providers.py +56 -0
- trueppm_api/core/extension_signals.py +147 -0
- trueppm_api/core/idempotent.py +260 -0
- trueppm_api/core/metadata.py +42 -0
- trueppm_api/core/middleware.py +51 -0
- trueppm_api/core/openapi.py +949 -0
- trueppm_api/core/password_reset.py +500 -0
- trueppm_api/core/protect_conflict.py +100 -0
- trueppm_api/core/ratelimit.py +148 -0
- trueppm_api/core/redis_throttle.py +82 -0
- trueppm_api/core/refresh_cookie_policy.py +156 -0
- trueppm_api/core/request_body.py +86 -0
- trueppm_api/core/security_checks.py +884 -0
- trueppm_api/core/storage_config.py +105 -0
- trueppm_api/core/text_decode.py +168 -0
- trueppm_api/core/throttling.py +344 -0
- trueppm_api/core/valkey.py +214 -0
- trueppm_api/core/valkey_cache.py +66 -0
- trueppm_api/core/valkey_checks.py +104 -0
- trueppm_api/core/valkey_config.py +39 -0
- trueppm_api/core/worker_broker_wait.py +149 -0
- trueppm_api/core/worker_heartbeat.py +134 -0
- trueppm_api/fields.py +36 -0
- trueppm_api/permissions.py +30 -0
- trueppm_api/py.typed +0 -0
- trueppm_api/routing.py +11 -0
- trueppm_api/settings/__init__.py +0 -0
- trueppm_api/settings/base.py +2455 -0
- trueppm_api/settings/dev.py +273 -0
- trueppm_api/settings/prod.py +228 -0
- trueppm_api/urls.py +153 -0
- trueppm_api/workflows/__init__.py +38 -0
- trueppm_api/workflows/backends/__init__.py +6 -0
- trueppm_api/workflows/backends/default.py +297 -0
- trueppm_api/workflows/consumers/__init__.py +11 -0
- trueppm_api/workflows/consumers/requeue_failed_task.py +65 -0
- trueppm_api/workflows/interface.py +148 -0
- trueppm_api/workflows/registry.py +106 -0
- trueppm_api/workflows/services.py +90 -0
- trueppm_api/wsgi.py +11 -0
- trueppm_api-0.4.0b2.dist-info/METADATA +70 -0
- trueppm_api-0.4.0b2.dist-info/RECORD +759 -0
- trueppm_api-0.4.0b2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""ViewSets for the per-user notification inbox + preferences (ADR-0075)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
from django.conf import settings
|
|
11
|
+
from django.db import transaction
|
|
12
|
+
from django.db.models import Q
|
|
13
|
+
from django.shortcuts import get_object_or_404
|
|
14
|
+
from django.utils import timezone
|
|
15
|
+
from django.utils.dateparse import parse_datetime
|
|
16
|
+
from drf_spectacular.types import OpenApiTypes
|
|
17
|
+
from drf_spectacular.utils import (
|
|
18
|
+
OpenApiParameter,
|
|
19
|
+
OpenApiResponse,
|
|
20
|
+
extend_schema,
|
|
21
|
+
extend_schema_view,
|
|
22
|
+
inline_serializer,
|
|
23
|
+
)
|
|
24
|
+
from rest_framework import mixins, serializers, status, viewsets
|
|
25
|
+
from rest_framework.decorators import action
|
|
26
|
+
from rest_framework.exceptions import PermissionDenied
|
|
27
|
+
from rest_framework.permissions import BasePermission, IsAuthenticated
|
|
28
|
+
from rest_framework.response import Response
|
|
29
|
+
from rest_framework.throttling import ScopedRateThrottle
|
|
30
|
+
from rest_framework.views import APIView
|
|
31
|
+
|
|
32
|
+
from trueppm_api.apps.access.permissions import (
|
|
33
|
+
IsProjectMember,
|
|
34
|
+
IsWorkspaceOperator,
|
|
35
|
+
)
|
|
36
|
+
from trueppm_api.apps.idempotency.mixins import IdempotencyMixin
|
|
37
|
+
from trueppm_api.apps.projects.models import Project
|
|
38
|
+
from trueppm_api.apps.workspace.permissions import IsWorkspaceAdminStrict
|
|
39
|
+
|
|
40
|
+
from .categories import CATEGORIES, CATEGORY_MENTIONS, event_types_for_category
|
|
41
|
+
from .models import (
|
|
42
|
+
DEFAULT_PREFERENCES,
|
|
43
|
+
PROJECT_NOTIFICATION_DEFAULT_MATRIX,
|
|
44
|
+
SIGNAL_ONLY_EVENTS,
|
|
45
|
+
EmailTransportMode,
|
|
46
|
+
Notification,
|
|
47
|
+
NotificationChannel,
|
|
48
|
+
NotificationPreference,
|
|
49
|
+
ProjectNotificationChannel,
|
|
50
|
+
ProjectNotificationEventType,
|
|
51
|
+
ProjectNotificationPreference,
|
|
52
|
+
WorkspaceEmailSettings,
|
|
53
|
+
project_notification_channel_delivery,
|
|
54
|
+
project_notification_event_delivery,
|
|
55
|
+
)
|
|
56
|
+
from .serializers import (
|
|
57
|
+
NotificationPreferenceSerializer,
|
|
58
|
+
NotificationSerializer,
|
|
59
|
+
ProjectNotificationPreferenceDocumentSerializer,
|
|
60
|
+
ProjectNotificationPreferenceSerializer,
|
|
61
|
+
UserNotificationSettingsSerializer,
|
|
62
|
+
WorkspaceEmailSettingsSerializer,
|
|
63
|
+
)
|
|
64
|
+
from .services import get_or_create_default_preferences, get_or_create_notification_settings
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from django.db.models import QuerySet
|
|
70
|
+
from rest_framework.request import Request
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Snooze presets (ADR-0216 §1). The client may send a preset key OR an explicit
|
|
74
|
+
# `until` ISO datetime; presets are resolved server-side so the "tomorrow 9am"
|
|
75
|
+
# anchor stays consistent and testable.
|
|
76
|
+
SNOOZE_PRESETS: tuple[str, ...] = ("1h", "3h", "tomorrow")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_snooze_preset(preset: str, now: datetime.datetime) -> datetime.datetime | None:
|
|
80
|
+
"""Resolve a snooze preset key to an absolute datetime, or None if unknown.
|
|
81
|
+
|
|
82
|
+
``1h`` / ``3h`` are relative to ``now``; ``tomorrow`` is the next day at 09:00
|
|
83
|
+
in the **server** timezone (``settings.TIME_ZONE``), returned as an aware UTC
|
|
84
|
+
datetime.
|
|
85
|
+
|
|
86
|
+
Deliberately *not* the admin-set ``Workspace.timezone`` and *not* the snoozing
|
|
87
|
+
user's ``profiles.Profile.timezone``. The profile value defaults to ``"auto"``
|
|
88
|
+
(resolved in the browser) and is scoped display-only by ADR-0410, so it is not
|
|
89
|
+
a server-resolvable anchor; widening this preset to either is a behavior change
|
|
90
|
+
with its own UX question, not a wording fix. ``Workspace.timezone`` has exactly
|
|
91
|
+
one reader — ``services._project_timezone``, the quiet-hours anchor (#3377).
|
|
92
|
+
"""
|
|
93
|
+
if preset == "1h":
|
|
94
|
+
return now + datetime.timedelta(hours=1)
|
|
95
|
+
if preset == "3h":
|
|
96
|
+
return now + datetime.timedelta(hours=3)
|
|
97
|
+
if preset == "tomorrow":
|
|
98
|
+
try:
|
|
99
|
+
tz = datetime.UTC if not settings.TIME_ZONE else ZoneInfo(settings.TIME_ZONE)
|
|
100
|
+
except Exception:
|
|
101
|
+
tz = datetime.UTC
|
|
102
|
+
local_tomorrow = (now.astimezone(tz) + datetime.timedelta(days=1)).replace(
|
|
103
|
+
hour=9, minute=0, second=0, microsecond=0
|
|
104
|
+
)
|
|
105
|
+
return local_tomorrow.astimezone(datetime.UTC)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_snooze_until(
|
|
110
|
+
data: Any, now: datetime.datetime
|
|
111
|
+
) -> tuple[datetime.datetime | None, Response | None]:
|
|
112
|
+
"""Resolve the target ``snoozed_until`` from a snooze request body.
|
|
113
|
+
|
|
114
|
+
Returns ``(until, None)`` on success — ``until`` is ``None`` for an explicit
|
|
115
|
+
un-snooze — or ``(None, error_response)`` with a ready-to-return 400 when the
|
|
116
|
+
body is malformed. Extracted from :meth:`NotificationViewSet.snooze` so the
|
|
117
|
+
action body stays a flat resolve-then-persist sequence; the preset/until/error
|
|
118
|
+
branching lives here.
|
|
119
|
+
"""
|
|
120
|
+
preset = data.get("preset")
|
|
121
|
+
if preset:
|
|
122
|
+
until = _resolve_snooze_preset(preset, now)
|
|
123
|
+
if until is None:
|
|
124
|
+
return None, Response(
|
|
125
|
+
{"detail": f"preset must be one of {list(SNOOZE_PRESETS)}."},
|
|
126
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
127
|
+
)
|
|
128
|
+
return until, None
|
|
129
|
+
if "until" in data:
|
|
130
|
+
raw = data.get("until")
|
|
131
|
+
if raw in (None, ""):
|
|
132
|
+
return None, None # explicit un-snooze
|
|
133
|
+
parsed = parse_datetime(raw) if isinstance(raw, str) else None
|
|
134
|
+
if parsed is None:
|
|
135
|
+
return None, Response(
|
|
136
|
+
{"detail": "until must be an ISO 8601 datetime or null."},
|
|
137
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
138
|
+
)
|
|
139
|
+
# Anchor a naive timestamp to the current timezone rather than letting
|
|
140
|
+
# Django emit a naive-datetime warning / compare wrong.
|
|
141
|
+
if timezone.is_naive(parsed):
|
|
142
|
+
parsed = timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
143
|
+
return parsed, None
|
|
144
|
+
return None, Response(
|
|
145
|
+
{"detail": "Provide a 'preset' or an 'until' datetime (null to un-snooze)."},
|
|
146
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@extend_schema_view(
|
|
151
|
+
list=extend_schema(
|
|
152
|
+
parameters=[
|
|
153
|
+
OpenApiParameter(
|
|
154
|
+
name="unread_only",
|
|
155
|
+
type=OpenApiTypes.BOOL,
|
|
156
|
+
location=OpenApiParameter.QUERY,
|
|
157
|
+
required=False,
|
|
158
|
+
description=(
|
|
159
|
+
"When `true` (or `1`), return only unread, non-archived "
|
|
160
|
+
"notifications. Mutually exclusive with `archived`."
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
OpenApiParameter(
|
|
164
|
+
name="archived",
|
|
165
|
+
type=OpenApiTypes.BOOL,
|
|
166
|
+
location=OpenApiParameter.QUERY,
|
|
167
|
+
required=False,
|
|
168
|
+
description=(
|
|
169
|
+
"When `true` (or `1`), return only archived notifications. "
|
|
170
|
+
"The default list (neither param set) excludes archived rows."
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
OpenApiParameter(
|
|
174
|
+
name="snoozed",
|
|
175
|
+
type=OpenApiTypes.BOOL,
|
|
176
|
+
location=OpenApiParameter.QUERY,
|
|
177
|
+
required=False,
|
|
178
|
+
description=(
|
|
179
|
+
"When `true` (or `1`), return only currently-snoozed rows "
|
|
180
|
+
"(`snoozed_until` in the future). Every other view EXCLUDES "
|
|
181
|
+
"currently-snoozed rows — including the unread-count query — "
|
|
182
|
+
"so a deferred notification does not increment the bell badge."
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
OpenApiParameter(
|
|
186
|
+
name="category",
|
|
187
|
+
type=OpenApiTypes.STR,
|
|
188
|
+
location=OpenApiParameter.QUERY,
|
|
189
|
+
required=False,
|
|
190
|
+
enum=list(CATEGORIES),
|
|
191
|
+
description=(
|
|
192
|
+
"Filter to one derived category: `mentions`, `tasks`, "
|
|
193
|
+
"`signals`, or `project`. Orthogonal to the read-state "
|
|
194
|
+
"filters. `mentions` also matches mention-sourced rows."
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
],
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
class NotificationViewSet(
|
|
201
|
+
IdempotencyMixin,
|
|
202
|
+
mixins.ListModelMixin,
|
|
203
|
+
mixins.RetrieveModelMixin,
|
|
204
|
+
mixins.UpdateModelMixin,
|
|
205
|
+
viewsets.GenericViewSet[Notification],
|
|
206
|
+
):
|
|
207
|
+
"""Per-user notification inbox.
|
|
208
|
+
|
|
209
|
+
GET /me/notifications/ ?unread_only=true&archived=true&snoozed=true&category=tasks
|
|
210
|
+
GET /me/notifications/{id}/
|
|
211
|
+
PATCH /me/notifications/{id}/ { is_read: bool, is_archived: bool }
|
|
212
|
+
POST /me/notifications/{id}/snooze/ { until: iso } | { preset: 1h|3h|tomorrow }
|
|
213
|
+
POST /me/notifications/mark-all-read/
|
|
214
|
+
|
|
215
|
+
All reads/writes are auto-scoped to the authenticated user — no cross-user
|
|
216
|
+
access possible by construction (queryset filters by recipient=request.user).
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
serializer_class = NotificationSerializer
|
|
220
|
+
permission_classes: list[type[BasePermission]] = [IsAuthenticated]
|
|
221
|
+
lookup_field = "pk"
|
|
222
|
+
# Opted out of Idempotency-Key replay (#3510, security review). An
|
|
223
|
+
# ``IdempotencyMixin`` replay returns the ORIGINAL response body verbatim
|
|
224
|
+
# (IdempotencyKey.response_body), bypassing ``NotificationSerializer.
|
|
225
|
+
# to_representation`` — and with it, the #3510 subject/body/project
|
|
226
|
+
# redaction — entirely. A member could PATCH or snooze a notification
|
|
227
|
+
# while still current (capturing the unredacted row under a self-chosen
|
|
228
|
+
# key), then replay that same key after being removed from the project to
|
|
229
|
+
# read the cached, unredacted content back out — a complete, self-service
|
|
230
|
+
# defeat of the read-time redaction this viewset exists to enforce. Both
|
|
231
|
+
# mutations this viewset exposes are already naturally idempotent at the
|
|
232
|
+
# application level without the header (PATCH is_read/is_archived is a
|
|
233
|
+
# no-op on repeat, and snooze is explicitly documented as idempotent — it
|
|
234
|
+
# just overwrites the timestamp), so the header buys no real double-submit
|
|
235
|
+
# protection here to weigh against that risk.
|
|
236
|
+
idempotency_exempt = True
|
|
237
|
+
|
|
238
|
+
def get_serializer_context(self) -> dict[str, Any]:
|
|
239
|
+
"""Precompute the recipient's member-project set once per response.
|
|
240
|
+
|
|
241
|
+
Backs ``NotificationSerializer._recipient_can_see_project``, which redacts
|
|
242
|
+
``snippet`` (#514 — program mentions can reach sibling-project members),
|
|
243
|
+
``subject``/``body``/``project`` (#3510), and the two raw object
|
|
244
|
+
references ``task_id`` / ``mention.task_comment`` (#3674), for a
|
|
245
|
+
recipient who is not a current member of the notification's project —
|
|
246
|
+
including one who has since been removed from it. Resolving the
|
|
247
|
+
membership set here keeps the inbox list at one query instead of one per
|
|
248
|
+
row: every one of those fields reads the same memoized set, so widening
|
|
249
|
+
the redaction added no per-row membership query.
|
|
250
|
+
"""
|
|
251
|
+
context: dict[str, Any] = dict(super().get_serializer_context())
|
|
252
|
+
user = self.request.user
|
|
253
|
+
if user.is_authenticated:
|
|
254
|
+
from trueppm_api.apps.access.models import ProjectMembership
|
|
255
|
+
|
|
256
|
+
context["member_project_ids"] = set(
|
|
257
|
+
ProjectMembership.objects.filter(user=user, is_deleted=False).values_list(
|
|
258
|
+
"project_id", flat=True
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
return context
|
|
262
|
+
|
|
263
|
+
def get_queryset(self) -> QuerySet[Notification]:
|
|
264
|
+
user = self.request.user
|
|
265
|
+
if not user.is_authenticated:
|
|
266
|
+
return Notification.objects.none()
|
|
267
|
+
qs = Notification.objects.filter(recipient=user).select_related(
|
|
268
|
+
"mention",
|
|
269
|
+
"mention__task_comment",
|
|
270
|
+
"mention__mentioner",
|
|
271
|
+
"mention__mentioned_user",
|
|
272
|
+
"project",
|
|
273
|
+
)
|
|
274
|
+
params = self.request.query_params
|
|
275
|
+
now = timezone.now()
|
|
276
|
+
snoozed_only = params.get("snoozed", "").lower() in ("1", "true")
|
|
277
|
+
unread_only = params.get("unread_only", "").lower() in ("1", "true")
|
|
278
|
+
archived_only = params.get("archived", "").lower() in ("1", "true")
|
|
279
|
+
|
|
280
|
+
if snoozed_only:
|
|
281
|
+
# The "Snoozed" chip: rows deferred to the future, still active.
|
|
282
|
+
qs = qs.filter(snoozed_until__gt=now, is_archived=False)
|
|
283
|
+
else:
|
|
284
|
+
# Every non-snoozed view hides rows still inside their snooze window;
|
|
285
|
+
# they reappear automatically once snoozed_until passes (pure query-
|
|
286
|
+
# time filter, ADR-0216 §1). Applied BEFORE the read-state branch so
|
|
287
|
+
# the unread-count path (unread_only=true) also excludes snoozed rows
|
|
288
|
+
# — otherwise a deferred notification would still light the bell badge.
|
|
289
|
+
qs = qs.filter(Q(snoozed_until__isnull=True) | Q(snoozed_until__lte=now))
|
|
290
|
+
if unread_only:
|
|
291
|
+
qs = qs.filter(is_read=False, is_archived=False)
|
|
292
|
+
elif archived_only:
|
|
293
|
+
qs = qs.filter(is_archived=True)
|
|
294
|
+
else:
|
|
295
|
+
# Default list excludes archived
|
|
296
|
+
qs = qs.filter(is_archived=False)
|
|
297
|
+
|
|
298
|
+
# Derived category filter (ADR-0216 §3). The mapping lives in
|
|
299
|
+
# categories.py so this filter and the serializer's category field share
|
|
300
|
+
# one source of truth. `mentions` additionally matches mention-sourced
|
|
301
|
+
# rows (blank event_type + mention FK). An unknown category maps to an
|
|
302
|
+
# empty event-type set → matches nothing.
|
|
303
|
+
category = params.get("category", "").strip().lower()
|
|
304
|
+
if category:
|
|
305
|
+
event_types = event_types_for_category(category)
|
|
306
|
+
category_q = Q(event_type__in=event_types)
|
|
307
|
+
if category == CATEGORY_MENTIONS:
|
|
308
|
+
category_q |= Q(mention__isnull=False)
|
|
309
|
+
qs = qs.filter(category_q)
|
|
310
|
+
|
|
311
|
+
return qs.order_by("-created_at")
|
|
312
|
+
|
|
313
|
+
@extend_schema(
|
|
314
|
+
request={
|
|
315
|
+
"application/json": {
|
|
316
|
+
"type": "object",
|
|
317
|
+
"properties": {
|
|
318
|
+
"preset": {"type": "string", "enum": list(SNOOZE_PRESETS)},
|
|
319
|
+
"until": {
|
|
320
|
+
"type": "string",
|
|
321
|
+
"format": "date-time",
|
|
322
|
+
"nullable": True,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
responses=NotificationSerializer,
|
|
328
|
+
)
|
|
329
|
+
@action(detail=True, methods=["post"], url_path="snooze")
|
|
330
|
+
def snooze(self, request: Request, pk: str | None = None) -> Response:
|
|
331
|
+
"""Snooze (or un-snooze) a single notification (ADR-0216 §1).
|
|
332
|
+
|
|
333
|
+
Body accepts EITHER ``{"preset": "1h"|"3h"|"tomorrow"}`` OR
|
|
334
|
+
``{"until": "<iso datetime>"}``. Pass ``{"until": null}`` (or an empty
|
|
335
|
+
body) to un-snooze — clearing ``snoozed_until`` returns the row to the
|
|
336
|
+
inbox immediately. Idempotent: re-snoozing overwrites the timestamp.
|
|
337
|
+
|
|
338
|
+
Looks the row up recipient-scoped directly (not via the filtered
|
|
339
|
+
get_queryset) so a currently-snoozed row can still be re-snoozed or
|
|
340
|
+
un-snoozed — the default queryset hides snoozed rows.
|
|
341
|
+
"""
|
|
342
|
+
user = request.user
|
|
343
|
+
notification = get_object_or_404(Notification, pk=pk, recipient=user)
|
|
344
|
+
now = timezone.now()
|
|
345
|
+
|
|
346
|
+
until, error = _resolve_snooze_until(request.data, now)
|
|
347
|
+
if error is not None:
|
|
348
|
+
return error
|
|
349
|
+
|
|
350
|
+
notification.snoozed_until = until
|
|
351
|
+
notification.save(update_fields=["snoozed_until"])
|
|
352
|
+
serializer = self.get_serializer(notification)
|
|
353
|
+
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
354
|
+
|
|
355
|
+
def perform_update(self, serializer: Any) -> None:
|
|
356
|
+
instance: Notification = serializer.instance
|
|
357
|
+
was_read = instance.is_read
|
|
358
|
+
updated = serializer.save()
|
|
359
|
+
# Stamp read_at on transition unread → read; never reset to None.
|
|
360
|
+
if updated.is_read and not was_read:
|
|
361
|
+
updated.read_at = timezone.now()
|
|
362
|
+
updated.save(update_fields=["read_at"])
|
|
363
|
+
|
|
364
|
+
@extend_schema(
|
|
365
|
+
summary="Mark all unread notifications read",
|
|
366
|
+
# Without this, drf-spectacular infers the viewset's NotificationSerializer;
|
|
367
|
+
# the action returns a small ``{updated: N}`` counter instead (#2127).
|
|
368
|
+
responses={
|
|
369
|
+
200: inline_serializer(
|
|
370
|
+
name="MarkAllReadResult",
|
|
371
|
+
fields={"updated": serializers.IntegerField()},
|
|
372
|
+
)
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
@action(detail=False, methods=["post"], url_path="mark-all-read")
|
|
376
|
+
def mark_all_read(self, request: Request) -> Response:
|
|
377
|
+
"""Mark every unread notification for this user as read in one update."""
|
|
378
|
+
user = request.user
|
|
379
|
+
if not user.is_authenticated:
|
|
380
|
+
return Response({"updated": 0}, status=status.HTTP_200_OK)
|
|
381
|
+
now = timezone.now()
|
|
382
|
+
updated = Notification.objects.filter(
|
|
383
|
+
recipient=user, is_read=False, is_archived=False
|
|
384
|
+
).update(is_read=True, read_at=now)
|
|
385
|
+
return Response({"updated": updated}, status=status.HTTP_200_OK)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class NotificationPreferenceViewSet(
|
|
389
|
+
IdempotencyMixin,
|
|
390
|
+
mixins.ListModelMixin,
|
|
391
|
+
mixins.UpdateModelMixin,
|
|
392
|
+
viewsets.GenericViewSet[NotificationPreference],
|
|
393
|
+
):
|
|
394
|
+
"""Per-user (event_type, channel) toggle matrix.
|
|
395
|
+
|
|
396
|
+
GET /me/notification-preferences/
|
|
397
|
+
PATCH /me/notification-preferences/{id}/ { enabled: bool }
|
|
398
|
+
|
|
399
|
+
First GET backfills DEFAULT_PREFERENCES (Priya's email-OFF flip from V2 VoC)
|
|
400
|
+
if the user has no rows yet — lazy initialization so user-create doesn't
|
|
401
|
+
have to know about this table.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
serializer_class = NotificationPreferenceSerializer
|
|
405
|
+
permission_classes: list[type[BasePermission]] = [IsAuthenticated]
|
|
406
|
+
|
|
407
|
+
def get_queryset(self) -> QuerySet[NotificationPreference]:
|
|
408
|
+
user = self.request.user
|
|
409
|
+
if not user.is_authenticated:
|
|
410
|
+
return NotificationPreference.objects.none()
|
|
411
|
+
return NotificationPreference.objects.filter(user=user).order_by("event_type", "channel")
|
|
412
|
+
|
|
413
|
+
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
|
414
|
+
if request.user.is_authenticated:
|
|
415
|
+
get_or_create_default_preferences(request.user)
|
|
416
|
+
return super().list(request, *args, **kwargs)
|
|
417
|
+
|
|
418
|
+
def perform_update(self, serializer: Any) -> None:
|
|
419
|
+
"""Disallow channel / event_type / user changes — only enabled toggle.
|
|
420
|
+
|
|
421
|
+
In practice unreachable (get_queryset already restricts to user=request.user
|
|
422
|
+
so cross-user PATCH 404s), but the guard is defense-in-depth.
|
|
423
|
+
"""
|
|
424
|
+
instance: NotificationPreference = serializer.instance
|
|
425
|
+
if instance.user_id != self.request.user.pk:
|
|
426
|
+
# DRF's PermissionDenied → 403; bare PermissionError raises 500.
|
|
427
|
+
raise PermissionDenied("Cannot modify another user's preferences.")
|
|
428
|
+
serializer.save()
|
|
429
|
+
|
|
430
|
+
@extend_schema(
|
|
431
|
+
summary="Apply a notification preference preset",
|
|
432
|
+
# Every field of the inferred contract was wrong (#2840). Without this,
|
|
433
|
+
# drf-spectacular fell back to the viewset's NotificationPreferenceSerializer
|
|
434
|
+
# for BOTH sides: it declared a request of {event_type, channel, enabled}
|
|
435
|
+
# (the body is {"preset": …}) and a single-object 200 (the handler returns
|
|
436
|
+
# the whole matrix, many=True — a JSON array).
|
|
437
|
+
request=inline_serializer(
|
|
438
|
+
name="NotificationPreferencePresetRequest",
|
|
439
|
+
fields={"preset": serializers.ChoiceField(choices=["signal_only", "everything"])},
|
|
440
|
+
),
|
|
441
|
+
responses={200: NotificationPreferenceSerializer(many=True)},
|
|
442
|
+
)
|
|
443
|
+
# The bare-array response also needs the view's paginator cleared, or
|
|
444
|
+
# drf-spectacular re-wraps this many=True declaration into the list route's
|
|
445
|
+
# envelope. That is done at the as_view() call in urls.py — this app routes by
|
|
446
|
+
# hand, so @action initkwargs are never applied. See the note there.
|
|
447
|
+
@action(detail=False, methods=["post"], url_path="apply-preset")
|
|
448
|
+
def apply_preset(self, request: Request) -> Response:
|
|
449
|
+
"""Apply a wholesale preference preset (#855) and return the new matrix.
|
|
450
|
+
|
|
451
|
+
Body: ``{"preset": "signal_only" | "everything"}``.
|
|
452
|
+
|
|
453
|
+
- ``signal_only`` — the contributor profile: in-app ON for the two
|
|
454
|
+
attention-worthy events (task.blocked, task.due_date_changed), every
|
|
455
|
+
other (event, channel) row OFF. This is Priya's escape from a noisy
|
|
456
|
+
default without auditing the full grid cell-by-cell.
|
|
457
|
+
- ``everything`` — restore the enabled state from DEFAULT_PREFERENCES.
|
|
458
|
+
|
|
459
|
+
Implemented as a bulk write over the existing per-(event, channel) rows
|
|
460
|
+
rather than a new "profile" model, so the matrix stays the single source
|
|
461
|
+
of truth and the data-driven settings page (ADR-0085) needs no special
|
|
462
|
+
casing. Scoped to ``request.user`` by construction.
|
|
463
|
+
"""
|
|
464
|
+
user = request.user
|
|
465
|
+
if not user.is_authenticated:
|
|
466
|
+
raise PermissionDenied("Authentication required.")
|
|
467
|
+
# Guard the top-level body shape: a non-object JSON body (list/str/scalar)
|
|
468
|
+
# has no ``.get``, so an unguarded access would raise AttributeError → 500.
|
|
469
|
+
# Treat any non-object body as a missing preset → the existing 400 (#2126).
|
|
470
|
+
preset = request.data.get("preset") if isinstance(request.data, dict) else None
|
|
471
|
+
if preset not in ("signal_only", "everything"):
|
|
472
|
+
return Response(
|
|
473
|
+
{"detail": "preset must be 'signal_only' or 'everything'."},
|
|
474
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
475
|
+
)
|
|
476
|
+
# Wrap get_or_create → read → bulk_update in a single atomic block so a
|
|
477
|
+
# concurrent second apply_preset on the same user (e.g. rapid double-click)
|
|
478
|
+
# cannot interleave a partial write — either the full preset lands or none
|
|
479
|
+
# of it does (#1317 atomicity finding).
|
|
480
|
+
with transaction.atomic():
|
|
481
|
+
# Ensure the full row set exists before we toggle (a brand-new user may
|
|
482
|
+
# have no rows yet — same lazy backfill the list view performs).
|
|
483
|
+
get_or_create_default_preferences(user)
|
|
484
|
+
prefs = list(NotificationPreference.objects.filter(user=user))
|
|
485
|
+
if preset == "signal_only":
|
|
486
|
+
for p in prefs:
|
|
487
|
+
p.enabled = (
|
|
488
|
+
p.channel == NotificationChannel.IN_APP
|
|
489
|
+
and p.event_type in SIGNAL_ONLY_EVENTS
|
|
490
|
+
)
|
|
491
|
+
else: # everything
|
|
492
|
+
default_map = {(e, c): enabled for (e, c, enabled) in DEFAULT_PREFERENCES}
|
|
493
|
+
for p in prefs:
|
|
494
|
+
p.enabled = default_map.get((p.event_type, p.channel), p.enabled)
|
|
495
|
+
NotificationPreference.objects.bulk_update(prefs, ["enabled"])
|
|
496
|
+
serializer = self.get_serializer(self.get_queryset(), many=True)
|
|
497
|
+
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
# Project-scoped notification preferences (#522)
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
_VALID_EVENT_TYPES = {choice.value for choice in ProjectNotificationEventType}
|
|
506
|
+
_VALID_CHANNELS = {choice.value for choice in ProjectNotificationChannel}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _merge_matrix(
|
|
510
|
+
stored: dict[str, dict[str, bool]],
|
|
511
|
+
incoming: dict[str, dict[str, bool]],
|
|
512
|
+
) -> dict[str, dict[str, bool]]:
|
|
513
|
+
"""Merge `incoming` into a copy of `stored`, preferring incoming values.
|
|
514
|
+
|
|
515
|
+
The PATCH semantics are partial: a client toggling a single cell should
|
|
516
|
+
not have to repost the full 36-cell grid. The defaults dict is also
|
|
517
|
+
overlaid on the response so a freshly added event type is populated for
|
|
518
|
+
a user whose row predates the event.
|
|
519
|
+
|
|
520
|
+
Unknown event-type / channel keys, and non-bool leaves, are dropped (#675):
|
|
521
|
+
the serializer now rejects them on write, but a row persisted before that
|
|
522
|
+
validation shipped could still carry garbage. Filtering here keeps it out of
|
|
523
|
+
the GET response and the dispatcher's view even on an un-migrated row, and
|
|
524
|
+
matches the dispatcher's `_matrix_cell` so the echo and the effective value
|
|
525
|
+
agree — defense-in-depth on top of the one-shot cleanup migration.
|
|
526
|
+
"""
|
|
527
|
+
merged: dict[str, dict[str, bool]] = {
|
|
528
|
+
evt: dict(chans) for evt, chans in PROJECT_NOTIFICATION_DEFAULT_MATRIX.items()
|
|
529
|
+
}
|
|
530
|
+
for source in (stored, incoming):
|
|
531
|
+
for evt, chans in source.items():
|
|
532
|
+
if evt not in _VALID_EVENT_TYPES or not isinstance(chans, dict):
|
|
533
|
+
continue
|
|
534
|
+
row = merged.setdefault(evt, {})
|
|
535
|
+
for ch, enabled in chans.items():
|
|
536
|
+
if ch in _VALID_CHANNELS and isinstance(enabled, bool):
|
|
537
|
+
row[ch] = enabled
|
|
538
|
+
return merged
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class ProjectNotificationPreferenceView(IdempotencyMixin, APIView):
|
|
542
|
+
"""GET/PATCH per-project notification preferences for the current user.
|
|
543
|
+
|
|
544
|
+
Mounted at ``/api/v1/projects/<pk>/notification-preferences/``. Any project
|
|
545
|
+
member may read and update their own preferences — there is no admin
|
|
546
|
+
surface to edit another member's routing (the design intentionally keeps
|
|
547
|
+
this user-controlled per ADR-0075's "each user owns their notification
|
|
548
|
+
contract" rule).
|
|
549
|
+
|
|
550
|
+
GET returns a defaults-overlaid view so a new event type added on the
|
|
551
|
+
server is populated even for a user whose row pre-dates the addition.
|
|
552
|
+
PATCH accepts a partial matrix and merges it onto the stored document.
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
# Route is `projects/<pk>/…`, so `pk` names the PROJECT here (#2745). Without
|
|
556
|
+
# this the project-scoped classes below resolve nothing and `has_permission`
|
|
557
|
+
# returns True for everyone — the gate reads as live in review and enforces
|
|
558
|
+
# nothing. Declared per view because `pk` means something else on other routes.
|
|
559
|
+
project_url_kwarg = "pk"
|
|
560
|
+
permission_classes = [IsAuthenticated, IsProjectMember]
|
|
561
|
+
# Exempt from the archived-write invariant (#3414). The PATCH writes the CALLER'S
|
|
562
|
+
# OWN `ProjectNotificationPreference` row, not project state — ADR-0075's "each user
|
|
563
|
+
# owns their notification contract" rule. An archived project still emits
|
|
564
|
+
# notifications (mentions on its comments, digests), so a member must be able to
|
|
565
|
+
# silence it; gating this would freeze every member's routing at whatever it
|
|
566
|
+
# happened to be when the project was archived.
|
|
567
|
+
archived_write_exempt = (
|
|
568
|
+
"writes the caller's own per-project notification preference, not project "
|
|
569
|
+
"state; a member must still be able to silence an archived project"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def _get_project(self, request: Request, pk: str) -> Project:
|
|
573
|
+
project = get_object_or_404(Project, pk=pk, is_deleted=False)
|
|
574
|
+
self.check_object_permissions(request, project)
|
|
575
|
+
return project
|
|
576
|
+
|
|
577
|
+
def _get_or_create_pref(self, project: Project, user: Any) -> ProjectNotificationPreference:
|
|
578
|
+
pref, _ = ProjectNotificationPreference.objects.get_or_create(project=project, user=user)
|
|
579
|
+
# Prime the FK cache. get_or_create only populates it on the *create* branch
|
|
580
|
+
# (the get branch is a plain manager .get(), which never sets fields_cache), so
|
|
581
|
+
# on the common existing-row path `pref.project` would otherwise be a cold
|
|
582
|
+
# lazy load — and the quiet-hours timezone serializer field reads it (#3377).
|
|
583
|
+
pref.project = project
|
|
584
|
+
return pref
|
|
585
|
+
|
|
586
|
+
@extend_schema(responses={200: ProjectNotificationPreferenceDocumentSerializer})
|
|
587
|
+
def get(self, request: Request, pk: str) -> Response:
|
|
588
|
+
project = self._get_project(request, pk)
|
|
589
|
+
pref = self._get_or_create_pref(project, request.user)
|
|
590
|
+
# Overlay defaults so a newly added event type renders even when the
|
|
591
|
+
# stored matrix predates it.
|
|
592
|
+
merged = _merge_matrix(pref.matrix or {}, {})
|
|
593
|
+
# The document serializer, not the plain one: it carries `event_delivery` and
|
|
594
|
+
# `channel_delivery`, which are not model fields, and reading them from the
|
|
595
|
+
# same class the schema publishes is what keeps the two from drifting (#3396,
|
|
596
|
+
# #3399). See its docstring for why
|
|
597
|
+
# `responses={200: ProjectNotificationPreferenceSerializer}` would have been a
|
|
598
|
+
# self-consistent lie rather than a fix.
|
|
599
|
+
#
|
|
600
|
+
# `event_delivery` says which rows are actually wired to a dispatcher (#2904).
|
|
601
|
+
# Eight of the nine are not: the matrix rendered them, they defaulted ON
|
|
602
|
+
# across in-app, email and Slack, and toggling one had no effect in either
|
|
603
|
+
# direction. Reporting it as a server fact lets a client label those rows
|
|
604
|
+
# instead of implying a delivery that never happens — the alternative is
|
|
605
|
+
# every client hard-coding the same list and drifting from it (TODO(#3016)).
|
|
606
|
+
#
|
|
607
|
+
# `channel_delivery` is the same fact one axis over (#3378): two of the four
|
|
608
|
+
# columns have no delivery path at all. The web client was hard-coding
|
|
609
|
+
# exactly that list, with a comment saying it should not survive — which is
|
|
610
|
+
# the drift this endpoint exists to prevent (TODO(#3252)).
|
|
611
|
+
payload = ProjectNotificationPreferenceDocumentSerializer(pref).data
|
|
612
|
+
payload["matrix"] = merged
|
|
613
|
+
return Response(payload, status=status.HTTP_200_OK)
|
|
614
|
+
|
|
615
|
+
# Declared, or the operation publishes no ``requestBody`` and a generated client
|
|
616
|
+
# has no parameter to send the matrix through (#3364). A plain ``APIView`` has no
|
|
617
|
+
# ``serializer_class`` for drf-spectacular to infer a writable shape from, so
|
|
618
|
+
# nothing supplies this but the annotation — which is why the account-wide
|
|
619
|
+
# sibling below (``MyNotificationSettingsView.patch``) was declared and this one,
|
|
620
|
+
# the same shape one class up, was not.
|
|
621
|
+
@extend_schema(
|
|
622
|
+
request=ProjectNotificationPreferenceSerializer,
|
|
623
|
+
responses={200: ProjectNotificationPreferenceDocumentSerializer},
|
|
624
|
+
)
|
|
625
|
+
def patch(self, request: Request, pk: str) -> Response:
|
|
626
|
+
project = self._get_project(request, pk)
|
|
627
|
+
pref = self._get_or_create_pref(project, request.user)
|
|
628
|
+
serializer = ProjectNotificationPreferenceSerializer(pref, data=request.data, partial=True)
|
|
629
|
+
serializer.is_valid(raise_exception=True)
|
|
630
|
+
|
|
631
|
+
validated = serializer.validated_data
|
|
632
|
+
if "matrix" in validated:
|
|
633
|
+
# Merge so a partial cell update doesn't drop the rest of the grid.
|
|
634
|
+
merged = _merge_matrix(pref.matrix or {}, validated["matrix"])
|
|
635
|
+
pref.matrix = merged
|
|
636
|
+
|
|
637
|
+
for field in ("paused", "quiet_hours_enabled", "quiet_hours_from", "quiet_hours_until"):
|
|
638
|
+
if field in validated:
|
|
639
|
+
setattr(pref, field, validated[field])
|
|
640
|
+
pref.save()
|
|
641
|
+
|
|
642
|
+
# Built from the *bound* write serializer rather than re-reading through the
|
|
643
|
+
# document serializer, so the echo reflects what this request wrote. That is
|
|
644
|
+
# why `event_delivery` is added by hand here and not on GET — the two paths
|
|
645
|
+
# assemble differently but must publish the same document, which is what the
|
|
646
|
+
# response-conformance tests assert against the one declared component.
|
|
647
|
+
payload = serializer.data
|
|
648
|
+
payload["matrix"] = _merge_matrix(pref.matrix or {}, {})
|
|
649
|
+
# Same shape as GET. The web hook maps the PATCH response through the same
|
|
650
|
+
# deserializer and writes it to the query cache, so omitting either here would
|
|
651
|
+
# drop the "not delivered yet" labels the moment a user toggled anything.
|
|
652
|
+
payload["event_delivery"] = project_notification_event_delivery()
|
|
653
|
+
payload["channel_delivery"] = project_notification_channel_delivery()
|
|
654
|
+
return Response(payload, status=status.HTTP_200_OK)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
class MyNotificationSettingsView(IdempotencyMixin, APIView):
|
|
658
|
+
"""GET/PATCH the authenticated user's account-wide notification settings.
|
|
659
|
+
|
|
660
|
+
Mounted at ``/api/v1/me/notification-settings/`` — the authoritative read/write
|
|
661
|
+
surface for the Do-Not-Disturb switch (#1707, ADR-0292). The same
|
|
662
|
+
``dnd_enabled`` value is projected read-only onto ``/auth/me`` so the web
|
|
663
|
+
current-user query carries it without a second request.
|
|
664
|
+
|
|
665
|
+
Self-scoped by construction: the row is always ``request.user``'s own
|
|
666
|
+
(``get_or_create`` on the caller), there is no object id in the path, and there
|
|
667
|
+
is no way to address another user's settings — so there is no IDOR surface and
|
|
668
|
+
no admin edit path, mirroring ADR-0075's "each user owns their notification
|
|
669
|
+
contract" rule.
|
|
670
|
+
"""
|
|
671
|
+
|
|
672
|
+
permission_classes = [IsAuthenticated]
|
|
673
|
+
|
|
674
|
+
@extend_schema(responses={200: UserNotificationSettingsSerializer})
|
|
675
|
+
def get(self, request: Request) -> Response:
|
|
676
|
+
settings_row = get_or_create_notification_settings(request.user)
|
|
677
|
+
return Response(
|
|
678
|
+
UserNotificationSettingsSerializer(settings_row).data,
|
|
679
|
+
status=status.HTTP_200_OK,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
@extend_schema(
|
|
683
|
+
request=UserNotificationSettingsSerializer,
|
|
684
|
+
responses={200: UserNotificationSettingsSerializer},
|
|
685
|
+
)
|
|
686
|
+
def patch(self, request: Request) -> Response:
|
|
687
|
+
settings_row = get_or_create_notification_settings(request.user)
|
|
688
|
+
serializer = UserNotificationSettingsSerializer(
|
|
689
|
+
settings_row, data=request.data, partial=True
|
|
690
|
+
)
|
|
691
|
+
serializer.is_valid(raise_exception=True)
|
|
692
|
+
serializer.save()
|
|
693
|
+
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _email_settings_payload(obj: WorkspaceEmailSettings, *, can_edit: bool) -> dict[str, Any]:
|
|
697
|
+
"""Serialize the singleton for the client, adding the read-only status flags.
|
|
698
|
+
|
|
699
|
+
The password is never included (write-only serializer field); the response
|
|
700
|
+
carries only ``password_is_set``. ``configured_via`` and ``host_configured``
|
|
701
|
+
preserve back-compat with the #639 read-only status hook.
|
|
702
|
+
|
|
703
|
+
``frontend_base_url`` surfaces the install's public origin (env-only,
|
|
704
|
+
``TRUEPPM_FRONTEND_BASE_URL``) so the Email settings page can show it read-only
|
|
705
|
+
and warn when it is unset — when empty, every emailed invite/reset deep-link
|
|
706
|
+
renders as a bare origin-less path and silently breaks (#2015). The value is
|
|
707
|
+
infra config an admin can already see elsewhere, not a secret; it is not
|
|
708
|
+
writable from the UI (domain changes stay a deploy concern).
|
|
709
|
+
"""
|
|
710
|
+
data: dict[str, Any] = dict(WorkspaceEmailSettingsSerializer(obj).data)
|
|
711
|
+
data["can_edit"] = can_edit
|
|
712
|
+
data["configured_via"] = (
|
|
713
|
+
"database" if obj.transport_mode != EmailTransportMode.CLOUD else "environment"
|
|
714
|
+
)
|
|
715
|
+
data["host_configured"] = bool(obj.host)
|
|
716
|
+
frontend_base_url = settings.FRONTEND_BASE_URL.rstrip("/")
|
|
717
|
+
data["frontend_base_url"] = frontend_base_url
|
|
718
|
+
data["frontend_base_url_configured"] = bool(frontend_base_url)
|
|
719
|
+
return data
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
class WorkspaceEmailSettingsView(IdempotencyMixin, APIView):
|
|
723
|
+
"""``/api/v1/workspace/email-settings/`` — writable workspace SMTP config.
|
|
724
|
+
|
|
725
|
+
Upgrades the #639 read-only status page to the writable surface (#712,
|
|
726
|
+
ADR-0213). ``GET`` is gated ``IsWorkspaceAdminStrict`` — a *workspace* ADMIN,
|
|
727
|
+
not merely someone with ADMIN on one project — because the payload discloses
|
|
728
|
+
installation mail-transport posture (SMTP host, from-domain, delivery
|
|
729
|
+
limits), the same class of org-topology disclosure the SSO config
|
|
730
|
+
read is gated against (#2016; mirrors ``sso.views`` / ADR-0087 §6). **Writes**
|
|
731
|
+
(``PUT``/``PATCH``) still require the install operator (superuser) because the
|
|
732
|
+
transport is installation-global — a single-project admin must not be able to
|
|
733
|
+
repoint all outbound mail at an attacker relay (security review C1). The
|
|
734
|
+
password is write-only and never echoed. A save is rejected (400) if the
|
|
735
|
+
candidate transport can't be opened, so a bad config can't lock the workspace
|
|
736
|
+
out of mail (validate-before-persist, ADR-0213 §3).
|
|
737
|
+
"""
|
|
738
|
+
|
|
739
|
+
permission_classes = [IsAuthenticated, IsWorkspaceAdminStrict]
|
|
740
|
+
|
|
741
|
+
def get_permissions(self) -> list[BasePermission]:
|
|
742
|
+
if self.request.method in ("PUT", "PATCH"):
|
|
743
|
+
return [IsAuthenticated(), IsWorkspaceOperator()]
|
|
744
|
+
return [IsAuthenticated(), IsWorkspaceAdminStrict()]
|
|
745
|
+
|
|
746
|
+
def get_throttles(self) -> list[Any]:
|
|
747
|
+
# Throttle only the write path (each write re-opens a candidate SMTP
|
|
748
|
+
# connection); GET is a cheap read the settings page polls.
|
|
749
|
+
if self.request.method in ("PUT", "PATCH"):
|
|
750
|
+
throttle = ScopedRateThrottle()
|
|
751
|
+
throttle.scope = "email_settings"
|
|
752
|
+
return [throttle]
|
|
753
|
+
return super().get_throttles()
|
|
754
|
+
|
|
755
|
+
def get(self, request: Request) -> Response:
|
|
756
|
+
obj = WorkspaceEmailSettings.load()
|
|
757
|
+
return Response(_email_settings_payload(obj, can_edit=bool(request.user.is_superuser)))
|
|
758
|
+
|
|
759
|
+
# Both writes published no ``requestBody`` while ``_update`` reads one, so the
|
|
760
|
+
# settings page's own save had no documented shape (#3364). Declared per method
|
|
761
|
+
# rather than on the class so GET keeps taking no body. These two are the
|
|
762
|
+
# instances the first draft of the #3364 sweep could not see: the read is one
|
|
763
|
+
# frame down in ``_update``, and a handler-local search finds only a delegating
|
|
764
|
+
# one-liner — which is why that sweep now follows ``self._helper()`` calls.
|
|
765
|
+
@extend_schema(request=WorkspaceEmailSettingsSerializer)
|
|
766
|
+
def put(self, request: Request) -> Response:
|
|
767
|
+
return self._update(request, partial=False)
|
|
768
|
+
|
|
769
|
+
@extend_schema(request=WorkspaceEmailSettingsSerializer)
|
|
770
|
+
def patch(self, request: Request) -> Response:
|
|
771
|
+
return self._update(request, partial=True)
|
|
772
|
+
|
|
773
|
+
def _update(self, request: Request, *, partial: bool) -> Response:
|
|
774
|
+
obj = WorkspaceEmailSettings.load()
|
|
775
|
+
serializer = WorkspaceEmailSettingsSerializer(
|
|
776
|
+
obj, data=request.data, partial=partial, context={"request": request}
|
|
777
|
+
)
|
|
778
|
+
serializer.is_valid(raise_exception=True)
|
|
779
|
+
serializer.save()
|
|
780
|
+
obj.refresh_from_db()
|
|
781
|
+
return Response(_email_settings_payload(obj, can_edit=True))
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class WorkspaceEmailTestView(IdempotencyMixin, APIView):
|
|
785
|
+
"""``POST /api/v1/workspace/email-settings/send-test/`` — send a test email.
|
|
786
|
+
|
|
787
|
+
Sends a fixed test message to the **requesting operator's own address only**
|
|
788
|
+
(server-derived — never a recipient from the request body, which would make
|
|
789
|
+
this an authenticated open relay, security review M5) through the resolved
|
|
790
|
+
transport. Synchronous so the admin gets immediate pass/fail feedback; a
|
|
791
|
+
transport failure returns 502 with a generic message.
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
permission_classes = [IsAuthenticated, IsWorkspaceOperator]
|
|
795
|
+
throttle_classes = [ScopedRateThrottle]
|
|
796
|
+
throttle_scope = "email_settings_probe"
|
|
797
|
+
|
|
798
|
+
@extend_schema(
|
|
799
|
+
request=None,
|
|
800
|
+
responses={
|
|
801
|
+
200: OpenApiResponse(
|
|
802
|
+
response=inline_serializer(
|
|
803
|
+
name="WorkspaceEmailTestResult",
|
|
804
|
+
fields={
|
|
805
|
+
"sent": serializers.BooleanField(),
|
|
806
|
+
"recipient": serializers.EmailField(),
|
|
807
|
+
},
|
|
808
|
+
),
|
|
809
|
+
description="Test message sent to the requesting operator's own address.",
|
|
810
|
+
),
|
|
811
|
+
400: OpenApiResponse(
|
|
812
|
+
# NOT the standard ``{"detail"}`` refusal envelope: this endpoint
|
|
813
|
+
# answers with its own ``{"sent", "error"}`` pair on both the 400
|
|
814
|
+
# and the 502 so a client renders one banner either way. Declared
|
|
815
|
+
# as it is actually emitted rather than as the house shape, which
|
|
816
|
+
# is the whole point of checking each site (#3319).
|
|
817
|
+
response={
|
|
818
|
+
"type": "object",
|
|
819
|
+
"properties": {
|
|
820
|
+
"sent": {"type": "boolean", "enum": [False]},
|
|
821
|
+
"error": {"type": "string"},
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
description=(
|
|
825
|
+
"The requesting operator's account has no email address on file, "
|
|
826
|
+
"so there is no server-derived recipient to send to. The recipient "
|
|
827
|
+
"is never read from the request body."
|
|
828
|
+
),
|
|
829
|
+
),
|
|
830
|
+
502: OpenApiResponse(
|
|
831
|
+
response={
|
|
832
|
+
"type": "object",
|
|
833
|
+
"properties": {
|
|
834
|
+
"sent": {"type": "boolean", "enum": [False]},
|
|
835
|
+
"error": {"type": "string"},
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
description=(
|
|
839
|
+
"The configured transport refused or failed. The underlying "
|
|
840
|
+
"exception is deliberately not surfaced — it can echo credentials."
|
|
841
|
+
),
|
|
842
|
+
),
|
|
843
|
+
},
|
|
844
|
+
)
|
|
845
|
+
def post(self, request: Request) -> Response:
|
|
846
|
+
from django.core.mail import EmailMessage
|
|
847
|
+
|
|
848
|
+
from .email_backend import (
|
|
849
|
+
resolve_email_connection,
|
|
850
|
+
resolve_from_email,
|
|
851
|
+
resolve_reply_to,
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
recipient = (getattr(request.user, "email", "") or "").strip()
|
|
855
|
+
if not recipient:
|
|
856
|
+
return Response(
|
|
857
|
+
{"sent": False, "error": "Your account has no email address on file."},
|
|
858
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
obj = WorkspaceEmailSettings.load()
|
|
862
|
+
try:
|
|
863
|
+
connection = resolve_email_connection(obj)
|
|
864
|
+
EmailMessage(
|
|
865
|
+
subject="TruePPM test email",
|
|
866
|
+
body=(
|
|
867
|
+
"This is a test message from your TruePPM workspace email "
|
|
868
|
+
"configuration. If you received it, outbound mail is working."
|
|
869
|
+
),
|
|
870
|
+
from_email=resolve_from_email(obj),
|
|
871
|
+
to=[recipient],
|
|
872
|
+
reply_to=resolve_reply_to(obj) or None,
|
|
873
|
+
connection=connection,
|
|
874
|
+
).send(fail_silently=False)
|
|
875
|
+
except Exception:
|
|
876
|
+
# Never surface the underlying transport exception (may echo creds).
|
|
877
|
+
logger.warning("send-test: transport failed for %s", obj.transport_mode)
|
|
878
|
+
return Response(
|
|
879
|
+
{
|
|
880
|
+
"sent": False,
|
|
881
|
+
"error": "Could not send the test email. Check the transport configuration.",
|
|
882
|
+
},
|
|
883
|
+
status=status.HTTP_502_BAD_GATEWAY,
|
|
884
|
+
)
|
|
885
|
+
return Response({"sent": True, "recipient": recipient})
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
class WorkspaceEmailHealthView(APIView):
|
|
889
|
+
"""``GET /api/v1/workspace/email-settings/health/`` — SPF/DKIM/DMARC posture.
|
|
890
|
+
|
|
891
|
+
Live, bounded DNS TXT lookups on the persisted From-address domain (never a
|
|
892
|
+
domain from request input). Operator-gated and tightly throttled — the
|
|
893
|
+
lookups are an egress surface (ADR-0213 §4, security review M4/H3).
|
|
894
|
+
"""
|
|
895
|
+
|
|
896
|
+
permission_classes = [IsAuthenticated, IsWorkspaceOperator]
|
|
897
|
+
throttle_classes = [ScopedRateThrottle]
|
|
898
|
+
throttle_scope = "email_settings_probe"
|
|
899
|
+
|
|
900
|
+
def get(self, request: Request) -> Response:
|
|
901
|
+
from .email_health import check_deliverability
|
|
902
|
+
|
|
903
|
+
obj = WorkspaceEmailSettings.load()
|
|
904
|
+
return Response(check_deliverability(obj.from_email, obj.dkim_selector))
|